diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index ead0f9e..d5a4722 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -3,6 +3,27 @@ You need to prefix commands with `C:\msys64\msys2_shell.cmd -defterm -here -no-s
Prefix build directories with `cmake-build-`.
+The xbox build uses Unix Makefiles.
+
+The host native tests will use MinGW Makefiles on Windows, but Unix Makefiles on other platforms.
+
The project uses gtest as a test framework.
+Always add or update doxygen documentation.
+
+The project requires that everything be documented in doxygen or the build will fail.
+
+Primary doxygen comments should be done like so:
+
+```cpp
+ /**
+ * @brief Describe the function, structure, etc.
+ *
+ * @param my_param Describe the parameter.
+ * @return Describe the return.
+ */
+```
+
+Inline doxygen comments should use `///< ...` instead of `/**< ... */`.
+
Always follow the style guidelines defined in .clang-format for c/c++ code.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 00ddff9..b4ca6b6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -103,14 +103,15 @@ jobs:
update: true
install: >-
bison
- cmake
flex
git
make
+ mingw-w64-x86_64-cmake
mingw-w64-x86_64-clang
mingw-w64-x86_64-gcc
mingw-w64-x86_64-lld
mingw-w64-x86_64-llvm
+ mingw-w64-x86_64-make
- name: Setup python
id: setup-python
diff --git a/.gitignore b/.gitignore
index a7fe280..7b1afca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,5 +43,8 @@ cmake-*/
.local/
docs/doxyconfig*
+# test output
+test-output/
+
# Temporary files
*.cmd~
diff --git a/.gitmodules b/.gitmodules
index ee65c5b..c06338e 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -16,4 +16,8 @@
[submodule "third-party/openssl"]
path = third-party/openssl
url = https://github.com/openssl/openssl.git
+ branch = OpenSSL_1_1_1-stable
+[submodule "third-party/tomlplusplus"]
+ path = third-party/tomlplusplus
+ url = https://github.com/marzer/tomlplusplus.git
branch = master
diff --git a/.run/docs.run.xml b/.run/docs.run.xml
new file mode 100644
index 0000000..3552706
--- /dev/null
+++ b/.run/docs.run.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6811f1d..b76265b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -5,6 +5,7 @@ cmake_minimum_required(VERSION 3.18)
# Allow third-party subdirectories that use cmake_minimum_required < 3.5 (removed in CMake 4.x)
set(CMAKE_POLICY_VERSION_MINIMUM 3.5 CACHE STRING "")
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON CACHE BOOL "Export compile commands for IDE tooling" FORCE)
set(MOONLIGHT_BUILD_KIND "HOST" CACHE STRING "Internal Moonlight build mode")
set_property(CACHE MOONLIGHT_BUILD_KIND PROPERTY STRINGS HOST XBOX)
@@ -51,12 +52,14 @@ option(BUILD_XBOX "Build the Xbox target through an internal child configure" ON
option(MOONLIGHT_FORCE_NXDK_DISTCLEAN "Force a fresh nxdk distclean during configure" OFF)
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/sources.cmake")
+include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/moonlight-dependencies.cmake")
if(BUILD_DOCS)
add_subdirectory(third-party/doxyconfig docs)
endif()
if(BUILD_TESTS)
+ moonlight_prepare_common_dependencies()
enable_testing()
add_subdirectory(tests)
endif()
diff --git a/CMakePresets.json b/CMakePresets.json
index 0aa380b..db3717a 100644
--- a/CMakePresets.json
+++ b/CMakePresets.json
@@ -34,6 +34,40 @@
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Debug"
}
+ },
+ {
+ "name": "xbox-release (mingw64)",
+ "displayName": "Xbox Release (mingw64)",
+ "description": "Direct nxdk Xbox configure for IDE/code-model support on Xbox-only sources",
+ "generator": "Unix Makefiles",
+ "toolchainFile": "${sourceDir}/third-party/nxdk/share/toolchain-nxdk.cmake",
+ "binaryDir": "${sourceDir}/cmake-build-xbox-release",
+ "environment": {
+ "CHERE_INVOKING": "1",
+ "MSYSTEM": "MINGW64",
+ "NXDK_DIR": "${sourceDir}/third-party/nxdk"
+ },
+ "cacheVariables": {
+ "BUILD_DOCS": "OFF",
+ "CMAKE_BUILD_TYPE": "Release",
+ "CMAKE_DEPENDS_USE_COMPILER": "FALSE",
+ "CMAKE_EXPORT_COMPILE_COMMANDS": "ON",
+ "CMAKE_MAKE_PROGRAM": "C:/msys64/usr/bin/make.exe",
+ "CMAKE_TRY_COMPILE_TARGET_TYPE": "STATIC_LIBRARY",
+ "MOONLIGHT_BUILD_KIND": "XBOX",
+ "MOONLIGHT_SKIP_NXDK_PREP": "ON",
+ "NXDK_DIR": "${sourceDir}/third-party/nxdk"
+ }
+ },
+ {
+ "name": "xbox-debug (mingw64)",
+ "displayName": "Xbox Debug (mingw64)",
+ "description": "Direct nxdk Xbox configure for IDE/code-model support on Xbox-only sources",
+ "inherits": "xbox-release (mingw64)",
+ "binaryDir": "${sourceDir}/cmake-build-xbox-debug",
+ "cacheVariables": {
+ "CMAKE_BUILD_TYPE": "Debug"
+ }
}
],
"buildPresets": [
@@ -44,6 +78,14 @@
{
"name": "nxdk-debug (mingw64)",
"configurePreset": "nxdk-debug (mingw64)"
+ },
+ {
+ "name": "xbox-release (mingw64)",
+ "configurePreset": "xbox-release (mingw64)"
+ },
+ {
+ "name": "xbox-debug (mingw64)",
+ "configurePreset": "xbox-debug (mingw64)"
}
]
}
diff --git a/README.md b/README.md
index b47918d..d0e3c96 100644
--- a/README.md
+++ b/README.md
@@ -7,9 +7,12 @@
Port of Moonlight for the Original Xbox. Unlikely to ever actually work. Do NOT use!
-Nothing works, except the splash screen.
+> [!WARNING]
+> Streaming does not work yet.
-
+
+
+
## Build
@@ -33,14 +36,15 @@ Nothing works, except the splash screen.
pacman -Syu
nxdk_dependencies=(
"bison"
- "cmake"
"flex"
"git"
"make"
+ "mingw-w64-x86_64-cmake"
"mingw-w64-x86_64-clang"
"mingw-w64-x86_64-gcc"
"mingw-w64-x86_64-lld"
"mingw-w64-x86_64-llvm"
+ "mingw-w64-x86_64-make"
)
moonlight_dependencies=(
"mingw-w64-x86_64-doxygen"
@@ -188,6 +192,10 @@ If you only want the emulator without the ROM/HDD support bundle, run:
scripts\setup-xemu.cmd --skip-support-files
```
+> [!NOTE]
+> You can set Xemu to use widescreen mode by using https://github.com/Ernegien/XboxEepromEditor
+> but 1080i does not work in Xemu.
+
## Todo
- Build
@@ -204,18 +212,19 @@ scripts\setup-xemu.cmd --skip-support-files
- [x] Enable sonarcloud
- [x] Build moonlight-common-c
- [x] Build custom enet
+ - [x] Docs via doxygen
- Menus / Screens
- [x] Loading/splash screen
- [x] Initial loading screen, see https://github.com/XboxDev/nxdk/blob/master/samples/sdl_image/main.c
- [x] Set video mode based on the best available mode
- [x] dynamic splash screen (size based on current resolution)
- [x] simplify (draw background color and overlay logo) to reduce total size
- - [ ] Main/Home
- - [ ] Settings
- - [ ] Add Host
- - [ ] Game/App Selection
- - [ ] Host Details
- - [ ] App Details
+ - [x] Main/Home
+ - [x] Settings
+ - [x] Add Host
+ - [x] Game/App Selection
+ - [x] Host Details
+ - [x] App Details
- [ ] Pause/Hotkey overlay
- Streaming
- [ ] Video - https://www.xbmc4xbox.org.uk/wiki/XBMC_Features_and_Supported_Formats#Xbox_supported_video_formats_and_resolutions
@@ -230,10 +239,9 @@ scripts\setup-xemu.cmd --skip-support-files
- [ ] Mouse Input
- [ ] Mouse Emulation via Gamepad
- Misc.
- - [ ] Save config and pairing states, probably use nlohmann/json
- - [ ] Host pairing
+ - [x] Save config and pairing states
+ - [x] Host pairing
- [ ] Possibly, GPU overclocking, see https://github.com/GXTX/XboxOverclock
- - [x] Docs via doxygen
diff --git a/build.sh b/build.sh
index 9331d26..9f5e478 100644
--- a/build.sh
+++ b/build.sh
@@ -63,13 +63,32 @@ if [[ "${CLEAN_BUILD}" -eq 1 ]]; then
rm -rf "${BUILD_DIR_PATH}"
fi
-cmake \
- -S . \
- -B "${BUILD_DIR_PATH}" \
- -DBUILD_DOCS=OFF \
- -DBUILD_TESTS=ON \
- -DBUILD_XBOX=ON \
- -DCMAKE_DEPENDS_USE_COMPILER=FALSE \
+cmake_configure_args=(
+ -S .
+ -B "${BUILD_DIR_PATH}"
+ -DBUILD_DOCS=OFF
+ -DBUILD_TESTS=ON
+ -DBUILD_XBOX=ON
+ -DCMAKE_DEPENDS_USE_COMPILER=FALSE
-DCMAKE_BUILD_TYPE=Release
+)
+
+case "$(uname -s)" in
+ MINGW*|MSYS*|CYGWIN*)
+ if ! cmake --help 2>/dev/null | grep -q "MinGW Makefiles"; then
+ echo "Windows builds require a CMake that supports MinGW Makefiles. Install the MSYS2 package 'mingw-w64-x86_64-cmake'."
+ exit 1
+ fi
+
+ cmake_configure_args+=(
+ -G "MinGW Makefiles"
+ -DCMAKE_TOOLCHAIN_FILE="${PROJECT_ROOT}/cmake/host-mingw64-clang.cmake"
+ )
+ ;;
+ *)
+ ;;
+esac
+
+cmake "${cmake_configure_args[@]}"
cmake --build "${BUILD_DIR_PATH}"
diff --git a/cmake/modules/FindNXDK.cmake b/cmake/modules/FindNXDK.cmake
index 444552b..742ff1b 100644
--- a/cmake/modules/FindNXDK.cmake
+++ b/cmake/modules/FindNXDK.cmake
@@ -62,13 +62,6 @@ if(NOT TARGET NXDK::NXDK)
IMPORTED_LOCATION "${NXDK_DIR}/lib/winmm.lib"
)
- add_library(ws2_32 STATIC IMPORTED)
- set_target_properties(
- ws2_32
- PROPERTIES
- IMPORTED_LOCATION "${NXDK_DIR}/lib/ws2_32.lib"
- )
-
add_library(xboxrt STATIC IMPORTED)
set_target_properties(
xboxrt
@@ -130,17 +123,3 @@ if (NOT TARGET NXDK::Net)
"${NXDK_DIR}/lib/net/nvnetdrv"
)
endif ()
-
-if (NOT TARGET NXDK::ws2_32)
- add_library(NXDK::ws2_32 INTERFACE IMPORTED)
- target_link_libraries(
- NXDK::ws2_32
- INTERFACE
- ws2_32
- )
- target_include_directories(
- NXDK::ws2_32
- SYSTEM INTERFACE
- "${NXDK_DIR}/lib/winapi/ws2_32"
- )
-endif ()
diff --git a/cmake/modules/FindNXDK_SDL2_TTF.cmake b/cmake/modules/FindNXDK_SDL2_TTF.cmake
new file mode 100644
index 0000000..befa6c7
--- /dev/null
+++ b/cmake/modules/FindNXDK_SDL2_TTF.cmake
@@ -0,0 +1,29 @@
+if(NOT TARGET NXDK::SDL2_TTF)
+ add_library(nxdk_sdl2_ttf STATIC IMPORTED)
+ set_target_properties(
+ nxdk_sdl2_ttf
+ PROPERTIES
+ IMPORTED_LOCATION "${NXDK_DIR}/lib/libSDL_ttf.lib"
+ )
+
+ add_library(nxdk_freetype STATIC IMPORTED)
+ set_target_properties(
+ nxdk_freetype
+ PROPERTIES
+ IMPORTED_LOCATION "${NXDK_DIR}/lib/libfreetype.lib"
+ )
+
+ add_library(NXDK::SDL2_TTF INTERFACE IMPORTED)
+ target_link_libraries(
+ NXDK::SDL2_TTF
+ INTERFACE
+ nxdk_sdl2_ttf
+ nxdk_freetype
+ )
+ target_include_directories(
+ NXDK::SDL2_TTF
+ SYSTEM INTERFACE
+ "${NXDK_DIR}/lib/sdl/SDL_ttf"
+ "${NXDK_DIR}/lib/sdl/SDL_ttf/external/freetype-2.4.12/include"
+ )
+endif()
diff --git a/cmake/modules/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake
index 7ba8bc7..8074599 100644
--- a/cmake/modules/GetOpenSSL.cmake
+++ b/cmake/modules/GetOpenSSL.cmake
@@ -1,13 +1,59 @@
include_guard(GLOBAL)
include(ExternalProject)
+include("${CMAKE_CURRENT_LIST_DIR}/../msys2.cmake")
-set(OPENSSL_VERSION 3.2.2)
+set(MOONLIGHT_OPENSSL_MODE "BUNDLED" CACHE STRING "How to provide OpenSSL for Moonlight: BUNDLED" FORCE)
+set_property(CACHE MOONLIGHT_OPENSSL_MODE PROPERTY STRINGS BUNDLED)
+
+set(OPENSSL_VERSION 1.1.1w)
set(OPENSSL_SOURCE_DIR "${CMAKE_SOURCE_DIR}/third-party/openssl")
set(OPENSSL_BUILD_ROOT "${CMAKE_BINARY_DIR}/third-party/openssl")
set(OPENSSL_BUILD_DIR "${OPENSSL_BUILD_ROOT}/build")
set(OPENSSL_INSTALL_DIR "${OPENSSL_BUILD_ROOT}/install")
+set(MOONLIGHT_OPENSSL_MODE "BUNDLED")
+
+if(MOONLIGHT_BUILD_KIND STREQUAL "XBOX")
+ set(MOONLIGHT_OPENSSL_PLATFORM "XBOX")
+else()
+ set(MOONLIGHT_OPENSSL_PLATFORM "HOST")
+endif()
+string(TOLOWER "${MOONLIGHT_OPENSSL_PLATFORM}" MOONLIGHT_OPENSSL_PLATFORM_LOWER)
+set(MOONLIGHT_OPENSSL_EXTERNAL_TARGET "openssl_external_${MOONLIGHT_OPENSSL_PLATFORM_LOWER}")
+
+set(MOONLIGHT_OPENSSL_PROVIDER "BUNDLED")
+
+# Convert a Windows path to an MSYS2-style path (e.g. C:/path -> /c/path) for use in MSYS2 shell commands.
+function(_moonlight_to_msys_path out_var path)
+ file(TO_CMAKE_PATH "${path}" normalized_path)
+
+ if(normalized_path MATCHES "^([A-Za-z]):/(.*)$")
+ string(TOLOWER "${CMAKE_MATCH_1}" _drive)
+ set(normalized_path "/${_drive}/${CMAKE_MATCH_2}")
+ endif()
+
+ set(${out_var} "${normalized_path}" PARENT_SCOPE)
+endfunction()
+
+# Quote a string for safe inclusion in a shell command
+function(_moonlight_shell_quote out_var value)
+ string(REPLACE "'" "'\"'\"'" _escaped_value "${value}")
+ set(${out_var} "'${_escaped_value}'" PARENT_SCOPE)
+endfunction()
+
+# Join a list of arguments into a single shell command string, quoting each argument as needed
+function(_moonlight_join_shell_command out_var)
+ set(quoted_args)
+ foreach(arg IN LISTS ARGN)
+ _moonlight_shell_quote(_quoted_arg "${arg}")
+ list(APPEND quoted_args "${_quoted_arg}")
+ endforeach()
+
+ list(JOIN quoted_args " " command)
+ set(${out_var} "${command}" PARENT_SCOPE)
+endfunction()
+
file(MAKE_DIRECTORY "${OPENSSL_BUILD_DIR}")
file(MAKE_DIRECTORY "${OPENSSL_INSTALL_DIR}/include")
file(MAKE_DIRECTORY "${OPENSSL_INSTALL_DIR}/lib")
@@ -18,68 +64,218 @@ if(NOT EXISTS "${OPENSSL_SOURCE_DIR}/Configure")
endif()
find_program(PERL_EXECUTABLE perl REQUIRED)
-find_program(OPENSSL_MAKE_EXECUTABLE NAMES make REQUIRED)
-
-set(OPENSSL_CPPFLAGS_LIST
- -UWIN32
- -U_WIN32
- -DNO_SYSLOG
- -DOPENSSL_NO_SYSLOG
- -D_exit=_Exit
- "-I${NXDK_DIR}/lib"
- "-I${NXDK_DIR}/lib/xboxrt/libc_extensions"
- "-I${NXDK_DIR}/lib/pdclib/include"
- "-I${NXDK_DIR}/lib/pdclib/platform/xbox/include"
- "-I${NXDK_DIR}/lib/winapi"
- "-I${NXDK_DIR}/lib/xboxrt/vcruntime"
- "-I${NXDK_DIR}/lib/net/lwip/src/include"
- "-I${NXDK_DIR}/lib/net/nforceif/include"
-)
-list(JOIN OPENSSL_CPPFLAGS_LIST " " OPENSSL_CPPFLAGS)
+set(OPENSSL_CONFIGURE_OPTIONS
+ no-shared
+ no-tests
+ no-asm
+ no-comp
+ no-threads
+ no-afalgeng
+ no-capieng
+ no-ui-console
+ no-ocsp
+ no-srp
+ no-pic
+ no-async
+ no-dso)
set(OPENSSL_ENV
${CMAKE_COMMAND} -E env
- "NXDK_DIR=${NXDK_DIR}"
- "CC=${NXDK_DIR}/bin/nxdk-cc"
- "CXX=${NXDK_DIR}/bin/nxdk-cxx"
- "AR=llvm-ar"
- "RANLIB=llvm-ranlib"
- "CPPFLAGS=${OPENSSL_CPPFLAGS}")
-
-ExternalProject_Add(openssl_external
- SOURCE_DIR "${OPENSSL_SOURCE_DIR}"
- BINARY_DIR "${OPENSSL_BUILD_DIR}"
- CONFIGURE_COMMAND
- ${OPENSSL_ENV}
- "${PERL_EXECUTABLE}" "${OPENSSL_SOURCE_DIR}/Configure"
- linux-x86
- no-shared
- no-tests
- no-asm
- no-apps
- no-comp
+ "MAKEFLAGS="
+ "MFLAGS="
+ "GNUMAKEFLAGS="
+ "MAKELEVEL=")
+set(MOONLIGHT_OPENSSL_WINDOWS_HOST FALSE)
+if(CMAKE_HOST_WIN32)
+ set(MOONLIGHT_OPENSSL_WINDOWS_HOST TRUE)
+endif()
+set(MOONLIGHT_OPENSSL_IN_ACTIVE_MSYS FALSE)
+if(MOONLIGHT_OPENSSL_WINDOWS_HOST
+ AND DEFINED ENV{MSYSTEM_PREFIX}
+ AND NOT "$ENV{MSYSTEM_PREFIX}" STREQUAL "")
+ set(MOONLIGHT_OPENSSL_IN_ACTIVE_MSYS TRUE)
+endif()
+set(OPENSSL_MAKE_ARGS)
+if(MOONLIGHT_OPENSSL_WINDOWS_HOST)
+ list(APPEND OPENSSL_MAKE_ARGS -j1)
+endif()
+
+if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "XBOX")
+ find_program(OPENSSL_MAKE_EXECUTABLE NAMES make REQUIRED)
+
+ set(OPENSSL_CONFIGURE_TARGET linux-x86)
+ list(APPEND OPENSSL_CONFIGURE_OPTIONS
no-sock
no-dgram
- no-posix-io
- no-threads
- no-afalgeng
- no-capieng
- no-ui-console
- no-http
- no-ocsp
- no-srp
- no-pic
- no-async
- no-dso
- --with-rand-seed=none
- "--prefix=${OPENSSL_INSTALL_DIR}"
- "--openssldir=${OPENSSL_INSTALL_DIR}/ssl"
- BUILD_COMMAND
+ --with-rand-seed=none)
+
+ set(OPENSSL_CPPFLAGS_LIST
+ -UWIN32
+ -U_WIN32
+ -DNO_SYSLOG
+ -DOPENSSL_NO_SYSLOG
+ -Dstrcasecmp=_stricmp
+ -Dstrncasecmp=_strnicmp
+ -D_stat=stat
+ -D_fstat=fstat
+ -D_exit=_Exit
+ -include
+ "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/openssl_compat.h"
+ "-I${NXDK_DIR}/lib"
+ "-I${NXDK_DIR}/lib/net"
+ "-I${NXDK_DIR}/lib/xboxrt/libc_extensions"
+ "-I${NXDK_DIR}/lib/pdclib/include"
+ "-I${NXDK_DIR}/lib/pdclib/platform/xbox/include"
+ "-I${NXDK_DIR}/lib/winapi"
+ "-I${NXDK_DIR}/lib/xboxrt/vcruntime"
+ "-I${NXDK_DIR}/lib/net/lwip/src/include"
+ "-I${NXDK_DIR}/lib/net/lwip/src/include/compat/posix"
+ "-I${NXDK_DIR}/lib/net/nforceif/include"
+ )
+ list(JOIN OPENSSL_CPPFLAGS_LIST " " OPENSSL_CPPFLAGS)
+
+ list(APPEND OPENSSL_ENV
+ "NXDK_DIR=${NXDK_DIR}"
+ "CC=${NXDK_DIR}/bin/nxdk-cc"
+ "CXX=${NXDK_DIR}/bin/nxdk-cxx"
+ "AR=llvm-ar"
+ "RANLIB=llvm-ranlib"
+ "CPPFLAGS=${OPENSSL_CPPFLAGS}")
+
+ set(OPENSSL_BUILD_COMMAND
${OPENSSL_ENV}
${OPENSSL_MAKE_EXECUTABLE}
- INSTALL_COMMAND
+ ${OPENSSL_MAKE_ARGS}
+ build_libs)
+ set(OPENSSL_INSTALL_COMMAND
+ ${OPENSSL_ENV}
+ ${OPENSSL_MAKE_EXECUTABLE}
+ ${OPENSSL_MAKE_ARGS}
+ install_dev)
+else()
+ if(MOONLIGHT_OPENSSL_WINDOWS_HOST)
+ if(CMAKE_SIZEOF_VOID_P EQUAL 8)
+ set(OPENSSL_CONFIGURE_TARGET mingw64)
+ else()
+ set(OPENSSL_CONFIGURE_TARGET mingw)
+ endif()
+
+ moonlight_get_windows_msys2_shell(OPENSSL_MSYS2_SHELL)
+
+ _moonlight_to_msys_path(_openssl_source_dir_msys "${OPENSSL_SOURCE_DIR}")
+ _moonlight_to_msys_path(_openssl_build_dir_msys "${OPENSSL_BUILD_DIR}")
+ _moonlight_to_msys_path(_openssl_install_dir_msys "${OPENSSL_INSTALL_DIR}")
+ _moonlight_to_msys_path(_perl_executable_msys "${PERL_EXECUTABLE}")
+ get_filename_component(_openssl_c_compiler_name "${CMAKE_C_COMPILER}" NAME)
+ get_filename_component(_openssl_cxx_compiler_name "${CMAKE_CXX_COMPILER}" NAME)
+ set(_openssl_tool_assignments
+ "MAKEFLAGS="
+ "MFLAGS="
+ "GNUMAKEFLAGS="
+ "MAKELEVEL="
+ "CC=${_openssl_c_compiler_name}"
+ "CXX=${_openssl_cxx_compiler_name}"
+ "CFLAGS=-DNOCRYPT"
+ "CPPFLAGS=-DWIN32_LEAN_AND_MEAN")
+ if(DEFINED CMAKE_AR AND NOT CMAKE_AR STREQUAL "")
+ get_filename_component(_openssl_ar_name "${CMAKE_AR}" NAME)
+ list(APPEND _openssl_tool_assignments "AR=${_openssl_ar_name}")
+ endif()
+ if(DEFINED CMAKE_RANLIB AND NOT CMAKE_RANLIB STREQUAL "")
+ get_filename_component(_openssl_ranlib_name "${CMAKE_RANLIB}" NAME)
+ list(APPEND _openssl_tool_assignments "RANLIB=${_openssl_ranlib_name}")
+ endif()
+ list(JOIN _openssl_tool_assignments " " _openssl_tool_prefix)
+ _moonlight_join_shell_command(_openssl_configure_command
+ "${_perl_executable_msys}"
+ "${_openssl_source_dir_msys}/Configure"
+ "${OPENSSL_CONFIGURE_TARGET}"
+ ${OPENSSL_CONFIGURE_OPTIONS}
+ "--prefix=${_openssl_install_dir_msys}"
+ "--openssldir=${_openssl_install_dir_msys}/ssl")
+ _moonlight_shell_quote(_openssl_build_dir_msys_quoted "${_openssl_build_dir_msys}")
+ set(OPENSSL_CONFIGURE_COMMAND
+ "${OPENSSL_MSYS2_SHELL}"
+ -defterm -here -no-start -mingw64
+ -c "cd ${_openssl_build_dir_msys_quoted} && ${_openssl_tool_prefix} exec ${_openssl_configure_command}")
+ set(OPENSSL_BUILD_COMMAND
+ "${OPENSSL_MSYS2_SHELL}"
+ -defterm -here -no-start -mingw64
+ -c "cd ${_openssl_build_dir_msys_quoted} && ${_openssl_tool_prefix} exec make -j1 build_libs")
+ set(OPENSSL_INSTALL_COMMAND
+ "${OPENSSL_MSYS2_SHELL}"
+ -defterm -here -no-start -mingw64
+ -c "cd ${_openssl_build_dir_msys_quoted} && ${_openssl_tool_prefix} exec make -j1 install_dev")
+ elseif(APPLE)
+ find_program(OPENSSL_MAKE_EXECUTABLE NAMES gmake make REQUIRED)
+
+ if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(arm64|aarch64)$")
+ set(OPENSSL_CONFIGURE_TARGET darwin64-arm64-cc)
+ elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")
+ set(OPENSSL_CONFIGURE_TARGET darwin64-x86_64-cc)
+ else()
+ message(FATAL_ERROR
+ "Unsupported macOS processor '${CMAKE_SYSTEM_PROCESSOR}' for bundled OpenSSL. "
+ "Use MOONLIGHT_OPENSSL_MODE=SYSTEM or add a Configure target mapping.")
+ endif()
+ elseif(UNIX)
+ find_program(OPENSSL_MAKE_EXECUTABLE NAMES gmake make REQUIRED)
+
+ if(CMAKE_SIZEOF_VOID_P EQUAL 8)
+ set(OPENSSL_CONFIGURE_TARGET linux-generic64)
+ else()
+ set(OPENSSL_CONFIGURE_TARGET linux-generic32)
+ endif()
+ else()
+ message(FATAL_ERROR
+ "Unsupported host platform for bundled OpenSSL. "
+ "Use MOONLIGHT_OPENSSL_MODE=SYSTEM or add a Configure target mapping.")
+ endif()
+
+ if(NOT MOONLIGHT_OPENSSL_WINDOWS_HOST)
+ list(APPEND OPENSSL_ENV
+ "CC=${CMAKE_C_COMPILER}"
+ "CXX=${CMAKE_CXX_COMPILER}")
+
+ if(DEFINED CMAKE_AR AND NOT CMAKE_AR STREQUAL "")
+ list(APPEND OPENSSL_ENV "AR=${CMAKE_AR}")
+ endif()
+ if(DEFINED CMAKE_RANLIB AND NOT CMAKE_RANLIB STREQUAL "")
+ list(APPEND OPENSSL_ENV "RANLIB=${CMAKE_RANLIB}")
+ endif()
+
+ set(OPENSSL_BUILD_COMMAND
+ ${OPENSSL_ENV}
+ ${OPENSSL_MAKE_EXECUTABLE}
+ ${OPENSSL_MAKE_ARGS}
+ build_libs)
+ set(OPENSSL_INSTALL_COMMAND
+ ${OPENSSL_ENV}
+ ${OPENSSL_MAKE_EXECUTABLE}
+ ${OPENSSL_MAKE_ARGS}
+ install_dev)
+ endif()
+endif()
+
+if(NOT DEFINED OPENSSL_CONFIGURE_COMMAND)
+ set(OPENSSL_CONFIGURE_COMMAND
${OPENSSL_ENV}
- ${OPENSSL_MAKE_EXECUTABLE} install_sw
+ "${PERL_EXECUTABLE}" "${OPENSSL_SOURCE_DIR}/Configure"
+ ${OPENSSL_CONFIGURE_TARGET}
+ ${OPENSSL_CONFIGURE_OPTIONS}
+ "--prefix=${OPENSSL_INSTALL_DIR}"
+ "--openssldir=${OPENSSL_INSTALL_DIR}/ssl")
+endif()
+
+ExternalProject_Add(${MOONLIGHT_OPENSSL_EXTERNAL_TARGET}
+ SOURCE_DIR "${OPENSSL_SOURCE_DIR}"
+ BINARY_DIR "${OPENSSL_BUILD_DIR}"
+ CONFIGURE_COMMAND
+ ${OPENSSL_CONFIGURE_COMMAND}
+ BUILD_COMMAND
+ ${OPENSSL_BUILD_COMMAND}
+ INSTALL_COMMAND
+ ${OPENSSL_INSTALL_COMMAND}
BUILD_BYPRODUCTS
"${OPENSSL_INSTALL_DIR}/lib/libcrypto.a"
"${OPENSSL_INSTALL_DIR}/lib/libssl.a"
@@ -104,7 +300,10 @@ if(NOT TARGET OpenSSL::Crypto)
set_target_properties(OpenSSL::Crypto PROPERTIES
IMPORTED_LOCATION "${OPENSSL_CRYPTO_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${OPENSSL_INCLUDE_DIR}")
- add_dependencies(OpenSSL::Crypto openssl_external)
+ if(WIN32 AND MOONLIGHT_OPENSSL_PLATFORM STREQUAL "HOST")
+ target_link_libraries(OpenSSL::Crypto INTERFACE ws2_32)
+ endif()
+ add_dependencies(OpenSSL::Crypto ${MOONLIGHT_OPENSSL_EXTERNAL_TARGET})
endif()
if(NOT TARGET OpenSSL::SSL)
@@ -113,8 +312,12 @@ if(NOT TARGET OpenSSL::SSL)
IMPORTED_LOCATION "${OPENSSL_SSL_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${OPENSSL_INCLUDE_DIR}")
target_link_libraries(OpenSSL::SSL INTERFACE OpenSSL::Crypto)
- add_dependencies(OpenSSL::SSL openssl_external)
+ add_dependencies(OpenSSL::SSL ${MOONLIGHT_OPENSSL_EXTERNAL_TARGET})
endif()
+message(STATUS "OpenSSL version: ${OPENSSL_VERSION}")
+message(STATUS "OpenSSL platform: ${MOONLIGHT_OPENSSL_PLATFORM}")
+message(STATUS "OpenSSL external target: ${MOONLIGHT_OPENSSL_EXTERNAL_TARGET}")
+message(STATUS "OpenSSL provider: ${MOONLIGHT_OPENSSL_PROVIDER}")
message(STATUS "OpenSSL source dir: ${OPENSSL_SOURCE_DIR}")
message(STATUS "OpenSSL include dir: ${OPENSSL_INCLUDE_DIR}")
diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake
new file mode 100644
index 0000000..5ab1553
--- /dev/null
+++ b/cmake/moonlight-dependencies.cmake
@@ -0,0 +1,78 @@
+include_guard(GLOBAL)
+
+# Prepare dependencies that are common to multiple Moonlight components
+macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES)
+ include(GetOpenSSL REQUIRED)
+
+ if(NOT TARGET moonlight-openssl)
+ add_library(moonlight-openssl INTERFACE)
+ add_library(Moonlight::OpenSSL ALIAS moonlight-openssl)
+ target_link_libraries(moonlight-openssl
+ INTERFACE
+ OpenSSL::SSL
+ OpenSSL::Crypto)
+ endif()
+
+ set(ENET_NO_INSTALL ON CACHE BOOL "Do not install libraries built for enet" FORCE)
+
+ set(_moonlight_restore_build_shared_libs FALSE)
+ if(DEFINED BUILD_SHARED_LIBS)
+ set(_moonlight_restore_build_shared_libs TRUE)
+ set(_moonlight_saved_build_shared_libs "${BUILD_SHARED_LIBS}")
+ endif()
+
+ set(BUILD_SHARED_LIBS OFF)
+
+ if(NOT TARGET moonlight-common-c)
+ add_subdirectory(
+ "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c"
+ "${CMAKE_BINARY_DIR}/third-party/moonlight-common-c"
+ )
+ endif()
+
+ if(_moonlight_restore_build_shared_libs)
+ set(BUILD_SHARED_LIBS "${_moonlight_saved_build_shared_libs}")
+ else()
+ unset(BUILD_SHARED_LIBS)
+ endif()
+
+ if(TARGET moonlight-common-c AND DEFINED MOONLIGHT_OPENSSL_EXTERNAL_TARGET)
+ if(TARGET ${MOONLIGHT_OPENSSL_EXTERNAL_TARGET})
+ add_dependencies(moonlight-common-c ${MOONLIGHT_OPENSSL_EXTERNAL_TARGET})
+ endif()
+ endif()
+
+ if(TARGET moonlight-common-c
+ AND CMAKE_C_COMPILER_ID STREQUAL "GNU")
+ target_compile_options(moonlight-common-c PRIVATE -Wno-error=cast-function-type)
+ endif()
+
+ if(MOONLIGHT_BUILD_KIND STREQUAL "XBOX")
+ if(NOT DEFINED NXDK_DIR OR NXDK_DIR STREQUAL "")
+ message(FATAL_ERROR "NXDK_DIR must be defined before preparing Xbox dependencies")
+ endif()
+
+ set(MOONLIGHT_NXDK_NET_INCLUDE_DIR "${NXDK_DIR}/lib/net")
+ set(MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR "${NXDK_DIR}/lib/xboxrt/libc_extensions")
+ set(MOONLIGHT_NXDK_LWIP_POSIX_COMPAT_DIR "${NXDK_DIR}/lib/net/lwip/src/include/compat/posix")
+
+ if(TARGET enet)
+ target_link_libraries(enet PUBLIC NXDK::NXDK NXDK::Net)
+ target_include_directories(enet PRIVATE
+ "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}"
+ "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}"
+ "${MOONLIGHT_NXDK_LWIP_POSIX_COMPAT_DIR}")
+ target_compile_options(enet PRIVATE -Wno-unused-function -Wno-error=unused-function)
+ endif()
+
+ if(TARGET moonlight-common-c)
+ target_include_directories(moonlight-common-c PRIVATE
+ "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}"
+ "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}"
+ "${MOONLIGHT_NXDK_LWIP_POSIX_COMPAT_DIR}")
+ target_compile_options(moonlight-common-c PRIVATE
+ -Wno-unused-function
+ -Wno-error=unused-function)
+ endif()
+ endif()
+endmacro()
diff --git a/cmake/msys2.cmake b/cmake/msys2.cmake
index 4065649..78a2ae4 100644
--- a/cmake/msys2.cmake
+++ b/cmake/msys2.cmake
@@ -56,19 +56,30 @@ function(_moonlight_try_msys2_root_from_tool out_var tool_path)
set(${out_var} "" PARENT_SCOPE)
endfunction()
-# Detect the MSYS2 installation root on Windows and cache the resolved path.
-function(moonlight_detect_windows_msys2_root out_var)
- if(NOT WIN32)
- message(FATAL_ERROR "moonlight_detect_windows_msys2_root is only available on Windows hosts")
- endif()
+# Cache one resolved MSYS2 root path.
+function(_moonlight_cache_detected_msys2_root resolved_root)
+ set(MOONLIGHT_MSYS2_ROOT "${resolved_root}" CACHE PATH "Path to the detected MSYS2 installation" FORCE)
+endfunction()
+# Build the configured MSYS2 root candidates from cache and environment overrides.
+function(_moonlight_get_configured_msys2_root_candidates out_var)
set(candidate_roots)
if(DEFINED MOONLIGHT_MSYS2_ROOT AND NOT MOONLIGHT_MSYS2_ROOT STREQUAL "")
list(APPEND candidate_roots "${MOONLIGHT_MSYS2_ROOT}")
endif()
+ if(DEFINED ENV{MOONLIGHT_MSYS2_ROOT} AND NOT "$ENV{MOONLIGHT_MSYS2_ROOT}" STREQUAL "")
+ list(APPEND candidate_roots "$ENV{MOONLIGHT_MSYS2_ROOT}")
+ endif()
if(DEFINED ENV{MSYS2_ROOT} AND NOT "$ENV{MSYS2_ROOT}" STREQUAL "")
list(APPEND candidate_roots "$ENV{MSYS2_ROOT}")
endif()
+
+ set(${out_var} "${candidate_roots}" PARENT_SCOPE)
+endfunction()
+
+# Build the default MSYS2 root candidates used when no explicit configuration is available.
+function(_moonlight_get_default_msys2_root_candidates out_var)
+ set(candidate_roots)
if(DEFINED ENV{SystemDrive} AND NOT "$ENV{SystemDrive}" STREQUAL "")
list(APPEND candidate_roots "$ENV{SystemDrive}/msys64")
endif()
@@ -77,16 +88,62 @@ function(moonlight_detect_windows_msys2_root out_var)
"C:/tools/msys64"
)
- foreach(candidate_root IN LISTS candidate_roots)
+ set(${out_var} "${candidate_roots}" PARENT_SCOPE)
+endfunction()
+
+# Try to resolve an MSYS2 root from a list of candidate root directories.
+function(_moonlight_try_msys2_root_candidates out_var)
+ foreach(candidate_root IN LISTS ARGN)
_moonlight_set_msys2_root_if_valid(_resolved_root "${candidate_root}")
if(NOT _resolved_root STREQUAL "")
- set(MOONLIGHT_MSYS2_ROOT "${_resolved_root}" CACHE PATH "Path to the detected MSYS2 installation" FORCE)
set(${out_var} "${_resolved_root}" PARENT_SCOPE)
return()
endif()
endforeach()
- set(program_hints ${candidate_roots})
+ set(${out_var} "" PARENT_SCOPE)
+endfunction()
+
+# Try to resolve an MSYS2 root by walking up from the given tool paths.
+function(_moonlight_try_msys2_root_from_tools out_var)
+ foreach(tool_path IN LISTS ARGN)
+ _moonlight_try_msys2_root_from_tool(_resolved_root "${tool_path}")
+ if(NOT _resolved_root STREQUAL "")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+ endforeach()
+
+ set(${out_var} "" PARENT_SCOPE)
+endfunction()
+
+# Try to resolve an MSYS2 root by searching PATH for common MSYS2 tool names.
+function(_moonlight_try_msys2_root_from_path_tools out_var)
+ foreach(tool_name
+ msys2_shell.cmd
+ bash.exe
+ make.exe
+ mingw32-make.exe
+ clang++.exe
+ clang.exe
+ g++.exe
+ gcc.exe
+ c++.exe
+ cc.exe)
+ find_program(_tool_path NAMES ${tool_name})
+ _moonlight_try_msys2_root_from_tool(_resolved_root "${_tool_path}")
+ if(NOT _resolved_root STREQUAL "")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+ endforeach()
+
+ set(${out_var} "" PARENT_SCOPE)
+endfunction()
+
+# Try to resolve an MSYS2 root by searching under hinted installation roots.
+function(_moonlight_try_hinted_msys2_root out_var)
+ set(program_hints ${ARGN})
find_program(_msys2_shell_path
NAMES msys2_shell.cmd
@@ -95,12 +152,11 @@ function(moonlight_detect_windows_msys2_root out_var)
)
_moonlight_try_msys2_root_from_tool(_resolved_root "${_msys2_shell_path}")
if(NOT _resolved_root STREQUAL "")
- set(MOONLIGHT_MSYS2_ROOT "${_resolved_root}" CACHE PATH "Path to the detected MSYS2 installation" FORCE)
set(${out_var} "${_resolved_root}" PARENT_SCOPE)
return()
endif()
- foreach(tool_name bash.exe mingw32-make.exe clang++.exe clang.exe)
+ foreach(tool_name bash.exe make.exe mingw32-make.exe clang++.exe clang.exe g++.exe gcc.exe c++.exe cc.exe)
find_program(_tool_path
NAMES ${tool_name}
HINTS ${program_hints}
@@ -108,12 +164,59 @@ function(moonlight_detect_windows_msys2_root out_var)
)
_moonlight_try_msys2_root_from_tool(_resolved_root "${_tool_path}")
if(NOT _resolved_root STREQUAL "")
- set(MOONLIGHT_MSYS2_ROOT "${_resolved_root}" CACHE PATH "Path to the detected MSYS2 installation" FORCE)
set(${out_var} "${_resolved_root}" PARENT_SCOPE)
return()
endif()
endforeach()
+ set(${out_var} "" PARENT_SCOPE)
+endfunction()
+
+# Detect the MSYS2 installation root on Windows and cache the resolved path.
+function(moonlight_detect_windows_msys2_root out_var)
+ if(NOT WIN32)
+ message(FATAL_ERROR "moonlight_detect_windows_msys2_root is only available on Windows hosts")
+ endif()
+
+ _moonlight_get_configured_msys2_root_candidates(candidate_roots)
+ _moonlight_try_msys2_root_candidates(_resolved_root ${candidate_roots})
+ if(NOT _resolved_root STREQUAL "")
+ _moonlight_cache_detected_msys2_root("${_resolved_root}")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+
+ _moonlight_try_msys2_root_from_tools(_resolved_root "${CMAKE_COMMAND}" "${CMAKE_MAKE_PROGRAM}")
+ if(NOT _resolved_root STREQUAL "")
+ _moonlight_cache_detected_msys2_root("${_resolved_root}")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+
+ _moonlight_try_msys2_root_from_path_tools(_resolved_root)
+ if(NOT _resolved_root STREQUAL "")
+ _moonlight_cache_detected_msys2_root("${_resolved_root}")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+
+ _moonlight_get_default_msys2_root_candidates(default_candidate_roots)
+ list(APPEND candidate_roots ${default_candidate_roots})
+
+ _moonlight_try_msys2_root_candidates(_resolved_root ${candidate_roots})
+ if(NOT _resolved_root STREQUAL "")
+ _moonlight_cache_detected_msys2_root("${_resolved_root}")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+
+ _moonlight_try_hinted_msys2_root(_resolved_root ${candidate_roots})
+ if(NOT _resolved_root STREQUAL "")
+ _moonlight_cache_detected_msys2_root("${_resolved_root}")
+ set(${out_var} "${_resolved_root}" PARENT_SCOPE)
+ return()
+ endif()
+
message(FATAL_ERROR
"Could not find an MSYS2 installation. "
"Set the MSYS2_ROOT environment variable or add the MSYS2 tools to PATH.")
diff --git a/cmake/nxdk.cmake b/cmake/nxdk.cmake
index e65655b..7fbf11d 100644
--- a/cmake/nxdk.cmake
+++ b/cmake/nxdk.cmake
@@ -249,6 +249,7 @@ function(moonlight_prepare_nxdk nxdk_dir state_dir)
"${nxdk_dir}/lib/libc++.lib"
"${nxdk_dir}/lib/libSDL2.lib"
"${nxdk_dir}/lib/libSDL2_image.lib"
+ "${nxdk_dir}/lib/libxboxrt.lib"
)
set(required_tools
"${cxbe_path}"
diff --git a/cmake/run-child-build.cmake b/cmake/run-child-build.cmake
index f3a7a18..8704a4d 100644
--- a/cmake/run-child-build.cmake
+++ b/cmake/run-child-build.cmake
@@ -31,6 +31,7 @@ if(MOONLIGHT_COMMAND_MODE STREQUAL "configure")
-DMOONLIGHT_SKIP_NXDK_PREP:BOOL=ON
"-DNXDK_DIR:PATH=${MOONLIGHT_NXDK_DIR}"
-DBUILD_DOCS:BOOL=OFF
+ -DCMAKE_EXPORT_COMPILE_COMMANDS:BOOL=ON
"-DCMAKE_TOOLCHAIN_FILE:FILEPATH=${MOONLIGHT_TOOLCHAIN_FILE}"
-DCMAKE_DEPENDS_USE_COMPILER:BOOL=FALSE
-DCMAKE_TRY_COMPILE_TARGET_TYPE:STRING=STATIC_LIBRARY
diff --git a/cmake/sources.cmake b/cmake/sources.cmake
index 23a8380..b6427ed 100644
--- a/cmake/sources.cmake
+++ b/cmake/sources.cmake
@@ -9,10 +9,15 @@ file(GLOB_RECURSE MOONLIGHT_SOURCES CONFIGURE_DEPENDS
"${MOONLIGHT_SOURCE_ROOT}/src/*.cpp"
)
+list(REMOVE_ITEM MOONLIGHT_SOURCES
+ "${MOONLIGHT_SOURCE_ROOT}/src/_nxdk_compat/poll_compat.cpp"
+ "${MOONLIGHT_SOURCE_ROOT}/src/_nxdk_compat/stat_compat.cpp")
+
set(MOONLIGHT_TEST_EXCLUDED_SOURCES
"${MOONLIGHT_SOURCE_ROOT}/src/main.cpp"
"${MOONLIGHT_SOURCE_ROOT}/src/splash/splash_screen.cpp"
"${MOONLIGHT_SOURCE_ROOT}/src/startup/memory_stats.cpp"
+ "${MOONLIGHT_SOURCE_ROOT}/src/ui/shell_screen.cpp"
)
set(MOONLIGHT_HOST_TESTABLE_SOURCES ${MOONLIGHT_SOURCES})
diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake
index 0206194..73043d1 100644
--- a/cmake/xbox-build.cmake
+++ b/cmake/xbox-build.cmake
@@ -3,6 +3,7 @@
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/sources.cmake")
include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/nxdk.cmake")
+include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/moonlight-dependencies.cmake")
#
# metadata
@@ -22,6 +23,7 @@ endif()
find_package(NXDK REQUIRED)
find_package(NXDK_SDL2 REQUIRED)
find_package(NXDK_SDL2_Image REQUIRED)
+find_package(NXDK_SDL2_TTF REQUIRED)
# add the automount_d_drive symbol to the linker flags, this is automatic with nxdk when using the Makefile option
# if this is not used, we must add some code to the main function to automount the D drive
@@ -35,6 +37,9 @@ add_custom_target(sync_xbe_assets ALL
COMMAND "${CMAKE_COMMAND}" -E copy_directory
"${CMAKE_CURRENT_SOURCE_DIR}/xbe"
"${XBOX_XBE_DIR}"
+ COMMAND "${CMAKE_COMMAND}" -E copy
+ "${NXDK_DIR}/samples/sdl_ttf/vegur-regular.ttf"
+ "${XBOX_XBE_DIR}/assets/fonts/vegur-regular.ttf"
COMMENT "Sync XBE assets"
)
@@ -44,41 +49,44 @@ endif()
set(CMAKE_CXX_FLAGS_RELEASE "-O2")
set(CMAKE_C_FLAGS_RELEASE "-O2")
-# moonlight-common-c submodule
-include(GetOpenSSL REQUIRED)
-set(ENET_NO_INSTALL ON CACHE BOOL "Do not install libraries built for enet" FORCE)
-set(BUILD_SHARED_LIBS OFF)
-add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c")
-if(TARGET moonlight-common-c AND TARGET openssl_external)
- add_dependencies(moonlight-common-c openssl_external)
-endif()
-target_link_libraries(enet PUBLIC NXDK::NXDK NXDK::Net NXDK::ws2_32)
-target_compile_options(enet PRIVATE -Wno-unused-function -Wno-error=unused-function)
-if(TARGET moonlight-common-c)
- target_compile_options(moonlight-common-c PRIVATE -Wno-unused-function -Wno-error=unused-function)
- target_link_libraries(moonlight-common-c PRIVATE NXDK::ws2_32)
-endif()
+MOONLIGHT_PREPARE_COMMON_DEPENDENCIES()
add_executable(${CMAKE_PROJECT_NAME}
${MOONLIGHT_SOURCES}
)
+target_sources(${CMAKE_PROJECT_NAME}
+ PRIVATE
+ "${CMAKE_SOURCE_DIR}/src/_nxdk_compat/stat_compat.cpp")
target_include_directories(${CMAKE_PROJECT_NAME}
SYSTEM PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}"
+ "${CMAKE_CURRENT_SOURCE_DIR}/third-party/tomlplusplus/include"
+ "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}"
+ "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}"
+ "${MOONLIGHT_NXDK_LWIP_POSIX_COMPAT_DIR}"
)
target_link_libraries(${CMAKE_PROJECT_NAME}
PUBLIC
NXDK::NXDK
NXDK::NXDK_CXX
+ NXDK::Net
NXDK::SDL2
NXDK::SDL2_Image
+ NXDK::SDL2_TTF
+ OpenSSL::Crypto
+ OpenSSL::SSL
)
target_compile_options(${CMAKE_PROJECT_NAME}
PRIVATE
${MOONLIGHT_COMPILE_OPTIONS}
$<$:-std=gnu++17>
)
-target_compile_definitions(${CMAKE_PROJECT_NAME} PRIVATE XBOX NXDK)
+target_compile_definitions(${CMAKE_PROJECT_NAME}
+ PRIVATE
+ XBOX
+ NXDK
+ TOML_EXCEPTIONS=0
+ TOML_ENABLE_WINDOWS_COMPAT=0)
add_dependencies(${CMAKE_PROJECT_NAME} moonlight-common-c)
if(BUILD_DOCS)
diff --git a/docs/images/loading.png b/docs/images/loading.png
deleted file mode 100644
index bd3e572..0000000
Binary files a/docs/images/loading.png and /dev/null differ
diff --git a/docs/images/screenshots/01-splash.png b/docs/images/screenshots/01-splash.png
new file mode 100644
index 0000000..854f78f
Binary files /dev/null and b/docs/images/screenshots/01-splash.png differ
diff --git a/docs/images/screenshots/02-hosts.png b/docs/images/screenshots/02-hosts.png
new file mode 100644
index 0000000..d39cf14
Binary files /dev/null and b/docs/images/screenshots/02-hosts.png differ
diff --git a/docs/images/screenshots/03-apps.png b/docs/images/screenshots/03-apps.png
new file mode 100644
index 0000000..373c5d7
Binary files /dev/null and b/docs/images/screenshots/03-apps.png differ
diff --git a/scripts/run-xemu.cmd b/scripts/run-xemu.cmd
index 3ee1e5e..f4d49ac 100644
--- a/scripts/run-xemu.cmd
+++ b/scripts/run-xemu.cmd
@@ -34,6 +34,18 @@ if /I "%~1"=="-h" exit /b 1
if /I "%~1"=="--help" exit /b 1
if /I "%~1"=="--build-dir" exit /b 0
if /I "%~1"=="--iso" exit /b 0
+if /I "%~1"=="--network" (
+ shift
+ if "%~1"=="" exit /b 1
+ shift
+ goto has_explicit_target
+)
+if /I "%~1"=="--tap-ifname" (
+ shift
+ if "%~1"=="" exit /b 1
+ shift
+ goto has_explicit_target
+)
if /I "%~1"=="--" (
shift
if "%~1"=="" exit /b 1
diff --git a/scripts/run-xemu.sh b/scripts/run-xemu.sh
index a8a72e3..a0c5226 100644
--- a/scripts/run-xemu.sh
+++ b/scripts/run-xemu.sh
@@ -7,12 +7,16 @@ set -euo pipefail
usage() {
cat <<'EOF'
-Usage: run-xemu.sh [--check] [--build-dir dir] [--iso path] [path]
+Usage: run-xemu.sh [--check] [--build-dir dir] [--iso path] [--network mode] [--tap-ifname name] [path]
Environment overrides:
MOONLIGHT_XEMU_BUILD_DIR
MOONLIGHT_XEMU_ISO_PATH
MOONLIGHT_XEMU_TARGET_PATH
+ MOONLIGHT_XEMU_NETWORK
+ MOONLIGHT_XEMU_TAP_IFNAME
+ MOONLIGHT_XEMU_ENABLE_SERIAL_STDIO
+ MOONLIGHT_XEMU_DISABLE_SERIAL_STDIO
EOF
return 0
}
@@ -69,6 +73,10 @@ write_xemu_config() {
printf 'show_welcome = false\n'
printf 'games_dir = "%s"\n' "$(escape_toml_string "$games_dir")"
printf 'skip_boot_anim = true\n'
+ printf '\n[display.ui]\n'
+ printf "aspect_ratio = 'native\'\n"
+ printf '\n[net]\n'
+ printf 'enable = true\n'
printf '\n[sys.files]\n'
printf 'bootrom_path = "%s"\n' "$(escape_toml_string "$bootrom_path")"
printf 'flashrom_path = "%s"\n' "$(escape_toml_string "$flashrom_path")"
@@ -79,6 +87,62 @@ write_xemu_config() {
return 0
}
+prepare_xemu_runtime_environment() {
+ if is_windows; then
+ if [[ -n "${XEMU_APPDATA:-}" ]]; then
+ APPDATA="$(to_native_path "$XEMU_APPDATA")"
+ export APPDATA
+ fi
+ if [[ -n "${XEMU_LOCALAPPDATA:-}" ]]; then
+ LOCALAPPDATA="$(to_native_path "$XEMU_LOCALAPPDATA")"
+ export LOCALAPPDATA
+ fi
+ return 0
+ fi
+
+ if [[ -n "${XEMU_HOME:-}" ]]; then
+ export HOME="$XEMU_HOME"
+ fi
+ if [[ -n "${XEMU_CONFIG_HOME:-}" ]]; then
+ export XDG_CONFIG_HOME="$XEMU_CONFIG_HOME"
+ fi
+ if [[ -n "${XEMU_DATA_HOME:-}" ]]; then
+ export XDG_DATA_HOME="$XEMU_DATA_HOME"
+ fi
+ if [[ -n "${XEMU_CACHE_HOME:-}" ]]; then
+ export XDG_CACHE_HOME="$XEMU_CACHE_HOME"
+ fi
+ if [[ -n "${XEMU_STATE_HOME:-}" ]]; then
+ export XDG_STATE_HOME="$XEMU_STATE_HOME"
+ fi
+
+ return 0
+}
+
+build_network_args() {
+ case "$network_mode" in
+ user)
+ return 0
+ ;;
+ none)
+ xemu_network_args=(-nic none)
+ return 0
+ ;;
+ tap)
+ if [[ -z "$tap_ifname" ]]; then
+ echo 'The tap network mode requires --tap-ifname or MOONLIGHT_XEMU_TAP_IFNAME.' >&2
+ exit 2
+ fi
+ xemu_network_args=(-nic "tap,ifname=$tap_ifname")
+ return 0
+ ;;
+ *)
+ echo "Unsupported network mode: $network_mode" >&2
+ exit 2
+ ;;
+ esac
+}
+
require_file() {
local label="$1"
local path="$2"
@@ -207,6 +271,14 @@ build_dir=""
iso_path="$(default_iso_path "$project_root")"
check_only=0
target_path=""
+network_mode="${MOONLIGHT_XEMU_NETWORK:-user}"
+tap_ifname="${MOONLIGHT_XEMU_TAP_IFNAME:-}"
+xemu_network_args=()
+serial_args=(-device lpc47m157 -serial stdio)
+
+if [[ "${MOONLIGHT_XEMU_ENABLE_SERIAL_STDIO:-1}" == "0" || -n "${MOONLIGHT_XEMU_DISABLE_SERIAL_STDIO:-}" ]]; then
+ serial_args=()
+fi
if [[ -n "${MOONLIGHT_XEMU_BUILD_DIR:-}" ]]; then
build_dir="$(resolve_build_dir "$MOONLIGHT_XEMU_BUILD_DIR")"
@@ -243,6 +315,22 @@ while [[ $# -gt 0 ]]; do
fi
iso_path="$(resolve_input_path "$1")"
;;
+ --network)
+ shift
+ if [[ $# -eq 0 ]]; then
+ echo 'Missing value for --network' >&2
+ exit 2
+ fi
+ network_mode="$1"
+ ;;
+ --tap-ifname)
+ shift
+ if [[ $# -eq 0 ]]; then
+ echo 'Missing value for --tap-ifname' >&2
+ exit 2
+ fi
+ tap_ifname="$1"
+ ;;
--)
shift
if [[ $# -gt 0 ]]; then
@@ -322,6 +410,8 @@ require_file 'xemu flash ROM' "$flashrom_path"
require_file 'xemu hard disk image' "$hdd_path"
write_xemu_config "$xemu_config_path" "$games_dir" "$bootrom_path" "$flashrom_path" "$eeprom_path" "$hdd_path"
+prepare_xemu_runtime_environment
+build_network_args
if [[ "$check_only" -eq 1 ]]; then
if [[ -n "$build_dir" ]]; then
@@ -334,7 +424,27 @@ if [[ "$check_only" -eq 1 ]]; then
printf 'XEMU_FLASHROM_PATH=%s\n' "$flashrom_path"
printf 'XEMU_EEPROM_PATH=%s\n' "$eeprom_path"
printf 'XEMU_HDD_PATH=%s\n' "$hdd_path"
+ printf 'XEMU_NETWORK_MODE=%s\n' "$network_mode"
+ if [[ -n "$tap_ifname" ]]; then
+ printf 'XEMU_TAP_IFNAME=%s\n' "$tap_ifname"
+ fi
+ if [[ "${#serial_args[@]}" -gt 0 ]]; then
+ printf 'XEMU_SERIAL_STDIO=enabled\n'
+ else
+ printf 'XEMU_SERIAL_STDIO=disabled\n'
+ fi
exit 0
fi
-exec "$xemu_exe" -config_path "$xemu_config_path" -dvd_path "$iso_path" -no-user-config
+xemu_args=(
+ -config_path "$xemu_config_path"
+ -dvd_path "$iso_path"
+ -no-user-config
+)
+
+if [[ ${#xemu_network_args[@]} -gt 0 ]]; then
+ xemu_args+=("${xemu_network_args[@]}")
+fi
+xemu_args+=("${serial_args[@]}")
+
+exec "$xemu_exe" "${xemu_args[@]}"
diff --git a/sonar-project.properties b/sonar-project.properties
new file mode 100644
index 0000000..751d61e
--- /dev/null
+++ b/sonar-project.properties
@@ -0,0 +1,5 @@
+# Sonar project analysis properties overrides
+sonar.projectKey=LizardByte_Moonlight-XboxOG
+
+# nxdk does not support newer c++ standards
+sonar.cfamily.reportingCppStandardOverride=c++17
diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h
new file mode 100644
index 0000000..26a042f
--- /dev/null
+++ b/src/_nxdk_compat/openssl_compat.h
@@ -0,0 +1,315 @@
+/**
+ * @file src/_nxdk_compat/openssl_compat.h
+ * @brief Declares OpenSSL compatibility shims for nxdk.
+ */
+#pragma once
+
+#ifndef __STDC_WANT_LIB_EXT1__
+ /**
+ * @brief Request Annex K declarations such as gmtime_s when the C library provides them.
+ */
+ #define __STDC_WANT_LIB_EXT1__ 1
+#endif
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+ /**
+ * @brief Receive bytes through the lwIP socket backend.
+ *
+ * @param s lwIP socket descriptor.
+ * @param mem Destination buffer.
+ * @param len Maximum number of bytes to receive.
+ * @param flags lwIP receive flags.
+ * @return Number of bytes received, or a negative value on failure.
+ */
+ ssize_t lwip_recv(int s, void *mem, size_t len, int flags);
+
+ /**
+ * @brief Send bytes through the lwIP socket backend.
+ *
+ * @param s lwIP socket descriptor.
+ * @param dataptr Source buffer.
+ * @param size Number of bytes to send.
+ * @param flags lwIP send flags.
+ * @return Number of bytes sent, or a negative value on failure.
+ */
+ ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags);
+
+ /**
+ * @brief Wait for lwIP socket readiness using the nxdk select implementation.
+ *
+ * @param maxfdp1 One greater than the highest descriptor to inspect.
+ * @param readset Optional descriptor set watched for readability.
+ * @param writeset Optional descriptor set watched for writability.
+ * @param exceptset Optional descriptor set watched for exceptional conditions.
+ * @param timeout Optional timeout value.
+ * @return Number of ready descriptors, zero on timeout, or a negative value on failure.
+ */
+ int lwip_select(int maxfdp1, struct fd_set *readset, struct fd_set *writeset, struct fd_set *exceptset, struct timeval *timeout);
+
+#ifndef LWIP_SOCKET_OFFSET
+ /**
+ * @brief Offset applied by lwIP when translating socket descriptors.
+ */
+ #define LWIP_SOCKET_OFFSET 0
+#endif
+
+#ifndef FD_SETSIZE
+ /**
+ * @brief Maximum number of sockets tracked by the compatibility fd_set.
+ */
+ #define FD_SETSIZE MEMP_NUM_NETCONN
+#endif
+
+#ifndef FD_SET
+ /**
+ * @brief Minimal socket descriptor set used by the lwIP-backed select shim.
+ */
+ typedef struct fd_set {
+ unsigned char fd_bits[(FD_SETSIZE + 7) / 8]; ///< Bitset storing tracked socket descriptors relative to LWIP_SOCKET_OFFSET.
+ } fd_set;
+
+ /**
+ * @brief Mark a socket descriptor as present in an fd_set.
+ */
+ #define FD_SET(n, p) ((p)->fd_bits[((n) - LWIP_SOCKET_OFFSET) / 8] = (unsigned char) ((p)->fd_bits[((n) - LWIP_SOCKET_OFFSET) / 8] | (1u << (((n) - LWIP_SOCKET_OFFSET) & 7))))
+
+ /**
+ * @brief Clear a socket descriptor from an fd_set.
+ */
+ #define FD_CLR(n, p) ((p)->fd_bits[((n) - LWIP_SOCKET_OFFSET) / 8] = (unsigned char) ((p)->fd_bits[((n) - LWIP_SOCKET_OFFSET) / 8] & ~(1u << (((n) - LWIP_SOCKET_OFFSET) & 7))))
+
+ /**
+ * @brief Return whether a socket descriptor is present in an fd_set.
+ */
+ #define FD_ISSET(n, p) (((p)->fd_bits[((n) - LWIP_SOCKET_OFFSET) / 8] & (1u << (((n) - LWIP_SOCKET_OFFSET) & 7))) != 0)
+
+ /**
+ * @brief Reset an fd_set so that it tracks no descriptors.
+ */
+ #define FD_ZERO(p) memset((void *) (p), 0, sizeof(*(p)))
+#endif
+
+#ifndef select
+ /**
+ * @brief Route select calls through the lwIP compatibility shim on nxdk builds.
+ */
+ #define select(maxfdp1, readset, writeset, exceptset, timeout) lwip_select(maxfdp1, readset, writeset, exceptset, timeout)
+#endif
+
+#ifndef F_OK
+ /**
+ * @brief Test access mode flag for file existence checks.
+ */
+ #define F_OK 0
+#endif
+
+#ifndef R_OK
+ /**
+ * @brief Test access mode flag for read permission checks.
+ */
+ #define R_OK 4
+#endif
+
+#ifndef W_OK
+ /**
+ * @brief Test access mode flag for write permission checks.
+ */
+ #define W_OK 2
+#endif
+
+#ifndef X_OK
+ /**
+ * @brief Test access mode flag for execute permission checks.
+ */
+ #define X_OK 1
+#endif
+
+#ifndef AF_UNIX
+ /**
+ * @brief Placeholder address-family value used when AF_UNIX is unavailable on nxdk.
+ */
+ #define AF_UNIX (-1)
+#endif
+
+/** @brief Redirect access to the nxdk OpenSSL compatibility shim. */
+#define access moonlight_nxdk_openssl_access
+/** @brief Redirect fileno to the nxdk OpenSSL compatibility shim. */
+#define fileno moonlight_nxdk_openssl_fileno
+/** @brief Redirect read to the nxdk OpenSSL compatibility shim. */
+#define read moonlight_nxdk_openssl_read
+/** @brief Redirect write to the nxdk OpenSSL compatibility shim. */
+#define write moonlight_nxdk_openssl_write
+/** @brief Redirect close to the nxdk OpenSSL compatibility shim. */
+#define close moonlight_nxdk_openssl_close
+/** @brief Redirect _close to the nxdk OpenSSL compatibility shim. */
+#define _close moonlight_nxdk_openssl__close
+/** @brief Redirect open to the nxdk OpenSSL compatibility shim. */
+#define open moonlight_nxdk_openssl_open
+/** @brief Redirect _open to the nxdk OpenSSL compatibility shim. */
+#define _open moonlight_nxdk_openssl__open
+/** @brief Redirect fdopen to the nxdk OpenSSL compatibility shim. */
+#define fdopen moonlight_nxdk_openssl_fdopen
+/** @brief Redirect _fdopen to the nxdk OpenSSL compatibility shim. */
+#define _fdopen moonlight_nxdk_openssl__fdopen
+/** @brief Redirect _unlink to the nxdk OpenSSL compatibility shim. */
+#define _unlink moonlight_nxdk_openssl__unlink
+/** @brief Redirect chmod to the nxdk OpenSSL compatibility shim. */
+#define chmod moonlight_nxdk_openssl_chmod
+/** @brief Redirect getuid to the nxdk OpenSSL compatibility shim. */
+#define getuid moonlight_nxdk_openssl_getuid
+/** @brief Redirect geteuid to the nxdk OpenSSL compatibility shim. */
+#define geteuid moonlight_nxdk_openssl_geteuid
+/** @brief Redirect getgid to the nxdk OpenSSL compatibility shim. */
+#define getgid moonlight_nxdk_openssl_getgid
+/** @brief Redirect getegid to the nxdk OpenSSL compatibility shim. */
+#define getegid moonlight_nxdk_openssl_getegid
+
+ /**
+ * @brief Stub access for file-system queries that OpenSSL may issue on nxdk.
+ */
+ static inline int moonlight_nxdk_openssl_access(const char *path, int mode) {
+ (void) path;
+ (void) mode;
+ return -1;
+ }
+
+ /**
+ * @brief Stub fileno for stdio streams that do not expose POSIX descriptors on nxdk.
+ */
+ static inline int moonlight_nxdk_openssl_fileno(FILE *stream) {
+ (void) stream;
+ return -1;
+ }
+
+ /**
+ * @brief Forward OpenSSL read calls to lwIP recv.
+ */
+ static inline ssize_t moonlight_nxdk_openssl_read(int fd, void *buffer, size_t count) {
+ return lwip_recv(fd, buffer, count, 0);
+ }
+
+ /**
+ * @brief Forward OpenSSL write calls to lwIP send.
+ */
+ static inline ssize_t moonlight_nxdk_openssl_write(int fd, const void *buffer, size_t count) {
+ return lwip_send(fd, buffer, count, 0);
+ }
+
+ /**
+ * @brief Stub close for descriptors that are not backed by a host file system on nxdk.
+ */
+ static inline int moonlight_nxdk_openssl_close(int fd) {
+ (void) fd;
+ return -1;
+ }
+
+ /**
+ * @brief Windows-style alias for the close compatibility shim.
+ */
+ static inline int moonlight_nxdk_openssl__close(int fd) {
+ return moonlight_nxdk_openssl_close(fd);
+ }
+
+ /**
+ * @brief Stub open for OpenSSL paths that are unsupported on nxdk.
+ */
+ static inline int moonlight_nxdk_openssl_open(const char *path, int flags, ...) {
+ (void) path;
+ (void) flags;
+ return -1;
+ }
+
+ /**
+ * @brief Windows-style alias for the open compatibility shim.
+ */
+ static inline int moonlight_nxdk_openssl__open(const char *path, int flags, ...) {
+ (void) path;
+ (void) flags;
+ return -1;
+ }
+
+ /**
+ * @brief Stub fdopen for descriptor-backed stdio that is unavailable on nxdk.
+ */
+ static inline FILE *moonlight_nxdk_openssl_fdopen(int fd, const char *mode) {
+ (void) fd;
+ (void) mode;
+ return NULL;
+ }
+
+ /**
+ * @brief Windows-style alias for the fdopen compatibility shim.
+ */
+ static inline FILE *moonlight_nxdk_openssl__fdopen(int fd, const char *mode) {
+ return moonlight_nxdk_openssl_fdopen(fd, mode);
+ }
+
+ /**
+ * @brief Stub unlink for OpenSSL cleanup paths that are unsupported on nxdk.
+ */
+ static inline int moonlight_nxdk_openssl__unlink(const char *path) {
+ (void) path;
+ return -1;
+ }
+
+ /**
+ * @brief Stub chmod for OpenSSL paths that are unsupported on nxdk.
+ */
+ static inline int moonlight_nxdk_openssl_chmod(const char *path, int mode) {
+ (void) path;
+ (void) mode;
+ return -1;
+ }
+
+ /**
+ * @brief Return a placeholder user identifier for nxdk builds.
+ */
+ static inline unsigned int moonlight_nxdk_openssl_getuid(void) {
+ return 0;
+ }
+
+ /**
+ * @brief Return a placeholder effective user identifier for nxdk builds.
+ */
+ static inline unsigned int moonlight_nxdk_openssl_geteuid(void) {
+ return 0;
+ }
+
+ /**
+ * @brief Return a placeholder group identifier for nxdk builds.
+ */
+ static inline unsigned int moonlight_nxdk_openssl_getgid(void) {
+ return 0;
+ }
+
+ /**
+ * @brief Return a placeholder effective group identifier for nxdk builds.
+ */
+ static inline unsigned int moonlight_nxdk_openssl_getegid(void) {
+ return 0;
+ }
+
+ /**
+ * @brief Adapt Microsoft's gmtime_s parameter order to the Annex K signature expected by OpenSSL.
+ */
+ static inline int moonlight_nxdk_openssl_gmtime_s(struct tm *result, const time_t *timer) {
+ return gmtime_s(timer, result);
+ }
+
+/** @brief Redirect gmtime_s to the nxdk OpenSSL compatibility shim. */
+#define gmtime_s moonlight_nxdk_openssl_gmtime_s
+
+#ifdef __cplusplus
+}
+#endif
diff --git a/src/_nxdk_compat/poll_compat.cpp b/src/_nxdk_compat/poll_compat.cpp
new file mode 100644
index 0000000..1fdfe26
--- /dev/null
+++ b/src/_nxdk_compat/poll_compat.cpp
@@ -0,0 +1,92 @@
+/**
+ * @file src/_nxdk_compat/poll_compat.cpp
+ * @brief Implements poll compatibility shims for nxdk.
+ */
+#ifdef NXDK
+
+ #include
+ #include
+ #include
+ #include
+ #include
+
+/**
+ * @brief Emulate poll by translating the requested events into select sets.
+ *
+ * @param fds File descriptor array to test.
+ * @param nfds Number of entries in @p fds.
+ * @param timeout Timeout in milliseconds, or a negative value to wait indefinitely.
+ * @return Number of ready descriptors, zero on timeout, or -1 on error.
+ */
+extern "C" int poll(struct pollfd *fds, nfds_t nfds, int timeout) {
+ if (fds == nullptr && nfds != 0) {
+ errno = EINVAL;
+ return -1;
+ }
+
+ fd_set readSet;
+ fd_set writeSet;
+ fd_set errorSet;
+ FD_ZERO(&readSet);
+ FD_ZERO(&writeSet);
+ FD_ZERO(&errorSet);
+
+ int maxFd = -1;
+ std::size_t readyCount = 0;
+
+ for (nfds_t index = 0; index < nfds; ++index) {
+ fds[index].revents = 0;
+
+ if (fds[index].fd < 0) {
+ continue;
+ }
+
+ if ((fds[index].events & POLLIN) != 0) {
+ FD_SET(fds[index].fd, &readSet);
+ }
+ if ((fds[index].events & POLLOUT) != 0) {
+ FD_SET(fds[index].fd, &writeSet);
+ }
+ FD_SET(fds[index].fd, &errorSet);
+
+ if (fds[index].fd > maxFd) {
+ maxFd = fds[index].fd;
+ }
+ }
+
+ timeval timeoutValue {};
+ timeval *timeoutPointer = nullptr;
+ if (timeout >= 0) {
+ timeoutValue.tv_sec = timeout / 1000;
+ timeoutValue.tv_usec = (timeout % 1000) * 1000;
+ timeoutPointer = &timeoutValue;
+ }
+
+ if (const int selectResult = select(maxFd + 1, &readSet, &writeSet, &errorSet, timeoutPointer); selectResult <= 0) {
+ return selectResult;
+ }
+
+ for (nfds_t index = 0; index < nfds; ++index) {
+ if (fds[index].fd < 0) {
+ continue;
+ }
+
+ if (FD_ISSET(fds[index].fd, &readSet)) {
+ fds[index].revents |= POLLIN;
+ }
+ if (FD_ISSET(fds[index].fd, &writeSet)) {
+ fds[index].revents |= POLLOUT;
+ }
+ if (FD_ISSET(fds[index].fd, &errorSet)) {
+ fds[index].revents |= POLLERR;
+ }
+
+ if (fds[index].revents != 0) {
+ ++readyCount;
+ }
+ }
+
+ return static_cast(readyCount);
+}
+
+#endif
diff --git a/src/_nxdk_compat/stat_compat.cpp b/src/_nxdk_compat/stat_compat.cpp
new file mode 100644
index 0000000..06fc150
--- /dev/null
+++ b/src/_nxdk_compat/stat_compat.cpp
@@ -0,0 +1,70 @@
+/**
+ * @file src/_nxdk_compat/stat_compat.cpp
+ * @brief Implements stat compatibility shims for nxdk.
+ */
+#ifdef NXDK
+
+ #include
+ #include
+
+extern "C" {
+
+ /**
+ * @brief Stub stat for nxdk builds that do not expose a compatible host file system.
+ *
+ * @param path Requested path.
+ * @param status Optional output populated with a zeroed status record.
+ * @return Always -1 to indicate that the query is unsupported.
+ */
+ int stat(const char *path, struct stat *status) { // NOSONAR(cpp:S833) extern "C" linkage requires external visibility
+ (void) path;
+
+ if (status != nullptr) {
+ std::memset(status, 0, sizeof(*status));
+ }
+
+ return -1;
+ }
+
+ /**
+ * @brief Stub fstat for nxdk builds that only need a successful zeroed response.
+ *
+ * @param fd File descriptor to inspect.
+ * @param status Optional output populated with a zeroed status record.
+ * @return Zero after clearing the status record when provided.
+ */
+ int fstat(int fd, struct stat *status) { // NOSONAR(cpp:S833) extern "C" linkage requires external visibility
+ (void) fd;
+
+ if (status != nullptr) {
+ std::memset(status, 0, sizeof(*status));
+ }
+
+ return 0;
+ }
+
+ /**
+ * @brief Windows-style alias for the stat compatibility shim.
+ *
+ * @param path Requested path.
+ * @param status Optional output populated with a zeroed status record.
+ * @return Result from stat().
+ */
+ int _stat(const char *path, struct stat *status) {
+ return stat(path, status);
+ }
+
+ /**
+ * @brief Windows-style alias for the fstat compatibility shim.
+ *
+ * @param fd File descriptor to inspect.
+ * @param status Optional output populated with a zeroed status record.
+ * @return Result from fstat().
+ */
+ int _fstat(int fd, struct stat *status) {
+ return fstat(fd, status);
+ }
+
+} // extern "C"
+
+#endif
diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp
new file mode 100644
index 0000000..b8aee00
--- /dev/null
+++ b/src/app/client_state.cpp
@@ -0,0 +1,2234 @@
+/**
+ * @file src/app/client_state.cpp
+ * @brief Implements client state models and transitions.
+ */
+// class header include
+#include "src/app/client_state.h"
+
+// standard includes
+#include "src/network/host_pairing.h"
+
+#include
+#include
+#include
+#include
+#include
+
+namespace {
+
+ constexpr std::size_t OVERLAY_SCROLL_STEP = 4U;
+ constexpr std::size_t LOG_VIEWER_SCROLL_STEP = 1U;
+ constexpr std::size_t LOG_VIEWER_FAST_SCROLL_STEP = 8U;
+ constexpr std::size_t HOST_TOOLBAR_BUTTON_COUNT = 3U;
+ constexpr std::size_t DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX = HOST_TOOLBAR_BUTTON_COUNT - 1U;
+ constexpr std::size_t HOST_GRID_COLUMN_COUNT = 3U;
+ constexpr std::size_t APP_GRID_COLUMN_COUNT = 4U;
+ constexpr std::size_t ADD_HOST_KEYPAD_COLUMN_COUNT = 3U;
+ constexpr const char *DELETE_SAVED_FILE_MENU_ID_PREFIX = "delete-saved-file:";
+ constexpr const char *SETTINGS_CATEGORY_PREFIX = "settings-category:";
+ constexpr std::array ADD_HOST_ADDRESS_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0'};
+ constexpr std::array ADD_HOST_PORT_KEYPAD_CHARACTERS {'1', '2', '3', '4', '5', '6', '7', '8', '9', '0'};
+
+ /**
+ * @brief Describes the keypad characters available for the active add-host field.
+ */
+ struct AddHostKeypadLayout {
+ const char *characters; ///< Null-terminated backing storage for the keypad characters.
+ std::size_t buttonCount; ///< Number of selectable keypad buttons in the layout.
+ };
+
+ /**
+ * @brief Returns the keypad character layout for the active add-host field.
+ *
+ * @param state Current client state containing the active add-host field.
+ * @return The keypad layout that matches the active add-host field.
+ */
+ AddHostKeypadLayout add_host_keypad_layout(const app::ClientState &state) {
+ if (state.addHostDraft.activeField == app::AddHostField::address) {
+ return {ADD_HOST_ADDRESS_KEYPAD_CHARACTERS.data(), ADD_HOST_ADDRESS_KEYPAD_CHARACTERS.size()};
+ }
+
+ return {ADD_HOST_PORT_KEYPAD_CHARACTERS.data(), ADD_HOST_PORT_KEYPAD_CHARACTERS.size()};
+ }
+
+ /**
+ * @brief Returns the currently selected keypad character for the active add-host field.
+ *
+ * @param state Current client state containing the keypad selection.
+ * @param character Receives the selected keypad character when one is available.
+ * @return True when a keypad character was written to @p character.
+ */
+ bool selected_add_host_keypad_character(const app::ClientState &state, char *character) {
+ const AddHostKeypadLayout layout = add_host_keypad_layout(state);
+ if (character == nullptr || layout.buttonCount == 0U) {
+ return false;
+ }
+
+ *character = layout.characters[state.addHostDraft.keypad.selectedButtonIndex % layout.buttonCount];
+ return true;
+ }
+
+ std::string add_host_field_menu_id(app::AddHostField field) {
+ return field == app::AddHostField::address ? "edit-address" : "edit-port";
+ }
+
+ std::string settings_category_menu_id(app::SettingsCategory category) {
+ switch (category) {
+ case app::SettingsCategory::logging:
+ return std::string(SETTINGS_CATEGORY_PREFIX) + "logging";
+ case app::SettingsCategory::display:
+ return std::string(SETTINGS_CATEGORY_PREFIX) + "display";
+ case app::SettingsCategory::input:
+ return std::string(SETTINGS_CATEGORY_PREFIX) + "input";
+ case app::SettingsCategory::reset:
+ return std::string(SETTINGS_CATEGORY_PREFIX) + "reset";
+ }
+
+ return std::string(SETTINGS_CATEGORY_PREFIX) + "logging";
+ }
+
+ app::SettingsCategory settings_category_from_menu_id(std::string_view itemId) {
+ if (itemId == settings_category_menu_id(app::SettingsCategory::display)) {
+ return app::SettingsCategory::display;
+ }
+ if (itemId == settings_category_menu_id(app::SettingsCategory::input)) {
+ return app::SettingsCategory::input;
+ }
+ if (itemId == settings_category_menu_id(app::SettingsCategory::reset)) {
+ return app::SettingsCategory::reset;
+ }
+ return app::SettingsCategory::logging;
+ }
+
+ const char *settings_category_description(app::SettingsCategory category) {
+ switch (category) {
+ case app::SettingsCategory::logging:
+ return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity.";
+ case app::SettingsCategory::display:
+ return "Display options will live here when video and layout tuning settings are added.";
+ case app::SettingsCategory::input:
+ return "Input options will live here when controller and navigation customization is added.";
+ case app::SettingsCategory::reset:
+ return "Review and delete Moonlight saved data, or remove everything with a full factory reset.";
+ }
+
+ return "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity.";
+ }
+
+ std::string pairing_reset_endpoint_key(std::string_view address, uint16_t port) {
+ return app::normalize_ipv4_address(address) + ":" + std::to_string(app::effective_host_port(port));
+ }
+
+ void remember_deleted_host_pairing(app::ClientState &state, const app::HostRecord &host) {
+ if (host.pairingState != app::PairingState::paired) {
+ return;
+ }
+
+ const std::string key = pairing_reset_endpoint_key(host.address, host.port);
+ if (key.empty()) {
+ return;
+ }
+
+ if (std::find(state.hosts.pairingResetEndpoints.begin(), state.hosts.pairingResetEndpoints.end(), key) == state.hosts.pairingResetEndpoints.end()) {
+ state.hosts.pairingResetEndpoints.push_back(key);
+ }
+ }
+
+ void clear_deleted_host_pairing(app::ClientState &state, const std::string &address, uint16_t port) {
+ const std::string key = pairing_reset_endpoint_key(address, port);
+ if (key.empty()) {
+ return;
+ }
+
+ state.hosts.pairingResetEndpoints.erase(
+ std::remove(state.hosts.pairingResetEndpoints.begin(), state.hosts.pairingResetEndpoints.end(), key),
+ state.hosts.pairingResetEndpoints.end()
+ );
+ }
+
+ void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen);
+
+ void remember_host_selection(app::ClientState &state, const app::HostRecord &host) {
+ state.hosts.selectedAddress = host.address;
+ state.hosts.selectedPort = host.port;
+ }
+
+ void clear_active_host(app::ClientState &state) {
+ state.hosts.active = {};
+ state.hosts.activeLoaded = false;
+ }
+
+ void clear_active_host_app_list(app::ClientState &state) {
+ if (!state.hosts.activeLoaded) {
+ return;
+ }
+
+ state.hosts.active.apps.clear();
+ state.hosts.active.appListState = app::HostAppListState::idle;
+ state.hosts.active.appListStatusMessage.clear();
+ state.hosts.active.appListContentHash = 0U;
+ state.hosts.active.lastAppListRefreshTick = 0U;
+ state.hosts.active.runningGameId = 0U;
+ state.apps.selectedAppIndex = 0U;
+ state.apps.scrollPage = 0U;
+ state.apps.showHiddenApps = false;
+ }
+
+ void copy_host_to_active_host(app::ClientState &state, const app::HostRecord &host) {
+ state.hosts.active = host;
+ state.hosts.activeLoaded = true;
+ remember_host_selection(state, host);
+ }
+
+ void unload_hosts_page_state(app::ClientState &state) {
+ if (!state.hosts.loaded) {
+ return;
+ }
+
+ if (!state.hosts.items.empty() && state.hosts.selectedHostIndex < state.hosts.items.size()) {
+ remember_host_selection(state, state.hosts.items[state.hosts.selectedHostIndex]);
+ }
+
+ state.hosts.items.clear();
+ state.hosts.loaded = false;
+ state.hosts.selectedHostIndex = 0U;
+ state.hosts.focusArea = app::HostsFocusArea::toolbar;
+ }
+
+ void unload_apps_page_state(app::ClientState &state) {
+ if (state.hosts.activeLoaded) {
+ remember_host_selection(state, state.hosts.active);
+ }
+ clear_active_host_app_list(state);
+ }
+
+ void unload_settings_page_state(app::ClientState &state) {
+ state.settings.savedFiles.clear();
+ state.settings.savedFilesDirty = true;
+ state.settings.logViewerLines.clear();
+ state.settings.logViewerScrollOffset = 0U;
+ }
+
+ void unload_pair_host_screen_state(app::ClientState &state) {
+ state.pairingDraft = {{}, app::DEFAULT_HOST_PORT, {}, app::PairingStage::idle, {}};
+ }
+
+ void unload_screen_state(app::ClientState &state, app::ScreenId nextScreen) {
+ if (state.shell.activeScreen == nextScreen) {
+ return;
+ }
+
+ switch (state.shell.activeScreen) {
+ case app::ScreenId::home:
+ case app::ScreenId::hosts:
+ if (nextScreen == app::ScreenId::apps || nextScreen == app::ScreenId::pair_host || nextScreen == app::ScreenId::settings) {
+ unload_hosts_page_state(state);
+ }
+ return;
+ case app::ScreenId::apps:
+ unload_apps_page_state(state);
+ return;
+ case app::ScreenId::add_host:
+ reset_add_host_draft(state, app::ScreenId::hosts);
+ return;
+ case app::ScreenId::pair_host:
+ unload_pair_host_screen_state(state);
+ return;
+ case app::ScreenId::settings:
+ unload_settings_page_state(state);
+ return;
+ }
+ }
+
+ void sync_selected_settings_category_from_menu(app::ClientState &state) {
+ if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) {
+ state.settings.selectedCategory = settings_category_from_menu_id(selectedItem->id);
+ }
+ }
+
+ bool starts_with(const std::string &value, const char *prefix) {
+ return value.rfind(prefix, 0U) == 0U;
+ }
+
+ void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen) {
+ state.addHostDraft = {
+ {},
+ {},
+ app::AddHostField::address,
+ {false, 0U, {}},
+ returnScreen,
+ {},
+ {},
+ false,
+ };
+ }
+
+ void reset_confirmation(app::ClientState &state) {
+ state.confirmation = {};
+ }
+
+ void open_confirmation(
+ app::ClientState &state,
+ app::ConfirmationAction action,
+ std::string title,
+ std::vector lines,
+ std::string targetPath = {}
+ ) {
+ state.confirmation.action = action;
+ state.confirmation.targetPath = std::move(targetPath);
+ state.confirmation.title = std::move(title);
+ state.confirmation.lines = std::move(lines);
+ state.modal.id = app::ModalId::confirmation;
+ state.modal.selectedActionIndex = 0U;
+ }
+
+ app::HostRecord *find_host_by_endpoint(std::vector &hosts, const std::string &address, uint16_t port) {
+ const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, port](const app::HostRecord &host) {
+ return app::host_matches_endpoint(host, address, port);
+ });
+ return iterator == hosts.end() ? nullptr : &(*iterator);
+ }
+
+ app::HostRecord *find_loaded_host_by_endpoint(app::ClientState &state, const std::string &address, uint16_t port) {
+ if (app::HostRecord *host = find_host_by_endpoint(state.hosts.items, address, port); host != nullptr) {
+ return host;
+ }
+ if (state.hosts.activeLoaded && app::host_matches_endpoint(state.hosts.active, address, port)) {
+ return &state.hosts.active;
+ }
+ return nullptr;
+ }
+
+ std::vector visible_app_indices(const app::HostRecord &host, bool showHiddenApps) {
+ std::vector indices;
+ for (std::size_t index = 0; index < host.apps.size(); ++index) {
+ if (showHiddenApps || !host.apps[index].hidden) {
+ indices.push_back(index);
+ }
+ }
+ return indices;
+ }
+
+ const app::HostAppRecord *find_app_by_id(const std::vector &apps, int appId) {
+ const auto iterator = std::find_if(apps.begin(), apps.end(), [appId](const app::HostAppRecord &record) {
+ return record.id == appId;
+ });
+ return iterator == apps.end() ? nullptr : &(*iterator);
+ }
+
+ std::size_t visible_app_index_for_id(const app::HostRecord &host, bool showHiddenApps, int appId) {
+ std::size_t visibleIndex = 0U;
+ for (const app::HostAppRecord &record : host.apps) {
+ if (!showHiddenApps && record.hidden) {
+ continue;
+ }
+ if (record.id == appId) {
+ return visibleIndex;
+ }
+ ++visibleIndex;
+ }
+ return static_cast(-1);
+ }
+
+ void refresh_running_flags(app::HostRecord *host) {
+ if (host == nullptr) {
+ return;
+ }
+
+ for (app::HostAppRecord &appRecord : host->apps) {
+ appRecord.running = static_cast(appRecord.id) == host->runningGameId;
+ }
+ }
+
+ void clamp_selected_host_index(app::ClientState &state) {
+ if (state.hosts.items.empty()) {
+ state.hosts.selectedHostIndex = 0U;
+ state.hosts.focusArea = app::HostsFocusArea::toolbar;
+ state.hosts.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX;
+ return;
+ }
+
+ if (state.hosts.selectedHostIndex >= state.hosts.items.size()) {
+ state.hosts.selectedHostIndex = state.hosts.items.size() - 1U;
+ }
+ }
+
+ void reset_hosts_home_selection(app::ClientState &state) {
+ if (state.hosts.items.empty()) {
+ state.hosts.focusArea = app::HostsFocusArea::toolbar;
+ state.hosts.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX;
+ state.hosts.selectedHostIndex = 0U;
+ return;
+ }
+
+ state.hosts.focusArea = app::HostsFocusArea::grid;
+ state.hosts.selectedHostIndex = 0U;
+ }
+
+ void clamp_selected_app_index(app::ClientState &state) {
+ const app::HostRecord *host = app::apps_host(state);
+ if (host == nullptr) {
+ state.apps.selectedAppIndex = 0U;
+ return;
+ }
+
+ const std::vector indices = visible_app_indices(*host, state.apps.showHiddenApps);
+ if (indices.empty()) {
+ state.apps.selectedAppIndex = 0U;
+ return;
+ }
+
+ if (state.apps.selectedAppIndex >= indices.size()) {
+ state.apps.selectedAppIndex = indices.size() - 1U;
+ }
+ }
+
+ std::vector build_menu_for_state(const app::ClientState &state) {
+ switch (state.shell.activeScreen) {
+ case app::ScreenId::settings:
+ return {
+ {settings_category_menu_id(app::SettingsCategory::logging), "Logging", settings_category_description(app::SettingsCategory::logging), true},
+ {settings_category_menu_id(app::SettingsCategory::display), "Display", settings_category_description(app::SettingsCategory::display), true},
+ {settings_category_menu_id(app::SettingsCategory::input), "Input", settings_category_description(app::SettingsCategory::input), true},
+ {settings_category_menu_id(app::SettingsCategory::reset), "Reset", settings_category_description(app::SettingsCategory::reset), true},
+ };
+ case app::ScreenId::add_host:
+ return {
+ {"edit-address", "Host Address", "Enter the IPv4 address for the PC that should be added to Moonlight.", true},
+ {"edit-port", "Host Port", "Override the default Moonlight host port when the PC listens on a custom value.", true},
+ {"test-connection", "Test Connection", "Check whether the current host address and port respond before saving anything.", true},
+ {"start-pairing", "Start Pairing", "Connect to the current host and begin PIN-based pairing.", true},
+ {"save-host", "Save Host", "Store this host in the saved host list and return to the home screen.", true},
+ {"cancel-add-host", "Cancel", "Discard the current host draft and return without saving.", true},
+ };
+ case app::ScreenId::pair_host:
+ return {
+ {"cancel-pairing", "Cancel", "Stop the current pairing attempt and return to the previous screen.", true},
+ };
+ case app::ScreenId::home:
+ case app::ScreenId::hosts:
+ case app::ScreenId::apps:
+ return {};
+ }
+
+ return {};
+ }
+
+ std::vector build_detail_menu_for_state(const app::ClientState &state) {
+ if (state.shell.activeScreen != app::ScreenId::settings) {
+ return {};
+ }
+
+ switch (state.settings.selectedCategory) {
+ case app::SettingsCategory::logging:
+ return {
+ {"view-log-file", "View Log File", "Open the runtime log file viewer so you can inspect the most recent log lines without leaving the shell.", true},
+ {"cycle-log-level", std::string("File Logging Level: ") + logging::to_string(state.settings.loggingLevel), "Choose the minimum severity written to moonlight.log. Lower levels produce more detail but increase disk writes.", true},
+ {"cycle-xemu-console-log-level", std::string("xemu Console Level: ") + logging::to_string(state.settings.xemuConsoleLoggingLevel), "Choose the minimum severity mirrored to xemu through DbgPrint() when you launch xemu with a serial console.", true},
+ };
+ case app::SettingsCategory::display:
+ return {
+ {"display-placeholder", "Display settings are not implemented yet", "Display-specific options are planned, but there are no adjustable display settings in this build yet.", true},
+ };
+ case app::SettingsCategory::input:
+ return {
+ {"input-placeholder", "Input settings are not implemented yet", "Input-specific options are planned, but there are no adjustable controller settings in this build yet.", true},
+ };
+ case app::SettingsCategory::reset:
+ {
+ std::vector items = {
+ {"factory-reset", "Factory Reset", "Delete every Moonlight saved file, including hosts, pairing identity, cached art, and logs.", true},
+ };
+ for (const startup::SavedFileEntry &savedFile : state.settings.savedFiles) {
+ items.push_back({std::string(DELETE_SAVED_FILE_MENU_ID_PREFIX) + savedFile.path, "Delete " + savedFile.displayName, "Delete only this saved file from disk while leaving the rest of the Moonlight data intact.", true});
+ }
+ return items;
+ }
+ }
+
+ return {};
+ }
+
+ void rebuild_menu(app::ClientState &state, const std::string &preferredItemId = {}, bool preserveSelection = true) {
+ const std::string previousSelection = preserveSelection && state.menu.selected_item() != nullptr ? state.menu.selected_item()->id : std::string {};
+ state.menu.set_items(build_menu_for_state(state));
+ if (!preferredItemId.empty() && state.menu.select_item_by_id(preferredItemId)) {
+ return;
+ }
+ if (!previousSelection.empty()) {
+ state.menu.select_item_by_id(previousSelection);
+ }
+
+ const std::string previousDetailSelection = preserveSelection && state.detailMenu.selected_item() != nullptr ? state.detailMenu.selected_item()->id : std::string {};
+ state.detailMenu.set_items(build_detail_menu_for_state(state));
+ if (!preferredItemId.empty() && state.detailMenu.select_item_by_id(preferredItemId)) {
+ return;
+ }
+ if (!previousDetailSelection.empty()) {
+ state.detailMenu.select_item_by_id(previousDetailSelection);
+ }
+ }
+
+ void rebuild_settings_detail_menu(app::ClientState &state, const std::string &preferredItemId = {}, bool preserveSelection = true) {
+ const std::string previousSelection = preserveSelection && state.detailMenu.selected_item() != nullptr ? state.detailMenu.selected_item()->id : std::string {};
+ state.detailMenu.set_items(build_detail_menu_for_state(state));
+ if (!preferredItemId.empty() && state.detailMenu.select_item_by_id(preferredItemId)) {
+ return;
+ }
+ if (!previousSelection.empty()) {
+ state.detailMenu.select_item_by_id(previousSelection);
+ }
+ }
+
+ void close_modal(app::ClientState &state) {
+ state.modal = {};
+ reset_confirmation(state);
+ }
+
+ void set_screen(app::ClientState &state, app::ScreenId screen, const std::string &preferredItemId = {}) {
+ unload_screen_state(state, screen);
+ state.shell.activeScreen = screen;
+ if (screen == app::ScreenId::settings) {
+ state.settings.savedFilesDirty = true;
+ state.settings.focusArea = app::SettingsFocusArea::categories;
+ }
+ close_modal(state);
+ rebuild_menu(state, preferredItemId, false);
+ if (screen == app::ScreenId::settings) {
+ sync_selected_settings_category_from_menu(state);
+ rebuild_settings_detail_menu(state);
+ }
+ clamp_selected_host_index(state);
+ clamp_selected_app_index(state);
+ }
+
+ void open_modal(app::ClientState &state, app::ModalId modalId, std::size_t selectedActionIndex = 0U) {
+ state.modal.id = modalId;
+ state.modal.selectedActionIndex = selectedActionIndex;
+ }
+
+ void cycle_log_viewer_placement(app::ClientState &state) {
+ switch (state.settings.logViewerPlacement) {
+ case app::LogViewerPlacement::full:
+ state.settings.logViewerPlacement = app::LogViewerPlacement::left;
+ return;
+ case app::LogViewerPlacement::left:
+ state.settings.logViewerPlacement = app::LogViewerPlacement::right;
+ return;
+ case app::LogViewerPlacement::right:
+ state.settings.logViewerPlacement = app::LogViewerPlacement::full;
+ return;
+ }
+ }
+
+ void scroll_log_viewer(app::ClientState &state, bool towardOlderEntries, std::size_t step) {
+ if (state.settings.logViewerLines.empty() || step == 0U) {
+ state.settings.logViewerScrollOffset = 0U;
+ return;
+ }
+
+ const std::size_t maxOffset = state.settings.logViewerLines.size() > 1U ? state.settings.logViewerLines.size() - 1U : 0U;
+ if (towardOlderEntries) {
+ state.settings.logViewerScrollOffset = std::min(maxOffset, state.settings.logViewerScrollOffset + step);
+ return;
+ }
+
+ state.settings.logViewerScrollOffset = state.settings.logViewerScrollOffset > step ? state.settings.logViewerScrollOffset - step : 0U;
+ }
+
+ std::size_t modal_action_count(const app::ClientState &state) {
+ switch (state.modal.id) {
+ case app::ModalId::host_actions:
+ return 4U;
+ case app::ModalId::app_actions:
+ return 3U;
+ case app::ModalId::confirmation:
+ return 2U;
+ case app::ModalId::none:
+ case app::ModalId::support:
+ case app::ModalId::host_details:
+ case app::ModalId::app_details:
+ case app::ModalId::log_viewer:
+ return 0U;
+ }
+ return 0U;
+ }
+
+ bool move_modal_selection(app::ClientState &state, int direction) {
+ const std::size_t count = modal_action_count(state);
+ if (count == 0U) {
+ return false;
+ }
+
+ const std::size_t current = state.modal.selectedActionIndex % count;
+ state.modal.selectedActionIndex = direction < 0 ? (current + count - 1U) % count : (current + 1U) % count;
+ return state.modal.selectedActionIndex != current;
+ }
+
+ void open_add_host_keypad(app::ClientState &state, app::AddHostField field) {
+ state.addHostDraft.activeField = field;
+ state.addHostDraft.keypad.visible = true;
+ state.addHostDraft.keypad.selectedButtonIndex = 0U;
+ state.addHostDraft.keypad.stagedInput = field == app::AddHostField::address ? state.addHostDraft.addressInput : state.addHostDraft.portInput;
+ state.shell.statusMessage = field == app::AddHostField::address ? "Editing host address" : "Editing host port";
+ rebuild_menu(state, add_host_field_menu_id(field));
+ }
+
+ void close_add_host_keypad(app::ClientState &state) {
+ state.addHostDraft.keypad.visible = false;
+ state.addHostDraft.keypad.stagedInput.clear();
+ rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField));
+ }
+
+ void accept_add_host_keypad(app::ClientState &state) {
+ if (state.addHostDraft.activeField == app::AddHostField::address) {
+ state.addHostDraft.addressInput = state.addHostDraft.keypad.stagedInput;
+ state.shell.statusMessage = "Updated host address";
+ } else {
+ state.addHostDraft.portInput = state.addHostDraft.keypad.stagedInput;
+ state.shell.statusMessage = state.addHostDraft.portInput.empty() ? "Using default Moonlight host port 47989" : "Updated host port";
+ }
+
+ state.addHostDraft.validationMessage.clear();
+ state.addHostDraft.connectionMessage.clear();
+ close_add_host_keypad(state);
+ }
+
+ void cancel_add_host_keypad(app::ClientState &state) {
+ state.shell.statusMessage = state.addHostDraft.activeField == app::AddHostField::address ? "Cancelled host address edit" : "Cancelled host port edit";
+ close_add_host_keypad(state);
+ }
+
+ bool move_add_host_keypad_selection(app::ClientState &state, int rowDelta, int columnDelta) {
+ const AddHostKeypadLayout layout = add_host_keypad_layout(state);
+ if (layout.buttonCount == 0U) {
+ return false;
+ }
+
+ const auto rowCount = static_cast((layout.buttonCount + ADD_HOST_KEYPAD_COLUMN_COUNT - 1U) / ADD_HOST_KEYPAD_COLUMN_COUNT);
+ const std::size_t currentIndex = state.addHostDraft.keypad.selectedButtonIndex % layout.buttonCount;
+ const auto currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT);
+ const auto currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT);
+
+ auto wrap_index = [](int value, int count) {
+ if (count <= 0) {
+ return 0;
+ }
+
+ int wrappedValue = value % count;
+ if (wrappedValue < 0) {
+ wrappedValue += count;
+ }
+ return wrappedValue;
+ };
+
+ int targetRow = currentRow;
+ int targetColumn = currentColumn;
+ if (rowDelta != 0) {
+ targetRow = wrap_index(currentRow + rowDelta, rowCount);
+ const std::size_t rowStart = static_cast(targetRow) * ADD_HOST_KEYPAD_COLUMN_COUNT;
+ const std::size_t rowWidth = std::min(ADD_HOST_KEYPAD_COLUMN_COUNT, layout.buttonCount - rowStart);
+ targetColumn = static_cast(std::min(currentColumn, rowWidth - 1U));
+ }
+
+ const std::size_t targetRowStart = static_cast(targetRow) * ADD_HOST_KEYPAD_COLUMN_COUNT;
+ if (const std::size_t targetRowWidth = std::min(ADD_HOST_KEYPAD_COLUMN_COUNT, layout.buttonCount - targetRowStart); columnDelta != 0 && targetRowWidth > 0U) {
+ targetColumn = wrap_index(targetColumn + columnDelta, static_cast(targetRowWidth));
+ }
+
+ const auto nextIndex = targetRowStart + static_cast(targetColumn);
+ state.addHostDraft.keypad.selectedButtonIndex = nextIndex;
+ return nextIndex != currentIndex;
+ }
+
+ void append_to_active_add_host_field(app::ClientState &state, char character) {
+ state.addHostDraft.keypad.stagedInput.push_back(character);
+ state.addHostDraft.validationMessage.clear();
+ state.addHostDraft.connectionMessage.clear();
+ }
+
+ void backspace_active_add_host_field(app::ClientState &state) {
+ if (!state.addHostDraft.keypad.stagedInput.empty()) {
+ state.addHostDraft.keypad.stagedInput.pop_back();
+ }
+ }
+
+ bool normalize_add_host_inputs(const app::ClientState &state, std::string *normalizedAddress, uint16_t *parsedPort, std::string *errorMessage) {
+ const std::string address = app::normalize_ipv4_address(state.addHostDraft.addressInput);
+ if (address.empty()) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Enter a valid IPv4 host address";
+ }
+ return false;
+ }
+
+ uint16_t port = 0;
+ if (!state.addHostDraft.portInput.empty() && !app::try_parse_host_port(state.addHostDraft.portInput, &port)) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Enter a valid host port";
+ }
+ return false;
+ }
+
+ if (normalizedAddress != nullptr) {
+ *normalizedAddress = address;
+ }
+ if (parsedPort != nullptr) {
+ *parsedPort = port;
+ }
+ return true;
+ }
+
+ app::HostRecord make_host_record(const std::string &address, uint16_t port) {
+ return {
+ app::build_default_host_display_name(address),
+ address,
+ port,
+ app::PairingState::not_paired,
+ app::HostReachability::unknown,
+ {},
+ {},
+ {},
+ {},
+ {},
+ address,
+ {},
+ 0,
+ 0,
+ {},
+ app::HostAppListState::idle,
+ {},
+ 0,
+ };
+ }
+
+ void move_toolbar_selection(app::ClientState &state, int direction) {
+ const std::size_t current = state.hosts.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT;
+ state.hosts.selectedToolbarButtonIndex = direction < 0 ? (current + HOST_TOOLBAR_BUTTON_COUNT - 1U) % HOST_TOOLBAR_BUTTON_COUNT : (current + 1U) % HOST_TOOLBAR_BUTTON_COUNT;
+ }
+
+ std::size_t grid_row_count(std::size_t itemCount, std::size_t columnCount) {
+ return itemCount == 0U || columnCount == 0U ? 0U : ((itemCount + columnCount - 1U) / columnCount);
+ }
+
+ std::size_t grid_row_start(std::size_t row, std::size_t columnCount) {
+ return row * columnCount;
+ }
+
+ std::size_t grid_row_end(std::size_t itemCount, std::size_t row, std::size_t columnCount) {
+ return std::min(itemCount, grid_row_start(row, columnCount) + columnCount);
+ }
+
+ std::size_t closest_index_in_row(std::size_t itemCount, std::size_t row, std::size_t columnCount, std::size_t preferredColumn) {
+ const std::size_t rowStart = grid_row_start(row, columnCount);
+ const std::size_t rowEnd = grid_row_end(itemCount, row, columnCount);
+ if (rowStart >= rowEnd) {
+ return itemCount == 0U ? 0U : (itemCount - 1U);
+ }
+
+ return rowStart + std::min(preferredColumn, (rowEnd - rowStart) - 1U);
+ }
+
+ bool move_grid_selection(std::size_t itemCount, std::size_t columnCount, int rowDelta, int columnDelta, std::size_t *selectedIndex, bool *movedAboveFirstRow = nullptr) {
+ if (movedAboveFirstRow != nullptr) {
+ *movedAboveFirstRow = false;
+ }
+ if (selectedIndex == nullptr || itemCount == 0U || columnCount == 0U) {
+ return false;
+ }
+
+ std::size_t currentIndex = std::min(*selectedIndex, itemCount - 1U);
+ const std::size_t rowCount = grid_row_count(itemCount, columnCount);
+ const std::size_t currentRow = currentIndex / columnCount;
+ const std::size_t currentColumn = currentIndex % columnCount;
+
+ if (columnDelta > 0) {
+ for (int step = 0; step < columnDelta; ++step) {
+ if (const std::size_t rowEnd = grid_row_end(itemCount, currentRow, columnCount); currentIndex + 1U < rowEnd) {
+ ++currentIndex;
+ continue;
+ }
+
+ const std::size_t nextRow = (currentIndex / columnCount) + 1U;
+ if (nextRow >= rowCount) {
+ break;
+ }
+ currentIndex = grid_row_start(nextRow, columnCount);
+ }
+ *selectedIndex = currentIndex;
+ return true;
+ }
+
+ if (columnDelta < 0) {
+ for (int step = 0; step < -columnDelta; ++step) {
+ if ((currentIndex % columnCount) > 0U) {
+ --currentIndex;
+ continue;
+ }
+
+ const std::size_t currentResolvedRow = currentIndex / columnCount;
+ if (currentResolvedRow == 0U) {
+ break;
+ }
+ const std::size_t previousRow = currentResolvedRow - 1U;
+ currentIndex = grid_row_end(itemCount, previousRow, columnCount) - 1U;
+ }
+ *selectedIndex = currentIndex;
+ return true;
+ }
+
+ if (rowDelta == 0) {
+ return false;
+ }
+
+ const int targetRow = static_cast(currentRow) + rowDelta;
+ if (targetRow < 0) {
+ if (movedAboveFirstRow != nullptr) {
+ *movedAboveFirstRow = true;
+ }
+ return false;
+ }
+
+ const std::size_t clampedRow = std::min(static_cast(targetRow), rowCount - 1U);
+ *selectedIndex = closest_index_in_row(itemCount, clampedRow, columnCount, currentColumn);
+ return true;
+ }
+
+ void move_host_grid_selection(app::ClientState &state, int rowDelta, int columnDelta) {
+ if (state.hosts.items.empty()) {
+ state.hosts.focusArea = app::HostsFocusArea::toolbar;
+ return;
+ }
+
+ bool movedAboveFirstRow = false;
+ move_grid_selection(state.hosts.items.size(), HOST_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.hosts.selectedHostIndex, &movedAboveFirstRow);
+ if (movedAboveFirstRow) {
+ state.hosts.focusArea = app::HostsFocusArea::toolbar;
+ return;
+ }
+ state.hosts.focusArea = app::HostsFocusArea::grid;
+ }
+
+ void move_app_grid_selection(app::ClientState &state, int rowDelta, int columnDelta) {
+ const app::HostRecord *host = app::apps_host(state);
+ if (host == nullptr) {
+ state.apps.selectedAppIndex = 0U;
+ return;
+ }
+
+ const std::vector indices = visible_app_indices(*host, state.apps.showHiddenApps);
+ if (indices.empty()) {
+ state.apps.selectedAppIndex = 0U;
+ return;
+ }
+
+ move_grid_selection(indices.size(), APP_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.apps.selectedAppIndex);
+ }
+
+ void enter_add_host_screen(app::ClientState &state) {
+ reset_add_host_draft(state, state.shell.activeScreen == app::ScreenId::add_host ? app::ScreenId::hosts : state.shell.activeScreen);
+ set_screen(state, app::ScreenId::add_host, "edit-address");
+ }
+
+ bool enter_pair_host_screen(app::ClientState &state, const std::string &address, uint16_t port) {
+ if (const app::HostRecord *host = find_loaded_host_by_endpoint(state, address, port); host != nullptr && host->reachability == app::HostReachability::offline) {
+ state.shell.statusMessage = "Host is offline. Bring it online before pairing.";
+ return false;
+ }
+
+ if (const app::HostRecord *host = find_loaded_host_by_endpoint(state, address, port); host != nullptr) {
+ copy_host_to_active_host(state, *host);
+ }
+
+ std::string pairingPin;
+ if (std::string pinError; !network::generate_pairing_pin(&pairingPin, &pinError)) {
+ state.shell.statusMessage = pinError.empty() ? "Failed to generate a secure pairing PIN." : std::move(pinError);
+ return false;
+ }
+
+ state.pairingDraft = app::create_pairing_draft(address, app::effective_host_port(port), pairingPin);
+ set_screen(state, app::ScreenId::pair_host, "cancel-pairing");
+ return true;
+ }
+
+ bool enter_apps_screen(app::ClientState &state, bool showHiddenApps) {
+ const app::HostRecord *host = state.hosts.items.empty() ? nullptr : &state.hosts.items[state.hosts.selectedHostIndex];
+ if (host == nullptr) {
+ return false;
+ }
+ if (host->reachability == app::HostReachability::offline) {
+ state.shell.statusMessage = "Host is offline. Bring it online before opening apps.";
+ return false;
+ }
+ if (host->pairingState != app::PairingState::paired) {
+ state.shell.statusMessage = "This host is no longer paired. Pair it again before opening apps.";
+ return false;
+ }
+
+ copy_host_to_active_host(state, *host);
+ state.apps.showHiddenApps = showHiddenApps;
+ state.apps.selectedAppIndex = 0U;
+ state.apps.scrollPage = 0U;
+ state.hosts.active.appListState = app::HostAppListState::loading;
+ state.hosts.active.appListStatusMessage = (state.hosts.active.apps.empty() ? "Loading apps for " : "Refreshing apps for ") + state.hosts.active.displayName + "...";
+ state.shell.statusMessage.clear();
+ set_screen(state, app::ScreenId::apps);
+ return true;
+ }
+
+ void select_host_by_endpoint(app::ClientState &state, const std::string &address, uint16_t port) {
+ for (std::size_t index = 0; index < state.hosts.items.size(); ++index) {
+ if (app::host_matches_endpoint(state.hosts.items[index], address, port)) {
+ state.hosts.selectedHostIndex = index;
+ state.hosts.focusArea = app::HostsFocusArea::grid;
+ remember_host_selection(state, state.hosts.items[index]);
+ return;
+ }
+ }
+ }
+
+ logging::LogLevel next_logging_level(logging::LogLevel currentLevel) {
+ switch (currentLevel) {
+ case logging::LogLevel::none:
+ return logging::LogLevel::error;
+ case logging::LogLevel::trace:
+ return logging::LogLevel::none;
+ case logging::LogLevel::debug:
+ return logging::LogLevel::trace;
+ case logging::LogLevel::info:
+ return logging::LogLevel::debug;
+ case logging::LogLevel::warning:
+ return logging::LogLevel::info;
+ case logging::LogLevel::error:
+ return logging::LogLevel::warning;
+ }
+ return logging::LogLevel::none;
+ }
+
+ /**
+ * @brief Closes the active modal and records the close in the outgoing update.
+ *
+ * @param state Client state whose modal should be dismissed.
+ * @param update Update structure that tracks modal lifecycle changes.
+ */
+ void close_modal_and_mark_closed(app::ClientState &state, app::AppUpdate *update) {
+ close_modal(state);
+ if (update != nullptr) {
+ update->navigation.modalClosed = true;
+ }
+ }
+
+ /**
+ * @brief Copies the current pairing draft into an update after entering the pairing screen.
+ *
+ * @param state Client state containing the generated pairing draft.
+ * @param update Update structure to populate.
+ */
+ void assign_pairing_request_from_draft(const app::ClientState &state, app::AppUpdate *update) {
+ if (update == nullptr) {
+ return;
+ }
+
+ update->navigation.screenChanged = true;
+ update->requests.pairingRequested = true;
+ update->requests.pairingAddress = state.pairingDraft.targetAddress;
+ update->requests.pairingPort = state.pairingDraft.targetPort;
+ update->requests.pairingPin = state.pairingDraft.generatedPin;
+ }
+
+ /**
+ * @brief Tries to enter the pairing screen for a host action and fills the update when successful.
+ *
+ * @param state Client state to transition.
+ * @param host Host selected for the pairing flow.
+ * @param update Update structure that receives the pairing request details.
+ */
+ void request_host_pairing(app::ClientState &state, const app::HostRecord &host, app::AppUpdate *update) {
+ if (!enter_pair_host_screen(state, host.address, host.port)) {
+ return;
+ }
+
+ assign_pairing_request_from_draft(state, update);
+ }
+
+ /**
+ * @brief Collects unique cover-art cache keys before deleting a saved host.
+ *
+ * @param deletedHost Host being removed from the saved-host list.
+ * @param update Update structure that receives cleanup work.
+ */
+ void collect_deleted_host_cover_art_keys(const app::HostRecord &deletedHost, app::AppUpdate *update) {
+ if (update == nullptr) {
+ return;
+ }
+
+ for (const app::HostAppRecord &appRecord : deletedHost.apps) {
+ if (appRecord.boxArtCacheKey.empty()) {
+ continue;
+ }
+ if (std::find(update->persistence.deletedHostCoverArtCacheKeys.begin(), update->persistence.deletedHostCoverArtCacheKeys.end(), appRecord.boxArtCacheKey) != update->persistence.deletedHostCoverArtCacheKeys.end()) {
+ continue;
+ }
+ update->persistence.deletedHostCoverArtCacheKeys.push_back(appRecord.boxArtCacheKey);
+ }
+ }
+
+ /**
+ * @brief Deletes the currently selected host and records the cleanup side effects.
+ *
+ * @param state Client state containing the selected host.
+ * @param update Update structure that receives deletion work.
+ */
+ void delete_selected_host(app::ClientState &state, app::AppUpdate *update) {
+ if (update == nullptr || state.hosts.selectedHostIndex >= state.hosts.items.size()) {
+ return;
+ }
+
+ const app::HostRecord deletedHost = state.hosts.items[state.hosts.selectedHostIndex];
+ remember_deleted_host_pairing(state, deletedHost);
+ update->persistence.hostDeleteCleanupRequested = true;
+ update->persistence.deletedHostAddress = deletedHost.address;
+ update->persistence.deletedHostPort = deletedHost.port;
+ update->persistence.deletedHostWasPaired = deletedHost.pairingState == app::PairingState::paired;
+ collect_deleted_host_cover_art_keys(deletedHost, update);
+ state.hosts.items.erase(state.hosts.items.begin() + static_cast(state.hosts.selectedHostIndex));
+ state.hosts.dirty = true;
+ update->persistence.hostsChanged = true;
+ clamp_selected_host_index(state);
+ close_modal_and_mark_closed(state, update);
+ state.shell.statusMessage = "Deleted saved host";
+ }
+
+ /**
+ * @brief Handles commands while the log viewer modal is active.
+ *
+ * @param state Client state containing the log viewer modal.
+ * @param command Command being processed.
+ * @param update Update structure that receives side effects.
+ * @return True when the command was consumed by the log viewer.
+ */
+ bool handle_log_viewer_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) {
+ if (state.modal.id != app::ModalId::log_viewer) {
+ return false;
+ }
+
+ switch (command) {
+ case input::UiCommand::back:
+ case input::UiCommand::activate:
+ case input::UiCommand::confirm:
+ close_modal_and_mark_closed(state, update);
+ return true;
+ case input::UiCommand::delete_character:
+ case input::UiCommand::open_context_menu:
+ cycle_log_viewer_placement(state);
+ state.settings.dirty = true;
+ if (update != nullptr) {
+ update->persistence.settingsChanged = true;
+ }
+ return true;
+ case input::UiCommand::previous_page:
+ scroll_log_viewer(state, true, LOG_VIEWER_SCROLL_STEP);
+ return true;
+ case input::UiCommand::next_page:
+ scroll_log_viewer(state, false, LOG_VIEWER_SCROLL_STEP);
+ return true;
+ case input::UiCommand::fast_previous_page:
+ scroll_log_viewer(state, true, LOG_VIEWER_FAST_SCROLL_STEP);
+ return true;
+ case input::UiCommand::fast_next_page:
+ scroll_log_viewer(state, false, LOG_VIEWER_FAST_SCROLL_STEP);
+ return true;
+ case input::UiCommand::move_up:
+ case input::UiCommand::move_down:
+ case input::UiCommand::move_left:
+ case input::UiCommand::move_right:
+ case input::UiCommand::toggle_overlay:
+ case input::UiCommand::none:
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * @brief Handles confirmation modal activation commands.
+ *
+ * @param state Client state containing the confirmation dialog.
+ * @param update Update structure that receives the confirmed action.
+ * @return True after consuming the confirmation action.
+ */
+ bool handle_confirmation_modal_activation(app::ClientState &state, app::AppUpdate *update) {
+ const bool confirmed = state.modal.selectedActionIndex % 2U == 0U;
+ const app::ConfirmationAction action = state.confirmation.action;
+ const std::string targetPath = state.confirmation.targetPath;
+ close_modal_and_mark_closed(state, update);
+ if (!confirmed) {
+ state.shell.statusMessage = "Cancelled the pending reset action";
+ return true;
+ }
+ if (update == nullptr) {
+ return true;
+ }
+ if (action == app::ConfirmationAction::delete_saved_file) {
+ update->persistence.savedFileDeleteRequested = true;
+ update->persistence.savedFileDeletePath = targetPath;
+ return true;
+ }
+ if (action == app::ConfirmationAction::factory_reset) {
+ update->persistence.factoryResetRequested = true;
+ }
+ return true;
+ }
+
+ /**
+ * @brief Handles activation inside the host actions modal.
+ *
+ * @param state Client state containing the selected host.
+ * @param update Update structure that receives host-action side effects.
+ * @return True after consuming the modal activation.
+ */
+ bool handle_host_actions_modal_activation(app::ClientState &state, app::AppUpdate *update) {
+ const app::HostRecord *host = app::selected_host(state);
+ if (host == nullptr) {
+ close_modal_and_mark_closed(state, update);
+ return true;
+ }
+
+ switch (state.modal.selectedActionIndex % 4U) {
+ case 0:
+ close_modal_and_mark_closed(state, update);
+ if (host->pairingState == app::PairingState::paired) {
+ if (update != nullptr) {
+ update->requests.appsBrowseRequested = true;
+ update->requests.appsBrowseShowHidden = true;
+ }
+ return true;
+ }
+ request_host_pairing(state, *host, update);
+ return true;
+ case 1:
+ close_modal_and_mark_closed(state, update);
+ if (update != nullptr) {
+ update->requests.connectionTestRequested = true;
+ update->requests.connectionTestAddress = host->address;
+ update->requests.connectionTestPort = app::effective_host_port(host->port);
+ }
+ return true;
+ case 2:
+ delete_selected_host(state, update);
+ return true;
+ case 3:
+ open_modal(state, app::ModalId::host_details);
+ return true;
+ default:
+ return true;
+ }
+ }
+
+ /**
+ * @brief Handles activation inside the app actions modal.
+ *
+ * @param state Client state containing the selected app.
+ * @param update Update structure that receives app-action side effects.
+ * @return True after consuming the modal activation.
+ */
+ bool handle_app_actions_modal_activation(app::ClientState &state, app::AppUpdate *update) {
+ const app::HostRecord *host = app::apps_host(state);
+ if (const app::HostAppRecord *selectedApp = app::selected_app(state); host == nullptr || selectedApp == nullptr) {
+ close_modal_and_mark_closed(state, update);
+ return true;
+ }
+
+ app::HostRecord *mutableHost = state.hosts.activeLoaded ? &state.hosts.active : nullptr;
+ if (mutableHost == nullptr) {
+ close_modal_and_mark_closed(state, update);
+ return true;
+ }
+
+ const std::vector indices = visible_app_indices(*mutableHost, state.apps.showHiddenApps);
+ if (indices.empty()) {
+ close_modal_and_mark_closed(state, update);
+ return true;
+ }
+
+ app::HostAppRecord &appRecord = mutableHost->apps[indices[state.apps.selectedAppIndex]];
+ switch (state.modal.selectedActionIndex % 3U) {
+ case 0:
+ appRecord.hidden = !appRecord.hidden;
+ state.hosts.dirty = true;
+ close_modal_and_mark_closed(state, update);
+ if (update != nullptr) {
+ update->persistence.hostsChanged = true;
+ }
+ clamp_selected_app_index(state);
+ return true;
+ case 1:
+ open_modal(state, app::ModalId::app_details);
+ return true;
+ case 2:
+ appRecord.favorite = !appRecord.favorite;
+ state.hosts.dirty = true;
+ close_modal_and_mark_closed(state, update);
+ if (update != nullptr) {
+ update->persistence.hostsChanged = true;
+ }
+ return true;
+ default:
+ return true;
+ }
+ }
+
+ /**
+ * @brief Handles activation or confirmation commands for the active modal.
+ *
+ * @param state Client state containing the modal.
+ * @param update Update structure that receives modal side effects.
+ * @return True when a modal action was processed.
+ */
+ bool handle_modal_activation(app::ClientState &state, app::AppUpdate *update) {
+ switch (state.modal.id) {
+ case app::ModalId::support:
+ case app::ModalId::host_details:
+ case app::ModalId::app_details:
+ close_modal_and_mark_closed(state, update);
+ return true;
+ case app::ModalId::log_viewer:
+ return true;
+ case app::ModalId::confirmation:
+ return handle_confirmation_modal_activation(state, update);
+ case app::ModalId::host_actions:
+ return handle_host_actions_modal_activation(state, update);
+ case app::ModalId::app_actions:
+ return handle_app_actions_modal_activation(state, update);
+ case app::ModalId::none:
+ return false;
+ }
+
+ return false;
+ }
+
+ bool handle_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) {
+ if (!state.modal.active()) {
+ return false;
+ }
+
+ if (handle_log_viewer_modal_command(state, command, update)) {
+ return true;
+ }
+
+ if (command == input::UiCommand::back) {
+ close_modal_and_mark_closed(state, update);
+ return true;
+ }
+
+ if (command == input::UiCommand::move_up || command == input::UiCommand::move_left) {
+ move_modal_selection(state, -1);
+ return true;
+ }
+ if (command == input::UiCommand::move_down || command == input::UiCommand::move_right) {
+ move_modal_selection(state, 1);
+ return true;
+ }
+
+ if (command != input::UiCommand::activate && command != input::UiCommand::confirm) {
+ return true;
+ }
+
+ return handle_modal_activation(state, update);
+ }
+
+} // namespace
+
+namespace app {
+
+ ClientState create_initial_state() {
+ ClientState state;
+ state.shell.activeScreen = ScreenId::hosts;
+ state.shell.overlayVisible = false;
+ state.shell.shouldExit = false;
+ state.hosts.dirty = false;
+ state.hosts.loaded = true;
+ state.shell.overlayScrollOffset = 0U;
+ state.hosts.focusArea = HostsFocusArea::toolbar;
+ state.hosts.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX;
+ state.hosts.selectedHostIndex = 0U;
+ state.apps.selectedAppIndex = 0U;
+ state.apps.scrollPage = 0U;
+ state.apps.showHiddenApps = false;
+ state.hosts.activeLoaded = false;
+ state.hosts.selectedPort = 0;
+ state.addHostDraft.activeField = AddHostField::address;
+ state.addHostDraft.keypad.visible = false;
+ state.addHostDraft.keypad.selectedButtonIndex = 0U;
+ state.addHostDraft.returnScreen = ScreenId::hosts;
+ state.addHostDraft.lastConnectionSucceeded = false;
+ state.settings.focusArea = SettingsFocusArea::categories;
+ state.pairingDraft.targetPort = DEFAULT_HOST_PORT;
+ state.pairingDraft.stage = PairingStage::idle;
+ state.settings.selectedCategory = SettingsCategory::logging;
+ state.settings.logViewerScrollOffset = 0U;
+ state.settings.logViewerPlacement = LogViewerPlacement::full;
+ state.settings.loggingLevel = logging::LogLevel::none;
+ state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none;
+ state.settings.dirty = false;
+ state.settings.savedFilesDirty = true;
+ return state;
+ }
+
+ const char *to_string(ScreenId screen) {
+ switch (screen) {
+ case ScreenId::home:
+ return "home";
+ case ScreenId::hosts:
+ return "hosts";
+ case ScreenId::apps:
+ return "apps";
+ case ScreenId::add_host:
+ return "add_host";
+ case ScreenId::pair_host:
+ return "pair_host";
+ case ScreenId::settings:
+ return "settings";
+ }
+ return "unknown";
+ }
+
+ void replace_hosts(ClientState &state, std::vector hosts, std::string statusMessage) {
+ state.hosts.items = std::move(hosts);
+ state.hosts.loaded = true;
+ state.hosts.dirty = false;
+ state.shell.statusMessage = std::move(statusMessage);
+ bool restoredSelection = false;
+ if (!state.hosts.selectedAddress.empty()) {
+ for (std::size_t index = 0; index < state.hosts.items.size(); ++index) {
+ if (host_matches_endpoint(state.hosts.items[index], state.hosts.selectedAddress, state.hosts.selectedPort)) {
+ state.hosts.selectedHostIndex = index;
+ state.hosts.focusArea = HostsFocusArea::grid;
+ restoredSelection = true;
+ break;
+ }
+ }
+ }
+ if (!restoredSelection) {
+ reset_hosts_home_selection(state);
+ }
+ clamp_selected_host_index(state);
+ clamp_selected_app_index(state);
+ if (state.shell.activeScreen == ScreenId::hosts) {
+ clear_active_host(state);
+ }
+
+ if (state.shell.activeScreen == ScreenId::settings || state.shell.activeScreen == ScreenId::add_host || state.shell.activeScreen == ScreenId::pair_host) {
+ rebuild_menu(state);
+ }
+ }
+
+ void replace_saved_files(ClientState &state, std::vector savedFiles) {
+ state.settings.savedFiles = std::move(savedFiles);
+ state.settings.savedFilesDirty = false;
+ if (state.shell.activeScreen == ScreenId::settings) {
+ rebuild_menu(state, state.detailMenu.selected_item() != nullptr ? state.detailMenu.selected_item()->id : std::string {});
+ }
+ }
+
+ std::string current_add_host_address(const ClientState &state) {
+ return state.addHostDraft.addressInput;
+ }
+
+ uint16_t current_add_host_port(const ClientState &state) {
+ uint16_t port = 0;
+ return try_parse_host_port(state.addHostDraft.portInput, &port) ? effective_host_port(port) : DEFAULT_HOST_PORT;
+ }
+
+ bool begin_selected_host_app_browse(ClientState &state, bool showHiddenApps) {
+ return enter_apps_screen(state, showHiddenApps);
+ }
+
+ std::string current_pairing_pin(const ClientState &state) {
+ return state.pairingDraft.generatedPin;
+ }
+
+ void apply_connection_test_result(ClientState &state, bool success, std::string message) {
+ if (state.shell.activeScreen == ScreenId::add_host) {
+ state.addHostDraft.connectionMessage = message;
+ state.addHostDraft.lastConnectionSucceeded = success;
+ }
+ if (!state.hosts.items.empty() && state.hosts.selectedHostIndex < state.hosts.items.size()) {
+ state.hosts.items[state.hosts.selectedHostIndex].reachability = success ? HostReachability::online : HostReachability::offline;
+ } else if (state.hosts.activeLoaded) {
+ state.hosts.active.reachability = success ? HostReachability::online : HostReachability::offline;
+ }
+ state.shell.statusMessage = std::move(message);
+ }
+
+ bool apply_pairing_result(ClientState &state, const std::string &address, uint16_t port, bool success, std::string message) {
+ state.pairingDraft.targetAddress = address;
+ state.pairingDraft.targetPort = effective_host_port(port);
+ state.pairingDraft.stage = success ? PairingStage::paired : PairingStage::failed;
+ if (!success) {
+ state.pairingDraft.generatedPin.clear();
+ }
+ state.pairingDraft.statusMessage = message;
+ state.shell.statusMessage = std::move(message);
+
+ HostRecord *host = find_loaded_host_by_endpoint(state, address, port);
+ if (host == nullptr) {
+ return false;
+ }
+
+ if (success) {
+ clear_deleted_host_pairing(state, address, port);
+ host->pairingState = PairingState::paired;
+ host->reachability = HostReachability::online;
+ if (state.hosts.loaded) {
+ select_host_by_endpoint(state, address, port);
+ } else {
+ remember_host_selection(state, *host);
+ }
+ set_screen(state, ScreenId::hosts);
+ state.hosts.dirty = true;
+ return true;
+ }
+
+ host->pairingState = PairingState::not_paired;
+ return false;
+ }
+
+ /**
+ * @brief Finds the host that should receive an app-list refresh result.
+ *
+ * @param state Client state that owns saved and active hosts.
+ * @param address Address reported by the background task.
+ * @param port Port reported by the background task.
+ * @return Pointer to the matching host, or null when no host matches.
+ */
+ HostRecord *find_app_list_result_host(ClientState &state, const std::string &address, uint16_t port) {
+ if (HostRecord *host = find_host_by_endpoint(state.hosts.items, address, port); host != nullptr) {
+ return host;
+ }
+ if (state.shell.activeScreen == ScreenId::apps && state.hosts.activeLoaded && host_matches_endpoint(state.hosts.active, address, port)) {
+ return &state.hosts.active;
+ }
+ return nullptr;
+ }
+
+ /**
+ * @brief Returns whether the given host is the active apps-screen selection.
+ *
+ * @param state Client state to inspect.
+ * @param host Host potentially backing the apps screen.
+ * @return True when the host is the active apps-screen selection.
+ */
+ bool app_list_result_targets_active_selection(const ClientState &state, const HostRecord *host) {
+ return host != nullptr && state.shell.activeScreen == ScreenId::apps && state.hosts.activeLoaded && host == &state.hosts.active;
+ }
+
+ /**
+ * @brief Returns the selected app identifier so it can be restored after a refresh.
+ *
+ * @param state Client state whose selected app should be preserved.
+ * @return The selected app ID, or zero when no app is selected.
+ */
+ int selected_app_id_for_restore(const ClientState &state) {
+ const HostAppRecord *currentSelection = selected_app(state);
+ return currentSelection == nullptr ? 0 : currentSelection->id;
+ }
+
+ /**
+ * @brief Applies a failed app-list refresh for an unpaired host.
+ *
+ * @param state Client state to update.
+ * @param host Host receiving the failure.
+ * @param hostIsActiveAppsScreenSelection True when the host backs the active apps screen.
+ * @param message Failure message returned by the refresh task.
+ */
+ void apply_unpaired_app_list_failure(ClientState &state, HostRecord *host, bool hostIsActiveAppsScreenSelection, std::string message) {
+ if (host == nullptr) {
+ return;
+ }
+
+ const bool persistedAppCacheChanged = !host->apps.empty() || host->appListContentHash != 0U;
+ host->pairingState = PairingState::not_paired;
+ host->apps.clear();
+ host->appListContentHash = 0;
+ host->lastAppListRefreshTick = 0U;
+ host->appListState = HostAppListState::failed;
+ host->appListStatusMessage = message;
+ state.hosts.dirty = state.hosts.dirty || persistedAppCacheChanged;
+ if (hostIsActiveAppsScreenSelection) {
+ state.shell.statusMessage = std::move(message);
+ }
+ refresh_running_flags(host);
+ clamp_selected_app_index(state);
+ }
+
+ /**
+ * @brief Applies a failed app-list refresh that can fall back to cached app data.
+ *
+ * @param state Client state to update.
+ * @param host Host receiving the failure.
+ * @param hostIsActiveAppsScreenSelection True when the host backs the active apps screen.
+ * @param message Failure message returned by the refresh task.
+ */
+ void apply_cached_app_list_failure(ClientState &state, HostRecord *host, bool hostIsActiveAppsScreenSelection, std::string message) {
+ if (host == nullptr) {
+ return;
+ }
+
+ host->appListState = host->apps.empty() ? HostAppListState::failed : HostAppListState::ready;
+ host->appListStatusMessage = host->apps.empty() ? message : "Using cached apps. Last refresh failed: " + message;
+ if (hostIsActiveAppsScreenSelection) {
+ state.shell.statusMessage = std::move(message);
+ }
+ refresh_running_flags(host);
+ clamp_selected_app_index(state);
+ }
+
+ /**
+ * @brief Merges saved app metadata into a freshly fetched app list.
+ *
+ * @param host Host providing persisted per-app metadata.
+ * @param apps Freshly fetched apps to merge.
+ * @return Merged app records ready to persist.
+ */
+ std::vector merge_host_app_records(const HostRecord &host, std::vector apps) {
+ std::vector mergedApps;
+ mergedApps.reserve(apps.size());
+ for (HostAppRecord &appRecord : apps) {
+ if (const HostAppRecord *savedApp = find_app_by_id(host.apps, appRecord.id); savedApp != nullptr) {
+ appRecord.hidden = appRecord.hidden || savedApp->hidden;
+ appRecord.favorite = savedApp->favorite;
+ appRecord.boxArtCached = appRecord.boxArtCached || savedApp->boxArtCached;
+ if (appRecord.boxArtCacheKey.empty()) {
+ appRecord.boxArtCacheKey = savedApp->boxArtCacheKey;
+ }
+ }
+ appRecord.running = static_cast(appRecord.id) == host.runningGameId;
+ mergedApps.push_back(std::move(appRecord));
+ }
+ return mergedApps;
+ }
+
+ /**
+ * @brief Restores the previously selected app after a successful refresh.
+ *
+ * @param state Client state whose selected app should be restored.
+ * @param host Host containing the refreshed app list.
+ * @param selectedAppId App ID that was selected before the refresh.
+ */
+ void restore_selected_app_after_refresh(ClientState &state, const HostRecord &host, int selectedAppId) {
+ if (selectedAppId != 0) {
+ const std::size_t restoredIndex = visible_app_index_for_id(host, state.apps.showHiddenApps, selectedAppId);
+ if (restoredIndex != static_cast(-1)) {
+ state.apps.selectedAppIndex = restoredIndex;
+ }
+ }
+ clamp_selected_app_index(state);
+ }
+
+ void apply_app_list_result(
+ ClientState &state,
+ const std::string &address,
+ uint16_t port,
+ std::vector apps,
+ uint64_t appListContentHash,
+ bool success,
+ std::string message
+ ) {
+ HostRecord *host = find_app_list_result_host(state, address, port);
+ if (host == nullptr) {
+ return;
+ }
+
+ const bool hostIsActiveAppsScreenSelection = app_list_result_targets_active_selection(state, host);
+ const int selectedAppId = selected_app_id_for_restore(state);
+
+ if (!success) {
+ if (network::error_indicates_unpaired_client(message)) {
+ apply_unpaired_app_list_failure(state, host, hostIsActiveAppsScreenSelection, std::move(message));
+ return;
+ }
+
+ apply_cached_app_list_failure(state, host, hostIsActiveAppsScreenSelection, std::move(message));
+ return;
+ }
+
+ bool persistedAppCacheChanged = false;
+ if (const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; appListChanged) {
+ host->apps = merge_host_app_records(*host, std::move(apps));
+ persistedAppCacheChanged = true;
+ } else {
+ refresh_running_flags(host);
+ }
+
+ persistedAppCacheChanged = persistedAppCacheChanged || host->appListContentHash != appListContentHash;
+ host->appListContentHash = appListContentHash;
+ host->appListState = HostAppListState::ready;
+ host->appListStatusMessage = message;
+ state.hosts.dirty = state.hosts.dirty || persistedAppCacheChanged;
+ if (hostIsActiveAppsScreenSelection) {
+ state.shell.statusMessage.clear();
+ }
+
+ restore_selected_app_after_refresh(state, *host, selectedAppId);
+ }
+
+ void mark_cover_art_cached(ClientState &state, const std::string &address, uint16_t port, int appId) {
+ HostRecord *host = find_host_by_endpoint(state.hosts.items, address, port);
+ if (host == nullptr && state.shell.activeScreen == ScreenId::apps && state.hosts.activeLoaded && host_matches_endpoint(state.hosts.active, address, port)) {
+ host = &state.hosts.active;
+ }
+ if (host == nullptr) {
+ return;
+ }
+
+ for (HostAppRecord &appRecord : host->apps) {
+ if (appRecord.id == appId) {
+ if (appRecord.boxArtCached) {
+ return;
+ }
+ appRecord.boxArtCached = true;
+ state.hosts.dirty = true;
+ return;
+ }
+ }
+ }
+
+ void set_log_file_path(ClientState &state, std::string logFilePath) {
+ state.settings.logFilePath = std::move(logFilePath);
+ }
+
+ void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage) {
+ state.settings.logViewerLines = std::move(lines);
+ state.settings.logViewerScrollOffset = 0U;
+ state.shell.statusMessage = std::move(statusMessage);
+ open_modal(state, ModalId::log_viewer);
+ }
+
+ bool host_requires_manual_pairing(const ClientState &state, const std::string &address, uint16_t port) {
+ const std::string key = pairing_reset_endpoint_key(address, port);
+ return !key.empty() && std::find(state.hosts.pairingResetEndpoints.begin(), state.hosts.pairingResetEndpoints.end(), key) != state.hosts.pairingResetEndpoints.end();
+ }
+
+ const HostRecord *selected_host(const ClientState &state) {
+ if ((state.shell.activeScreen == ScreenId::apps || state.shell.activeScreen == ScreenId::pair_host) && state.hosts.activeLoaded) {
+ return &state.hosts.active;
+ }
+ if (state.hosts.items.empty() || state.hosts.selectedHostIndex >= state.hosts.items.size()) {
+ return nullptr;
+ }
+ return &state.hosts.items[state.hosts.selectedHostIndex];
+ }
+
+ const HostAppRecord *selected_app(const ClientState &state) {
+ const HostRecord *host = apps_host(state);
+ if (host == nullptr) {
+ return nullptr;
+ }
+ const std::vector indices = visible_app_indices(*host, state.apps.showHiddenApps);
+ if (indices.empty()) {
+ return nullptr;
+ }
+ const std::size_t visibleIndex = std::min(state.apps.selectedAppIndex, indices.size() - 1U);
+ return &host->apps[indices[visibleIndex]];
+ }
+
+ const HostRecord *apps_host(const ClientState &state) {
+ if (state.shell.activeScreen != ScreenId::apps || !state.hosts.activeLoaded) {
+ return nullptr;
+ }
+ return &state.hosts.active;
+ }
+
+ /**
+ * @brief Handles overlay toggle and scrolling commands.
+ *
+ * @param state Client state containing the overlay state.
+ * @param command Command being processed.
+ * @param update Update structure that receives overlay changes.
+ * @return True when the command was consumed by the overlay.
+ */
+ bool handle_overlay_command(ClientState &state, input::UiCommand command, AppUpdate *update) {
+ if (command == input::UiCommand::toggle_overlay) {
+ state.shell.overlayVisible = !state.shell.overlayVisible;
+ if (!state.shell.overlayVisible) {
+ state.shell.overlayScrollOffset = 0U;
+ }
+ if (update != nullptr) {
+ update->navigation.overlayChanged = true;
+ update->navigation.overlayVisibilityChanged = true;
+ }
+ return true;
+ }
+
+ if (!state.shell.overlayVisible || update == nullptr) {
+ return false;
+ }
+
+ switch (command) {
+ case input::UiCommand::previous_page:
+ state.shell.overlayScrollOffset += OVERLAY_SCROLL_STEP;
+ update->navigation.overlayChanged = true;
+ return true;
+ case input::UiCommand::next_page:
+ state.shell.overlayScrollOffset = state.shell.overlayScrollOffset > OVERLAY_SCROLL_STEP ? state.shell.overlayScrollOffset - OVERLAY_SCROLL_STEP : 0U;
+ update->navigation.overlayChanged = true;
+ return true;
+ case input::UiCommand::fast_previous_page:
+ state.shell.overlayScrollOffset += OVERLAY_SCROLL_STEP * 3U;
+ update->navigation.overlayChanged = true;
+ return true;
+ case input::UiCommand::fast_next_page:
+ {
+ const std::size_t fastStep = OVERLAY_SCROLL_STEP * 3U;
+ state.shell.overlayScrollOffset = state.shell.overlayScrollOffset > fastStep ? state.shell.overlayScrollOffset - fastStep : 0U;
+ update->navigation.overlayChanged = true;
+ return true;
+ }
+ case input::UiCommand::move_up:
+ case input::UiCommand::move_down:
+ case input::UiCommand::move_left:
+ case input::UiCommand::move_right:
+ case input::UiCommand::activate:
+ case input::UiCommand::confirm:
+ case input::UiCommand::back:
+ case input::UiCommand::delete_character:
+ case input::UiCommand::open_context_menu:
+ case input::UiCommand::toggle_overlay:
+ case input::UiCommand::none:
+ return false;
+ }
+
+ return false;
+ }
+
+ /**
+ * @brief Handles commands while the add-host keypad modal is visible.
+ *
+ * @param state Client state containing the keypad draft.
+ * @param command Command being processed.
+ * @return True when the command was consumed by the keypad.
+ */
+ bool handle_add_host_keypad_command(ClientState &state, input::UiCommand command) {
+ if (state.shell.activeScreen != ScreenId::add_host || !state.addHostDraft.keypad.visible) {
+ return false;
+ }
+
+ switch (command) {
+ case input::UiCommand::move_up:
+ move_add_host_keypad_selection(state, -1, 0);
+ return true;
+ case input::UiCommand::move_down:
+ move_add_host_keypad_selection(state, 1, 0);
+ return true;
+ case input::UiCommand::move_left:
+ move_add_host_keypad_selection(state, 0, -1);
+ return true;
+ case input::UiCommand::move_right:
+ move_add_host_keypad_selection(state, 0, 1);
+ return true;
+ case input::UiCommand::back:
+ cancel_add_host_keypad(state);
+ return true;
+ case input::UiCommand::delete_character:
+ backspace_active_add_host_field(state);
+ return true;
+ case input::UiCommand::confirm:
+ accept_add_host_keypad(state);
+ return true;
+ case input::UiCommand::activate:
+ {
+ if (char character = '\0'; selected_add_host_keypad_character(state, &character)) {
+ append_to_active_add_host_field(state, character);
+ }
+ return true;
+ }
+ case input::UiCommand::open_context_menu:
+ case input::UiCommand::previous_page:
+ case input::UiCommand::next_page:
+ case input::UiCommand::fast_previous_page:
+ case input::UiCommand::fast_next_page:
+ case input::UiCommand::toggle_overlay:
+ case input::UiCommand::none:
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * @brief Handles activation of a settings detail row.
+ *
+ * @param state Client state containing the settings menus.
+ * @param detailUpdate Activated detail-menu update.
+ * @param update Update structure that receives side effects.
+ */
+ void handle_settings_detail_activation(ClientState &state, const ui::MenuUpdate &detailUpdate, AppUpdate *update) {
+ if (update == nullptr) {
+ return;
+ }
+
+ update->navigation.activatedItemId = detailUpdate.activatedItemId;
+ if (detailUpdate.activatedItemId == "view-log-file") {
+ update->requests.logViewRequested = true;
+ return;
+ }
+ if (detailUpdate.activatedItemId == "cycle-log-level") {
+ state.settings.loggingLevel = next_logging_level(state.settings.loggingLevel);
+ state.settings.dirty = true;
+ update->persistence.settingsChanged = true;
+ state.shell.statusMessage = std::string("Logging level set to ") + logging::to_string(state.settings.loggingLevel);
+ rebuild_menu(state, "cycle-log-level");
+ return;
+ }
+ if (detailUpdate.activatedItemId == "cycle-xemu-console-log-level") {
+ state.settings.xemuConsoleLoggingLevel = next_logging_level(state.settings.xemuConsoleLoggingLevel);
+ state.settings.dirty = true;
+ update->persistence.settingsChanged = true;
+ state.shell.statusMessage = std::string("xemu console logging level set to ") + logging::to_string(state.settings.xemuConsoleLoggingLevel);
+ rebuild_menu(state, "cycle-xemu-console-log-level");
+ return;
+ }
+ if (detailUpdate.activatedItemId == "factory-reset") {
+ open_confirmation(
+ state,
+ ConfirmationAction::factory_reset,
+ "Factory Reset",
+ {
+ "Delete all Moonlight saved data?",
+ "This removes hosts, settings, logs, pairing identity, and cached cover art.",
+ }
+ );
+ update->navigation.modalOpened = true;
+ return;
+ }
+ if (starts_with(detailUpdate.activatedItemId, DELETE_SAVED_FILE_MENU_ID_PREFIX)) {
+ const std::string targetPath = detailUpdate.activatedItemId.substr(std::char_traits::length(DELETE_SAVED_FILE_MENU_ID_PREFIX));
+ open_confirmation(
+ state,
+ ConfirmationAction::delete_saved_file,
+ "Delete Saved File",
+ {
+ "Delete this saved file?",
+ targetPath,
+ },
+ targetPath
+ );
+ update->navigation.modalOpened = true;
+ return;
+ }
+ state.shell.statusMessage = detailUpdate.activatedItemId + " is not implemented yet";
+ }
+
+ /**
+ * @brief Handles commands on the settings screen.
+ *
+ * @param state Client state containing the settings menus.
+ * @param command Command being processed.
+ * @param update Update structure that receives side effects.
+ * @return True when the settings screen consumed the command.
+ */
+ bool handle_settings_screen_command(ClientState &state, input::UiCommand command, AppUpdate *update) {
+ if (state.shell.activeScreen != ScreenId::settings || update == nullptr) {
+ return false;
+ }
+
+ if (command == input::UiCommand::move_left && state.settings.focusArea == SettingsFocusArea::options) {
+ state.settings.focusArea = SettingsFocusArea::categories;
+ return true;
+ }
+ if (command == input::UiCommand::move_right && state.settings.focusArea == SettingsFocusArea::categories && !state.detailMenu.items().empty()) {
+ state.settings.focusArea = SettingsFocusArea::options;
+ return true;
+ }
+
+ if (state.settings.focusArea == SettingsFocusArea::categories) {
+ const ui::MenuUpdate categoryUpdate = state.menu.handle_command(command);
+ if (categoryUpdate.backRequested) {
+ set_screen(state, ScreenId::hosts);
+ update->navigation.screenChanged = true;
+ return true;
+ }
+ if (categoryUpdate.selectionChanged) {
+ sync_selected_settings_category_from_menu(state);
+ rebuild_settings_detail_menu(state, {}, false);
+ return true;
+ }
+ if (!categoryUpdate.activationRequested) {
+ return true;
+ }
+
+ update->navigation.activatedItemId = categoryUpdate.activatedItemId;
+ sync_selected_settings_category_from_menu(state);
+ rebuild_menu(state, categoryUpdate.activatedItemId);
+ if (!state.detailMenu.items().empty()) {
+ state.settings.focusArea = SettingsFocusArea::options;
+ }
+ return true;
+ }
+
+ const ui::MenuUpdate detailUpdate = state.detailMenu.handle_command(command);
+ if (detailUpdate.backRequested) {
+ state.settings.focusArea = SettingsFocusArea::categories;
+ return true;
+ }
+ if (!detailUpdate.activationRequested) {
+ return true;
+ }
+
+ handle_settings_detail_activation(state, detailUpdate, update);
+ return true;
+ }
+
+ /**
+ * @brief Activates a selected host from the hosts screen.
+ *
+ * @param state Client state containing the selected host.
+ * @param update Update structure that receives browse or pairing work.
+ */
+ void activate_selected_host(ClientState &state, AppUpdate *update) {
+ const HostRecord *host = selected_host(state);
+ if (host == nullptr || update == nullptr) {
+ return;
+ }
+
+ update->navigation.activatedItemId = "select-host";
+ if (host->pairingState == PairingState::paired) {
+ update->requests.appsBrowseRequested = true;
+ update->requests.appsBrowseShowHidden = false;
+ return;
+ }
+
+ request_host_pairing(state, *host, update);
+ }
+
+ /**
+ * @brief Handles activation of the hosts toolbar.
+ *
+ * @param state Client state containing the toolbar selection.
+ * @param update Update structure that receives side effects.
+ */
+ void activate_hosts_toolbar(ClientState &state, AppUpdate *update) {
+ if (update == nullptr) {
+ return;
+ }
+
+ const std::size_t toolbarIndex = state.hosts.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT;
+ if (toolbarIndex == 0U) {
+ set_screen(state, ScreenId::settings, settings_category_menu_id(SettingsCategory::logging));
+ update->navigation.screenChanged = true;
+ update->navigation.activatedItemId = "settings-button";
+ return;
+ }
+ if (toolbarIndex == 1U) {
+ open_modal(state, ModalId::support);
+ update->navigation.modalOpened = true;
+ update->navigation.activatedItemId = "support-button";
+ return;
+ }
+
+ enter_add_host_screen(state);
+ update->navigation.screenChanged = true;
+ update->navigation.activatedItemId = "add-host-button";
+ }
+
+ /**
+ * @brief Handles commands on the hosts screen.
+ *
+ * @param state Client state containing the hosts screen selection.
+ * @param command Command being processed.
+ * @param update Update structure that receives side effects.
+ * @return True when the hosts screen consumed the command.
+ */
+ bool handle_hosts_screen_command(ClientState &state, input::UiCommand command, AppUpdate *update) {
+ if (state.shell.activeScreen != ScreenId::hosts || update == nullptr) {
+ return false;
+ }
+
+ switch (command) {
+ case input::UiCommand::move_left:
+ if (state.hosts.focusArea == HostsFocusArea::toolbar) {
+ move_toolbar_selection(state, -1);
+ } else {
+ move_host_grid_selection(state, 0, -1);
+ }
+ return true;
+ case input::UiCommand::move_right:
+ if (state.hosts.focusArea == HostsFocusArea::toolbar) {
+ move_toolbar_selection(state, 1);
+ } else {
+ move_host_grid_selection(state, 0, 1);
+ }
+ return true;
+ case input::UiCommand::move_down:
+ if (state.hosts.focusArea == HostsFocusArea::toolbar) {
+ if (!state.hosts.items.empty()) {
+ state.hosts.focusArea = HostsFocusArea::grid;
+ }
+ } else {
+ move_host_grid_selection(state, 1, 0);
+ }
+ return true;
+ case input::UiCommand::move_up:
+ if (state.hosts.focusArea == HostsFocusArea::grid) {
+ move_host_grid_selection(state, -1, 0);
+ }
+ return true;
+ case input::UiCommand::open_context_menu:
+ if (state.hosts.focusArea == HostsFocusArea::grid && selected_host(state) != nullptr) {
+ open_modal(state, ModalId::host_actions);
+ update->navigation.modalOpened = true;
+ }
+ return true;
+ case input::UiCommand::activate:
+ case input::UiCommand::confirm:
+ if (state.hosts.focusArea == HostsFocusArea::toolbar) {
+ activate_hosts_toolbar(state, update);
+ return true;
+ }
+ activate_selected_host(state, update);
+ return true;
+ case input::UiCommand::back:
+ case input::UiCommand::delete_character:
+ case input::UiCommand::previous_page:
+ case input::UiCommand::next_page:
+ case input::UiCommand::fast_previous_page:
+ case input::UiCommand::fast_next_page:
+ case input::UiCommand::toggle_overlay:
+ case input::UiCommand::none:
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * @brief Handles commands on the apps screen.
+ *
+ * @param state Client state containing the apps selection.
+ * @param command Command being processed.
+ * @param update Update structure that receives side effects.
+ * @return True when the apps screen consumed the command.
+ */
+ bool handle_apps_screen_command(ClientState &state, input::UiCommand command, AppUpdate *update) {
+ if (state.shell.activeScreen != ScreenId::apps || update == nullptr) {
+ return false;
+ }
+
+ switch (command) {
+ case input::UiCommand::move_left:
+ move_app_grid_selection(state, 0, -1);
+ return true;
+ case input::UiCommand::move_right:
+ move_app_grid_selection(state, 0, 1);
+ return true;
+ case input::UiCommand::move_up:
+ move_app_grid_selection(state, -1, 0);
+ return true;
+ case input::UiCommand::move_down:
+ move_app_grid_selection(state, 1, 0);
+ return true;
+ case input::UiCommand::open_context_menu:
+ if (selected_app(state) != nullptr) {
+ open_modal(state, ModalId::app_actions);
+ update->navigation.modalOpened = true;
+ }
+ return true;
+ case input::UiCommand::activate:
+ case input::UiCommand::confirm:
+ if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) {
+ state.shell.statusMessage = "Launching " + appRecord->name + " is not implemented yet";
+ update->navigation.activatedItemId = "launch-app";
+ }
+ return true;
+ case input::UiCommand::back:
+ state.shell.statusMessage.clear();
+ set_screen(state, ScreenId::hosts);
+ update->navigation.screenChanged = true;
+ return true;
+ case input::UiCommand::delete_character:
+ case input::UiCommand::previous_page:
+ case input::UiCommand::next_page:
+ case input::UiCommand::fast_previous_page:
+ case input::UiCommand::fast_next_page:
+ case input::UiCommand::toggle_overlay:
+ case input::UiCommand::none:
+ return true;
+ }
+
+ return true;
+ }
+
+ /**
+ * @brief Applies an invalid add-host draft error to the UI state.
+ *
+ * @param state Client state containing the add-host draft.
+ * @param validationError Validation message to display.
+ */
+ void apply_add_host_validation_error(ClientState &state, std::string_view validationError) {
+ state.addHostDraft.validationMessage = validationError;
+ state.shell.statusMessage = validationError;
+ }
+
+ /**
+ * @brief Handles activation of add-host screen menu actions.
+ *
+ * @param state Client state containing the add-host draft.
+ * @param activatedItemId Activated menu item identifier.
+ * @param update Update structure that receives side effects.
+ */
+ void handle_add_host_menu_activation(ClientState &state, std::string_view activatedItemId, AppUpdate *update) {
+ if (update == nullptr) {
+ return;
+ }
+
+ if (activatedItemId == "edit-address") {
+ open_add_host_keypad(state, AddHostField::address);
+ return;
+ }
+ if (activatedItemId == "edit-port") {
+ open_add_host_keypad(state, AddHostField::port);
+ return;
+ }
+
+ std::string normalizedAddress;
+ uint16_t parsedPort = 0;
+ std::string validationError;
+ const bool draftIsValid = normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &validationError);
+ if (activatedItemId == "test-connection") {
+ if (!draftIsValid) {
+ apply_add_host_validation_error(state, validationError);
+ return;
+ }
+ state.addHostDraft.validationMessage.clear();
+ state.addHostDraft.connectionMessage = "Testing connection to " + normalizedAddress + (parsedPort == 0 ? std::string {} : ":" + std::to_string(parsedPort)) + "...";
+ state.shell.statusMessage = state.addHostDraft.connectionMessage;
+ update->requests.connectionTestRequested = true;
+ update->requests.connectionTestAddress = normalizedAddress;
+ update->requests.connectionTestPort = effective_host_port(parsedPort);
+ return;
+ }
+ if (activatedItemId == "save-host") {
+ if (!draftIsValid) {
+ apply_add_host_validation_error(state, validationError);
+ return;
+ }
+ if (find_host_by_endpoint(state.hosts.items, normalizedAddress, parsedPort) != nullptr) {
+ apply_add_host_validation_error(state, "That host is already saved");
+ return;
+ }
+
+ state.hosts.items.push_back(make_host_record(normalizedAddress, parsedPort));
+ state.hosts.selectedHostIndex = state.hosts.items.size() - 1U;
+ state.hosts.focusArea = HostsFocusArea::grid;
+ state.hosts.dirty = true;
+ update->persistence.hostsChanged = true;
+ state.addHostDraft.validationMessage.clear();
+ state.addHostDraft.connectionMessage.clear();
+ state.shell.statusMessage = "Saved host " + normalizedAddress;
+ set_screen(state, ScreenId::hosts);
+ update->navigation.screenChanged = true;
+ return;
+ }
+ if (activatedItemId == "start-pairing") {
+ if (!draftIsValid) {
+ apply_add_host_validation_error(state, validationError);
+ return;
+ }
+
+ const HostRecord *host = find_host_by_endpoint(state.hosts.items, normalizedAddress, parsedPort);
+ if (host == nullptr) {
+ state.hosts.items.push_back(make_host_record(normalizedAddress, parsedPort));
+ state.hosts.selectedHostIndex = state.hosts.items.size() - 1U;
+ state.hosts.dirty = true;
+ update->persistence.hostsChanged = true;
+ host = &state.hosts.items.back();
+ }
+
+ request_host_pairing(state, *host, update);
+ return;
+ }
+ if (activatedItemId == "cancel-add-host") {
+ state.addHostDraft.validationMessage.clear();
+ state.addHostDraft.connectionMessage.clear();
+ set_screen(state, ScreenId::hosts);
+ update->navigation.screenChanged = true;
+ }
+ }
+
+ AppUpdate handle_command(ClientState &state, input::UiCommand command) {
+ AppUpdate update {};
+
+ if (handle_overlay_command(state, command, &update)) {
+ return update;
+ }
+
+ if (handle_add_host_keypad_command(state, command)) {
+ return update;
+ }
+
+ if (handle_modal_command(state, command, &update)) {
+ return update;
+ }
+
+ if (command == input::UiCommand::delete_character && !state.shell.statusMessage.empty()) {
+ state.shell.statusMessage.clear();
+ return update;
+ }
+
+ if (handle_settings_screen_command(state, command, &update)) {
+ return update;
+ }
+
+ if (handle_hosts_screen_command(state, command, &update)) {
+ return update;
+ }
+
+ if (handle_apps_screen_command(state, command, &update)) {
+ return update;
+ }
+
+ const ui::MenuUpdate menuUpdate = state.menu.handle_command(command);
+ if (menuUpdate.overlayToggleRequested) {
+ state.shell.overlayVisible = !state.shell.overlayVisible;
+ update.navigation.overlayChanged = true;
+ update.navigation.overlayVisibilityChanged = true;
+ return update;
+ }
+
+ if (menuUpdate.backRequested) {
+ if (state.shell.activeScreen == ScreenId::settings || state.shell.activeScreen == ScreenId::add_host || state.shell.activeScreen == ScreenId::pair_host) {
+ if (state.shell.activeScreen == ScreenId::pair_host) {
+ update.requests.pairingCancelledRequested = true;
+ }
+ state.shell.statusMessage = state.shell.activeScreen == ScreenId::apps ? std::string {} : state.shell.statusMessage;
+ set_screen(state, ScreenId::hosts);
+ update.navigation.screenChanged = true;
+ }
+ return update;
+ }
+
+ if (!menuUpdate.activationRequested) {
+ return update;
+ }
+
+ update.navigation.activatedItemId = menuUpdate.activatedItemId;
+
+ if (state.shell.activeScreen == ScreenId::pair_host) {
+ if (menuUpdate.activatedItemId == "cancel-pairing") {
+ update.requests.pairingCancelledRequested = true;
+ set_screen(state, ScreenId::hosts);
+ update.navigation.screenChanged = true;
+ }
+ return update;
+ }
+
+ if (state.shell.activeScreen != ScreenId::add_host) {
+ return update;
+ }
+
+ handle_add_host_menu_activation(state, menuUpdate.activatedItemId, &update);
+ return update;
+ }
+
+} // namespace app
diff --git a/src/app/client_state.h b/src/app/client_state.h
new file mode 100644
index 0000000..532142a
--- /dev/null
+++ b/src/app/client_state.h
@@ -0,0 +1,572 @@
+/**
+ * @file src/app/client_state.h
+ * @brief Declares client state models and transitions.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+
+// standard includes
+#include "src/app/host_records.h"
+#include "src/app/pairing_flow.h"
+#include "src/input/navigation_input.h"
+#include "src/logging/logger.h"
+#include "src/startup/saved_files.h"
+#include "src/ui/menu_model.h"
+
+namespace app {
+
+ /**
+ * @brief Top-level screens used by the Moonlight client shell.
+ */
+ enum class ScreenId {
+ home, ///< Placeholder home screen identifier retained for shared shell logic.
+ hosts, ///< Saved-host browser and primary landing screen.
+ apps, ///< Per-host application library screen.
+ add_host, ///< Manual host entry workflow.
+ pair_host, ///< Pairing workflow for an unpaired host.
+ settings, ///< Shell settings screen.
+ };
+
+ /**
+ * @brief Focus areas on the hosts page.
+ */
+ enum class HostsFocusArea {
+ toolbar, ///< Focus is on the hosts toolbar buttons.
+ grid, ///< Focus is on the saved-host tile grid.
+ };
+
+ /**
+ * @brief Active modal surfaced on top of the current page.
+ */
+ enum class ModalId {
+ none, ///< No modal is currently visible.
+ support, ///< Support and help modal.
+ host_actions, ///< Host action menu for the selected host.
+ host_details, ///< Host detail sheet for the selected host.
+ app_actions, ///< App action menu for the selected app.
+ app_details, ///< App detail sheet for the selected app.
+ confirmation, ///< Destructive-action confirmation dialog.
+ log_viewer, ///< Dedicated log viewer modal.
+ };
+
+ /**
+ * @brief Layout options for the embedded log viewer.
+ */
+ enum class LogViewerPlacement {
+ full, ///< Use the full modal width for the log viewer.
+ left, ///< Dock the log viewer on the left side of the split layout.
+ right, ///< Dock the log viewer on the right side of the split layout.
+ };
+
+ /**
+ * @brief Focus areas used by the two-pane settings screen.
+ */
+ enum class SettingsFocusArea {
+ categories, ///< Focus is on the settings category list.
+ options, ///< Focus is on the options list for the selected category.
+ };
+
+ /**
+ * @brief Top-level categories shown on the left side of the settings screen.
+ */
+ enum class SettingsCategory {
+ logging, ///< Logging and diagnostics options.
+ display, ///< Display and video presentation options.
+ input, ///< Input and controller options.
+ reset, ///< Reset and cleanup actions.
+ };
+
+ /**
+ * @brief Destructive confirmation requests surfaced in a modal popup.
+ */
+ enum class ConfirmationAction {
+ none, ///< No destructive action is pending confirmation.
+ delete_saved_file, ///< Delete one saved file or directory.
+ factory_reset, ///< Remove all persisted Moonlight state.
+ };
+
+ /**
+ * @brief Active field for keypad-based host entry.
+ */
+ enum class AddHostField {
+ address, ///< The host IPv4 address field.
+ port, ///< The optional host port override field.
+ };
+
+ /**
+ * @brief Controller selection state for the add-host keypad modal.
+ */
+ struct AddHostKeypadState {
+ bool visible; ///< True when the keypad modal is currently shown.
+ std::size_t selectedButtonIndex; ///< Zero-based selection inside the keypad button grid.
+ std::string stagedInput; ///< Draft text currently assembled inside the keypad modal.
+ };
+
+ /**
+ * @brief Controller-friendly draft state for manual host entry.
+ */
+ struct AddHostDraft {
+ std::string addressInput; ///< Raw host address text entered by the user.
+ std::string portInput; ///< Raw port override text entered by the user.
+ AddHostField activeField; ///< Field currently targeted by directional input.
+ AddHostKeypadState keypad; ///< Nested keypad-modal selection state.
+ ScreenId returnScreen; ///< Screen to return to when add-host flow completes or cancels.
+ std::string validationMessage; ///< Validation feedback for the current address or port text.
+ std::string connectionMessage; ///< Result message from the latest host connection test.
+ bool lastConnectionSucceeded; ///< True when the latest connection test succeeded.
+ };
+
+ /**
+ * @brief Context modal state shared by the hosts and apps pages.
+ */
+ struct ModalState {
+ ModalId id = ModalId::none; ///< Currently active modal identifier.
+ std::size_t selectedActionIndex = 0; ///< Zero-based index of the highlighted modal action.
+
+ /**
+ * @brief Return whether a modal is currently active.
+ *
+ * @return true when the modal identifier is not ModalId::none.
+ */
+ bool active() const {
+ return id != ModalId::none;
+ }
+ };
+
+ /**
+ * @brief Content shown by the destructive-action confirmation dialog.
+ */
+ struct ConfirmationDialogState {
+ ConfirmationAction action = ConfirmationAction::none; ///< Requested confirmation action.
+ std::string targetPath; ///< File or directory path targeted by the action, when applicable.
+ std::string title; ///< Modal title presented to the user.
+ std::vector lines; ///< Body lines describing the consequence of the action.
+ };
+
+ /**
+ * @brief Shell-wide state that is not owned by a specific workflow screen.
+ */
+ struct ShellState {
+ ScreenId activeScreen = ScreenId::hosts; ///< Screen currently shown by the shell.
+ bool overlayVisible = false; ///< True when the diagnostics overlay is visible.
+ bool shouldExit = false; ///< True when the application should terminate.
+ std::size_t overlayScrollOffset = 0U; ///< Scroll offset used by long overlay content.
+ std::string statusMessage; ///< Primary user-visible status line.
+ };
+
+ /**
+ * @brief State owned by the saved-host browser and retained host snapshot.
+ */
+ struct HostsState {
+ bool dirty = false; ///< True when the host list changed and should be saved.
+ bool loaded = false; ///< True when the hosts page list is currently loaded in memory.
+ HostsFocusArea focusArea = HostsFocusArea::toolbar; ///< Focused region on the hosts page.
+ std::size_t selectedToolbarButtonIndex = 0U; ///< Zero-based selection inside the hosts toolbar.
+ std::size_t selectedHostIndex = 0U; ///< Zero-based selection inside the saved host list.
+ std::vector items; ///< Saved hosts currently tracked by the shell.
+ HostRecord active; ///< Host snapshot kept for host-specific non-host screens after unloading the hosts page.
+ bool activeLoaded = false; ///< True when active contains a valid host snapshot.
+ std::string selectedAddress; ///< Last selected host address used to restore hosts-page selection after reload.
+ uint16_t selectedPort = 0; ///< Last selected host port override used to restore hosts-page selection after reload.
+ std::vector pairingResetEndpoints; ///< Endpoints whose pairing material should be cleared during reset.
+
+ /**
+ * @brief Return whether the saved-host collection is empty.
+ *
+ * @return True when no saved hosts are currently loaded.
+ */
+ bool empty() const {
+ return items.empty();
+ }
+
+ /**
+ * @brief Return the number of saved hosts currently tracked by the shell.
+ *
+ * @return Number of host records stored in the collection.
+ */
+ std::size_t size() const {
+ return items.size();
+ }
+
+ /**
+ * @brief Return an iterator to the first saved host.
+ *
+ * @return Mutable iterator to the first element.
+ */
+ auto begin() {
+ return items.begin();
+ }
+
+ /**
+ * @brief Return an iterator one past the last saved host.
+ *
+ * @return Mutable iterator to the end of the collection.
+ */
+ auto end() {
+ return items.end();
+ }
+
+ /**
+ * @brief Return a const iterator to the first saved host.
+ *
+ * @return Const iterator to the first element.
+ */
+ auto begin() const {
+ return items.begin();
+ }
+
+ /**
+ * @brief Return a const iterator one past the last saved host.
+ *
+ * @return Const iterator to the end of the collection.
+ */
+ auto end() const {
+ return items.end();
+ }
+
+ /**
+ * @brief Remove every saved host from the collection.
+ */
+ void clear() {
+ items.clear();
+ }
+
+ /**
+ * @brief Return the first saved host.
+ *
+ * @return Reference to the first host record.
+ */
+ HostRecord &front() {
+ return items.front();
+ }
+
+ /**
+ * @brief Return the first saved host.
+ *
+ * @return Const reference to the first host record.
+ */
+ const HostRecord &front() const {
+ return items.front();
+ }
+
+ /**
+ * @brief Return the last saved host.
+ *
+ * @return Reference to the last host record.
+ */
+ HostRecord &back() {
+ return items.back();
+ }
+
+ /**
+ * @brief Return the last saved host.
+ *
+ * @return Const reference to the last host record.
+ */
+ const HostRecord &back() const {
+ return items.back();
+ }
+
+ /**
+ * @brief Return the saved host at the requested index.
+ *
+ * @param index Zero-based host index.
+ * @return Reference to the host record at @p index.
+ */
+ HostRecord &operator[](std::size_t index) {
+ return items[index];
+ }
+
+ /**
+ * @brief Return the saved host at the requested index.
+ *
+ * @param index Zero-based host index.
+ * @return Const reference to the host record at @p index.
+ */
+ const HostRecord &operator[](std::size_t index) const {
+ return items[index];
+ }
+ };
+
+ /**
+ * @brief State owned by the per-host apps browser.
+ */
+ struct AppsState {
+ std::size_t selectedAppIndex = 0U; ///< Zero-based selection inside the visible app list.
+ std::size_t scrollPage = 0U; ///< Horizontal page offset for paged app browsing.
+ bool showHiddenApps = false; ///< True when hidden apps should remain visible in the apps screen.
+ };
+
+ /**
+ * @brief State owned by the settings, log viewer, and saved-file workflows.
+ */
+ struct SettingsState {
+ SettingsFocusArea focusArea = SettingsFocusArea::categories; ///< Focused pane within the settings screen.
+ SettingsCategory selectedCategory = SettingsCategory::logging; ///< Settings category selected in the left pane.
+ std::string logFilePath; ///< Path currently loaded into the log viewer.
+ std::vector logViewerLines; ///< Loaded log file lines shown in the log viewer.
+ std::size_t logViewerScrollOffset = 0U; ///< Zero-based vertical scroll offset inside the log viewer.
+ LogViewerPlacement logViewerPlacement = LogViewerPlacement::full; ///< Log viewer pane placement relative to the shell.
+ logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the persisted log file.
+ logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level mirrored through DbgPrint() to xemu's serial console.
+ bool dirty = false; ///< True when persisted TOML-backed settings changed and should be saved.
+ std::vector savedFiles; ///< Saved-file catalog shown on the reset settings page.
+ bool savedFilesDirty = true; ///< True when the saved-file catalog should be refreshed.
+ };
+
+ /**
+ * @brief Serializable app state for the menu-driven client shell.
+ */
+ struct ClientState {
+ ShellState shell; ///< Shell-wide status and overlay state.
+ HostsState hosts; ///< Saved-host browsing state and retained host snapshot.
+ AppsState apps; ///< Apps-screen selection and paging state.
+ ui::MenuModel menu; ///< Primary vertical menu model for the active screen.
+ ui::MenuModel detailMenu; ///< Secondary detail or actions menu.
+ AddHostDraft addHostDraft; ///< Draft state for the add-host workflow.
+ PairingDraft pairingDraft; ///< Draft state for the active pairing workflow.
+ ModalState modal; ///< Context modal currently stacked over the shell.
+ SettingsState settings; ///< Settings, log viewer, and saved-file workflow state.
+ ConfirmationDialogState confirmation; ///< Confirmation dialog content for destructive actions.
+ };
+
+ /**
+ * @brief Navigation and modal effects emitted by one command update.
+ */
+ struct AppNavigationUpdate {
+ bool screenChanged = false; ///< True when the active screen changed.
+ bool overlayChanged = false; ///< True when overlay content changed.
+ bool overlayVisibilityChanged = false; ///< True when overlay visibility toggled.
+ bool exitRequested = false; ///< True when the shell requested application exit.
+ bool modalOpened = false; ///< True when a modal became active during the update.
+ bool modalClosed = false; ///< True when the active modal was dismissed during the update.
+ std::string activatedItemId; ///< Stable identifier for the activated menu item, when any.
+ };
+
+ /**
+ * @brief Network and browsing requests emitted by one command update.
+ */
+ struct AppRequestUpdate {
+ bool connectionTestRequested = false; ///< True when a manual host connection test should run.
+ std::string connectionTestAddress; ///< Host address that should be tested.
+ uint16_t connectionTestPort = 0; ///< Host port that should be tested.
+ bool pairingRequested = false; ///< True when manual pairing should begin.
+ bool pairingCancelledRequested = false; ///< True when an in-progress pairing request should be cancelled.
+ std::string pairingAddress; ///< Host address targeted by pairing.
+ uint16_t pairingPort = 0; ///< Host port targeted by pairing.
+ std::string pairingPin; ///< Generated client PIN that should be shown to the user.
+ bool appsBrowseRequested = false; ///< True when app browsing for the selected host should begin.
+ bool appsBrowseShowHidden = false; ///< Hidden-app visibility requested for the app browse action.
+ bool logViewRequested = false; ///< True when the log viewer should be refreshed from disk.
+ };
+
+ /**
+ * @brief Persistence and cleanup side effects emitted by one command update.
+ */
+ struct AppPersistenceUpdate {
+ bool hostsChanged = false; ///< True when the host list changed and should be persisted.
+ bool settingsChanged = false; ///< True when persisted TOML-backed settings changed.
+ bool savedFileDeleteRequested = false; ///< True when one managed file should be deleted.
+ std::string savedFileDeletePath; ///< Managed file path requested for deletion.
+ bool factoryResetRequested = false; ///< True when a full saved-data reset should run.
+ bool hostDeleteCleanupRequested = false; ///< True when host deletion follow-up cleanup should run.
+ bool deletedHostWasPaired = false; ///< True when the deleted host previously had pairing credentials.
+ std::string deletedHostAddress; ///< Address of the host removed from storage.
+ uint16_t deletedHostPort = 0; ///< Port of the host removed from storage.
+ std::vector deletedHostCoverArtCacheKeys; ///< Cover-art cache keys to remove for the deleted host.
+ };
+
+ /**
+ * @brief Result of updating the client shell with a UI command.
+ */
+ struct AppUpdate {
+ AppNavigationUpdate navigation; ///< Navigation and modal changes emitted by the command.
+ AppRequestUpdate requests; ///< Network and browsing requests emitted by the command.
+ AppPersistenceUpdate persistence; ///< Persistence and cleanup work emitted by the command.
+ };
+
+ /**
+ * @brief Create the initial app state shown after startup.
+ *
+ * @return The initial client state.
+ */
+ ClientState create_initial_state();
+
+ /**
+ * @brief Return a display label for a screen identifier.
+ *
+ * @param screen Screen identifier to stringify.
+ * @return Stable lowercase screen name.
+ */
+ const char *to_string(ScreenId screen);
+
+ /**
+ * @brief Replace the in-memory host list from a persisted snapshot.
+ *
+ * @param state Mutable app state.
+ * @param hosts Loaded host records.
+ * @param statusMessage Optional status line shown in the shell.
+ */
+ void replace_hosts(ClientState &state, std::vector hosts, std::string statusMessage = {});
+
+ /**
+ * @brief Replace the in-memory saved-file inventory shown on the settings page.
+ *
+ * @param state Mutable app state.
+ * @param savedFiles Saved files currently found on disk.
+ */
+ void replace_saved_files(ClientState &state, std::vector savedFiles);
+
+ /**
+ * @brief Return the current host address shown in the add-host flow.
+ *
+ * @param state App state containing the add-host draft.
+ * @return Current draft IPv4 address text.
+ */
+ std::string current_add_host_address(const ClientState &state);
+
+ /**
+ * @brief Return the effective TCP port for the current add-host draft.
+ *
+ * @param state App state containing the add-host draft.
+ * @return Effective host port using the default when the field is empty.
+ */
+ uint16_t current_add_host_port(const ClientState &state);
+
+ /**
+ * @brief Return the current pairing PIN shown in the pairing flow.
+ *
+ * @param state App state containing the pairing draft.
+ * @return Four-digit PIN string.
+ */
+ std::string current_pairing_pin(const ClientState &state);
+
+ /**
+ * @brief Apply the result of a host connection test to the current shell state.
+ *
+ * @param state Mutable app state.
+ * @param success Whether the test succeeded.
+ * @param message User-visible status message.
+ */
+ void apply_connection_test_result(ClientState &state, bool success, std::string message);
+
+ /**
+ * @brief Apply the result of a pairing attempt to the current shell state.
+ *
+ * @param state Mutable app state.
+ * @param address Host address used for pairing.
+ * @param port Host port used for pairing.
+ * @param success Whether the pairing attempt succeeded.
+ * @param message User-visible status message.
+ * @return True when the host list changed and should be persisted.
+ */
+ bool apply_pairing_result(ClientState &state, const std::string &address, uint16_t port, bool success, std::string message);
+
+ /**
+ * @brief Apply a fetched app list to a saved host.
+ *
+ * @param state Mutable app state.
+ * @param address Host address used for the fetch.
+ * @param port Host port used for the fetch.
+ * @param apps Fresh app records returned by the host.
+ * @param appListContentHash Stable content hash for the returned app list.
+ * @param success Whether the fetch succeeded.
+ * @param message User-visible status message.
+ */
+ void apply_app_list_result(
+ ClientState &state,
+ const std::string &address,
+ uint16_t port,
+ std::vector apps,
+ uint64_t appListContentHash,
+ bool success,
+ std::string message
+ );
+
+ /**
+ * @brief Mark one cached cover-art entry as available for a host app.
+ *
+ * @param state Mutable app state.
+ * @param address Host address owning the app.
+ * @param port Host port owning the app.
+ * @param appId App identifier whose cached art is now available.
+ */
+ void mark_cover_art_cached(ClientState &state, const std::string &address, uint16_t port, int appId);
+
+ /**
+ * @brief Update the log file path tracked by the shell.
+ *
+ * @param state Mutable app state.
+ * @param logFilePath Path to the log file that should be shown in the viewer.
+ */
+ void set_log_file_path(ClientState &state, std::string logFilePath);
+
+ /**
+ * @brief Replace the loaded log viewer contents.
+ *
+ * @param state Mutable app state.
+ * @param lines Log file lines ready for display.
+ * @param statusMessage User-visible status line for the log viewer state.
+ */
+ void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage);
+
+ /**
+ * @brief Return whether a saved host still requires a manual pairing flow.
+ *
+ * @param state App state containing the saved host list.
+ * @param address Host address to inspect.
+ * @param port Host port to inspect.
+ * @return true when the matching host exists and is not paired.
+ */
+ bool host_requires_manual_pairing(const ClientState &state, const std::string &address, uint16_t port);
+
+ /**
+ * @brief Enter the apps screen for the currently selected host after authorization has been refreshed.
+ *
+ * @param state Mutable app state.
+ * @param showHiddenApps Whether hidden apps should still be shown.
+ * @return true when the apps page was entered.
+ */
+ bool begin_selected_host_app_browse(ClientState &state, bool showHiddenApps);
+
+ /**
+ * @brief Return the currently selected loaded host for the active screen.
+ *
+ * On the hosts page this returns the selected saved host tile. On host-specific
+ * pages such as pairing it may return the lightweight active host snapshot.
+ *
+ * @param state App state containing the loaded host selection.
+ * @return Selected host record, or nullptr when no saved host is selected.
+ */
+ const HostRecord *selected_host(const ClientState &state);
+
+ /**
+ * @brief Return the currently selected app on the Apps screen.
+ *
+ * @param state App state containing the selected host and apps list.
+ * @return Selected app record, or nullptr when no visible app is selected.
+ */
+ const HostAppRecord *selected_app(const ClientState &state);
+
+ /**
+ * @brief Return the host currently shown by the Apps screen.
+ *
+ * @param state App state containing the selected host.
+ * @return Host record backing the apps page, or nullptr when unavailable.
+ */
+ const HostRecord *apps_host(const ClientState &state);
+
+ /**
+ * @brief Apply a UI command to the client shell.
+ *
+ * @param state Mutable app state.
+ * @param command UI command from controller or keyboard input.
+ * @return Summary of the resulting state transition.
+ */
+ AppUpdate handle_command(ClientState &state, input::UiCommand command);
+
+} // namespace app
diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp
new file mode 100644
index 0000000..df8b402
--- /dev/null
+++ b/src/app/host_records.cpp
@@ -0,0 +1,549 @@
+/**
+ * @file src/app/host_records.cpp
+ * @brief Implements host record models and utilities.
+ */
+// class header include
+#include "src/app/host_records.h"
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+
+// local includes
+#include "src/platform/error_utils.h"
+
+namespace {
+
+ using platform::append_error;
+
+ bool parse_ipv4_octet(std::string_view segment, int *value) {
+ if (segment.empty()) {
+ return false;
+ }
+
+ int parsedValue = 0;
+ for (char character : segment) {
+ if (character < '0' || character > '9') {
+ return false;
+ }
+
+ parsedValue = (parsedValue * 10) + (character - '0');
+ if (parsedValue > 255) {
+ return false;
+ }
+ }
+
+ if (value != nullptr) {
+ *value = parsedValue;
+ }
+
+ return true;
+ }
+
+ std::vector split_string_view(std::string_view text, char delimiter) {
+ std::vector segments;
+ std::size_t startIndex = 0;
+
+ while (startIndex <= text.size()) {
+ const std::size_t delimiterIndex = text.find(delimiter, startIndex);
+ if (delimiterIndex == std::string_view::npos) {
+ segments.push_back(text.substr(startIndex));
+ break;
+ }
+
+ segments.push_back(text.substr(startIndex, delimiterIndex - startIndex));
+ startIndex = delimiterIndex + 1;
+ }
+
+ return segments;
+ }
+
+ char hex_digit(std::byte value) {
+ const unsigned int digit = std::to_integer(value);
+ return static_cast(digit < 10U ? ('0' + digit) : ('A' + (digit - 10U)));
+ }
+
+ void append_percent_encoded_byte(std::string *encoded, unsigned char character) {
+ if (encoded == nullptr) {
+ return;
+ }
+
+ const auto byteValue = static_cast(character);
+ encoded->push_back('%');
+ encoded->push_back(hex_digit((byteValue >> 4U) & std::byte {0x0F}));
+ encoded->push_back(hex_digit(byteValue & std::byte {0x0F}));
+ }
+
+ bool is_unreserved_serialized_character(unsigned char character) {
+ return std::isalnum(character) != 0 || character == '-' || character == '_' || character == '.';
+ }
+
+ std::string percent_encode(std::string_view text) {
+ std::string encoded;
+ encoded.reserve(text.size());
+
+ for (const unsigned char character : text) {
+ if (is_unreserved_serialized_character(character)) {
+ encoded.push_back(static_cast(character));
+ continue;
+ }
+
+ append_percent_encoded_byte(&encoded, character);
+ }
+
+ return encoded;
+ }
+
+ int hex_value(char character) {
+ if (character >= '0' && character <= '9') {
+ return character - '0';
+ }
+ if (character >= 'A' && character <= 'F') {
+ return 10 + (character - 'A');
+ }
+ if (character >= 'a' && character <= 'f') {
+ return 10 + (character - 'a');
+ }
+ return -1;
+ }
+
+ bool percent_decode(std::string_view text, std::string *decoded) {
+ if (decoded == nullptr) {
+ return false;
+ }
+
+ std::string result;
+ result.reserve(text.size());
+ std::size_t index = 0;
+ while (index < text.size()) {
+ if (text[index] != '%') {
+ result.push_back(text[index]);
+ ++index;
+ continue;
+ }
+
+ if (index + 2U >= text.size()) {
+ return false;
+ }
+
+ const int highNibble = hex_value(text[index + 1U]);
+ const int lowNibble = hex_value(text[index + 2U]);
+ if (highNibble < 0 || lowNibble < 0) {
+ return false;
+ }
+
+ result.push_back(static_cast((highNibble << 4U) | lowNibble));
+ index += 3U;
+ }
+
+ *decoded = std::move(result);
+ return true;
+ }
+
+ bool try_parse_unsigned_integer(std::string_view text, uint64_t maxValue, uint64_t *value) {
+ if (text.empty()) {
+ return false;
+ }
+
+ uint64_t parsedValue = 0;
+ for (char character : text) {
+ if (character < '0' || character > '9') {
+ return false;
+ }
+
+ const auto digit = static_cast(character - '0');
+ if (parsedValue > ((maxValue - digit) / 10U)) {
+ return false;
+ }
+ parsedValue = (parsedValue * 10U) + digit;
+ }
+
+ if (value != nullptr) {
+ *value = parsedValue;
+ }
+ return true;
+ }
+
+ bool try_parse_serialized_boolean(std::string_view text, bool *value) {
+ if (text == "0") {
+ if (value != nullptr) {
+ *value = false;
+ }
+ return true;
+ }
+ if (text == "1") {
+ if (value != nullptr) {
+ *value = true;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ std::string serialize_cached_host_metadata(const app::HostRecord &record) {
+ return std::to_string(record.runningGameId) + ',' + std::to_string(record.resolvedHttpPort) + ',' + std::to_string(record.httpsPort) + ',' + std::to_string(record.appListContentHash);
+ }
+
+ bool parse_cached_host_metadata(std::string_view serializedMetadata, app::HostRecord *record) {
+ if (record == nullptr || serializedMetadata.empty()) {
+ return record != nullptr;
+ }
+
+ const std::vector fields = split_string_view(serializedMetadata, ',');
+ if (fields.size() != 4U) {
+ return false;
+ }
+
+ uint64_t runningGameId = 0;
+ uint64_t resolvedHttpPort = 0;
+ uint64_t httpsPort = 0;
+ uint64_t appListContentHash = 0;
+ if (!try_parse_unsigned_integer(fields[0], std::numeric_limits::max(), &runningGameId) ||
+ !try_parse_unsigned_integer(fields[1], std::numeric_limits::max(), &resolvedHttpPort) ||
+ !try_parse_unsigned_integer(fields[2], std::numeric_limits::max(), &httpsPort) ||
+ !try_parse_unsigned_integer(fields[3], std::numeric_limits::max(), &appListContentHash)) {
+ return false;
+ }
+
+ record->runningGameId = static_cast(runningGameId);
+ record->resolvedHttpPort = static_cast(resolvedHttpPort);
+ record->httpsPort = static_cast(httpsPort);
+ record->appListContentHash = appListContentHash;
+ return true;
+ }
+
+ std::string serialize_cached_app_record(const app::HostAppRecord &record) {
+ return percent_encode(record.name) + ',' + std::to_string(record.id) + ',' + (record.hdrSupported ? "1" : "0") + ',' + (record.hidden ? "1" : "0") + ',' +
+ (record.favorite ? "1" : "0") + ',' + percent_encode(record.boxArtCacheKey) + ',' + (record.boxArtCached ? "1" : "0");
+ }
+
+ bool parse_cached_app_record(std::string_view serializedApp, app::HostAppRecord *record) {
+ if (record == nullptr || serializedApp.empty()) {
+ return false;
+ }
+
+ const std::vector fields = split_string_view(serializedApp, ',');
+ if (fields.size() != 7U) {
+ return false;
+ }
+
+ std::string name;
+ std::string boxArtCacheKey;
+ uint64_t id = 0;
+ bool hdrSupported = false;
+ bool hidden = false;
+ bool favorite = false;
+ bool boxArtCached = false;
+ if (!percent_decode(fields[0], &name) || !try_parse_unsigned_integer(fields[1], static_cast(std::numeric_limits::max()), &id) || !try_parse_serialized_boolean(fields[2], &hdrSupported) ||
+ !try_parse_serialized_boolean(fields[3], &hidden) || !try_parse_serialized_boolean(fields[4], &favorite) || !percent_decode(fields[5], &boxArtCacheKey) || !try_parse_serialized_boolean(fields[6], &boxArtCached)) {
+ return false;
+ }
+
+ *record = {
+ std::move(name),
+ static_cast(id),
+ hdrSupported,
+ hidden,
+ favorite,
+ std::move(boxArtCacheKey),
+ boxArtCached,
+ false,
+ };
+ return true;
+ }
+
+ std::string serialize_cached_app_list(const std::vector &apps) {
+ std::string serializedApps;
+ for (std::size_t index = 0; index < apps.size(); ++index) {
+ if (index > 0U) {
+ serializedApps += '|';
+ }
+ serializedApps += serialize_cached_app_record(apps[index]);
+ }
+ return serializedApps;
+ }
+
+ bool parse_cached_app_list(std::string_view serializedApps, std::vector *apps) {
+ if (apps == nullptr) {
+ return false;
+ }
+ if (serializedApps.empty()) {
+ apps->clear();
+ return true;
+ }
+
+ std::vector parsedApps;
+ for (const std::string_view serializedApp : split_string_view(serializedApps, '|')) {
+ app::HostAppRecord parsedApp {};
+ if (!parse_cached_app_record(serializedApp, &parsedApp)) {
+ return false;
+ }
+ parsedApps.push_back(std::move(parsedApp));
+ }
+
+ *apps = std::move(parsedApps);
+ return true;
+ }
+
+} // namespace
+
+namespace app {
+
+ const char *to_string(PairingState pairingState) {
+ switch (pairingState) {
+ case PairingState::not_paired:
+ return "not_paired";
+ case PairingState::paired:
+ return "paired";
+ }
+
+ return "unknown";
+ }
+
+ const char *to_string(HostReachability reachability) {
+ switch (reachability) {
+ case HostReachability::unknown:
+ return "unknown";
+ case HostReachability::online:
+ return "online";
+ case HostReachability::offline:
+ return "offline";
+ }
+
+ return "unknown";
+ }
+
+ std::string normalize_ipv4_address(std::string_view address) {
+ const std::vector segments = split_string_view(address, '.');
+ if (segments.size() != 4) {
+ return {};
+ }
+
+ std::string normalizedAddress;
+ for (std::string_view segment : segments) {
+ int octetValue = 0;
+ if (!parse_ipv4_octet(segment, &octetValue)) {
+ return {};
+ }
+
+ if (!normalizedAddress.empty()) {
+ normalizedAddress += '.';
+ }
+ normalizedAddress += std::to_string(octetValue);
+ }
+
+ return normalizedAddress;
+ }
+
+ bool is_valid_ipv4_address(std::string_view address) {
+ return !normalize_ipv4_address(address).empty();
+ }
+
+ std::string build_default_host_display_name(std::string_view normalizedAddress) {
+ return std::string("Host ") + std::string(normalizedAddress);
+ }
+
+ uint16_t effective_host_port(uint16_t port) {
+ return port == 0 ? DEFAULT_HOST_PORT : port;
+ }
+
+ bool try_parse_host_port(std::string_view portText, uint16_t *parsedPort) {
+ if (portText.empty()) {
+ if (parsedPort != nullptr) {
+ *parsedPort = 0;
+ }
+ return true;
+ }
+
+ unsigned long parsedValue = 0;
+ for (char character : portText) {
+ if (character < '0' || character > '9') {
+ return false;
+ }
+
+ parsedValue = (parsedValue * 10) + static_cast(character - '0');
+ if (parsedValue > 65535UL) {
+ return false;
+ }
+ }
+
+ if (parsedValue == 0) {
+ return false;
+ }
+
+ if (parsedPort != nullptr) {
+ *parsedPort = static_cast(parsedValue);
+ }
+
+ return true;
+ }
+
+ bool contains_host_address(const std::vector &records, std::string_view normalizedAddress, uint16_t port) {
+ const uint16_t effectivePort = effective_host_port(port);
+ return std::any_of(records.begin(), records.end(), [normalizedAddress, effectivePort](const HostRecord &record) {
+ return record.address == normalizedAddress && effective_host_port(record.port) == effectivePort;
+ });
+ }
+
+ bool host_matches_endpoint(const HostRecord &host, std::string_view normalizedAddress, uint16_t port) {
+ if (host.address != normalizedAddress) {
+ return false;
+ }
+
+ const uint16_t effectivePort = effective_host_port(port);
+ if (effective_host_port(host.port) == effectivePort) {
+ return true;
+ }
+ if (host.resolvedHttpPort != 0 && host.resolvedHttpPort == effectivePort) {
+ return true;
+ }
+ if (host.httpsPort != 0 && host.httpsPort == effectivePort) {
+ return true;
+ }
+ return false;
+ }
+
+ bool validate_host_record(const HostRecord &record, std::string *errorMessage) {
+ if (record.displayName.empty()) {
+ return append_error(errorMessage, "Host display name cannot be empty");
+ }
+
+ if (record.displayName.find('\t') != std::string::npos || record.displayName.find('\n') != std::string::npos || record.displayName.find('\r') != std::string::npos) {
+ return append_error(errorMessage, "Host display name cannot contain tabs or new lines");
+ }
+
+ if (const std::string normalizedAddress = normalize_ipv4_address(record.address); normalizedAddress.empty()) {
+ return append_error(errorMessage, "Host address must be a valid IPv4 address");
+ } else if (normalizedAddress != record.address) {
+ return append_error(errorMessage, "Host address must already be normalized before saving");
+ }
+
+ if (record.port != 0 && effective_host_port(record.port) != record.port) {
+ return append_error(errorMessage, "Host port override must be a valid non-zero TCP port");
+ }
+
+ return true;
+ }
+
+ /**
+ * @brief Parse one serialized host-record line and append it to the accumulated result.
+ *
+ * @param line Raw tab-separated line to parse.
+ * @param lineNumber One-based line number used in parse errors.
+ * @param result Aggregate parse result to update.
+ */
+ void append_parsed_host_record(std::string_view line, std::size_t lineNumber, ParseHostRecordsResult *result) {
+ if (result == nullptr) {
+ return;
+ }
+
+ const std::vector fields = split_string_view(line, '\t');
+ if (fields.size() != 6U) {
+ result->errors.push_back("Line " + std::to_string(lineNumber) + " must contain six tab-separated fields");
+ return;
+ }
+
+ uint16_t port = 0;
+ const std::string_view pairingField = fields[3];
+ if (!try_parse_host_port(fields[2], &port)) {
+ result->errors.push_back("Line " + std::to_string(lineNumber) + " uses an invalid TCP port");
+ return;
+ }
+
+ PairingState pairingState = PairingState::not_paired;
+ if (pairingField == "paired") {
+ pairingState = PairingState::paired;
+ } else if (pairingField != "not_paired") {
+ result->errors.push_back("Line " + std::to_string(lineNumber) + " uses an unknown pairing state");
+ return;
+ }
+
+ HostRecord record {
+ std::string(fields[0]),
+ std::string(fields[1]),
+ port,
+ pairingState,
+ };
+
+ if (std::string errorMessage; !validate_host_record(record, &errorMessage)) {
+ result->errors.push_back("Line " + std::to_string(lineNumber) + ": " + errorMessage);
+ return;
+ }
+
+ HostRecord cachedRecord = record;
+ if (!parse_cached_host_metadata(fields[4], &cachedRecord) || !parse_cached_app_list(fields[5], &cachedRecord.apps)) {
+ result->errors.push_back("Line " + std::to_string(lineNumber) + " contains malformed cached app data");
+ return;
+ }
+
+ cachedRecord.appListState = cachedRecord.apps.empty() ? HostAppListState::idle : HostAppListState::ready;
+ cachedRecord.appListStatusMessage.clear();
+ cachedRecord.lastAppListRefreshTick = 0U;
+ for (HostAppRecord &appRecord : cachedRecord.apps) {
+ appRecord.running = static_cast(appRecord.id) == cachedRecord.runningGameId;
+ }
+ record = std::move(cachedRecord);
+
+ result->records.push_back(std::move(record));
+ }
+
+ std::string serialize_host_records(const std::vector &records) {
+ std::string serializedRecords;
+
+ for (const HostRecord &record : records) {
+ if (std::string errorMessage; !validate_host_record(record, &errorMessage)) {
+ continue;
+ }
+
+ serializedRecords += record.displayName;
+ serializedRecords += '\t';
+ serializedRecords += record.address;
+ serializedRecords += '\t';
+ if (record.port != 0) {
+ serializedRecords += std::to_string(record.port);
+ }
+ serializedRecords += '\t';
+ serializedRecords += to_string(record.pairingState);
+ serializedRecords += '\t';
+ serializedRecords += serialize_cached_host_metadata(record);
+ serializedRecords += '\t';
+ serializedRecords += serialize_cached_app_list(record.apps);
+ serializedRecords += '\n';
+ }
+
+ return serializedRecords;
+ }
+
+ ParseHostRecordsResult parse_host_records(std::string_view serializedRecords) {
+ ParseHostRecordsResult result {};
+
+ std::size_t lineStart = 0;
+ std::size_t lineNumber = 1;
+ while (lineStart <= serializedRecords.size()) {
+ const std::size_t lineEnd = serializedRecords.find('\n', lineStart);
+ std::string_view line = lineEnd == std::string_view::npos ? serializedRecords.substr(lineStart) : serializedRecords.substr(lineStart, lineEnd - lineStart);
+
+ if (!line.empty() && line.back() == '\r') {
+ line.remove_suffix(1);
+ }
+
+ if (!line.empty()) {
+ append_parsed_host_record(line, lineNumber, &result);
+ }
+
+ if (lineEnd == std::string_view::npos) {
+ break;
+ }
+
+ lineStart = lineEnd + 1;
+ ++lineNumber;
+ }
+
+ return result;
+ }
+
+} // namespace app
diff --git a/src/app/host_records.h b/src/app/host_records.h
new file mode 100644
index 0000000..ff1af02
--- /dev/null
+++ b/src/app/host_records.h
@@ -0,0 +1,201 @@
+/**
+ * @file src/app/host_records.h
+ * @brief Declares host record models and utilities.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+#include
+
+namespace app {
+
+ inline constexpr uint16_t DEFAULT_HOST_PORT = 47989; ///< Default HTTP port used by Moonlight-compatible hosts.
+
+ /**
+ * @brief Pairing state tracked for a saved host record.
+ */
+ enum class PairingState {
+ not_paired, ///< The client has not completed pairing with the host.
+ paired, ///< The client is paired and can issue authenticated requests.
+ };
+
+ /**
+ * @brief Reachability state tracked for a discovered or saved host.
+ */
+ enum class HostReachability {
+ unknown, ///< Reachability has not been probed yet.
+ online, ///< The host responded to the latest reachability check.
+ offline, ///< The host did not respond to the latest reachability check.
+ };
+
+ /**
+ * @brief Fetch state for the per-host app library.
+ */
+ enum class HostAppListState {
+ idle, ///< No app-list request is active and no fresh result is pending.
+ loading, ///< An app-list request is currently in progress.
+ ready, ///< A recent app list is available for display.
+ failed, ///< The latest app-list request failed.
+ };
+
+ /**
+ * @brief App metadata shown on the per-host apps page.
+ */
+ struct HostAppRecord {
+ std::string name; ///< Display name reported by the host.
+ int id = 0; ///< Stable host-defined application identifier.
+ bool hdrSupported = false; ///< True when the app advertises HDR streaming support.
+ bool hidden = false; ///< True when the app should be hidden from the default browse view.
+ bool favorite = false; ///< True when the app is pinned as a favorite locally.
+ std::string boxArtCacheKey; ///< Cache key used to load box art from local storage.
+ bool boxArtCached = false; ///< True when the referenced box art is already cached on disk.
+ bool running = false; ///< True when the app is currently running on the host.
+ };
+
+ /**
+ * @brief Manual host record shown in the shell.
+ */
+ struct HostRecord {
+ std::string displayName; ///< User-facing host label shown in the shell.
+ std::string address; ///< Stored primary IPv4 address for the host.
+ uint16_t port = 0; ///< Stored HTTP port override where zero means DEFAULT_HOST_PORT.
+ PairingState pairingState = PairingState::not_paired; ///< Current pairing state for this client.
+ HostReachability reachability = HostReachability::unknown; ///< Most recent reachability probe result.
+ std::string activeAddress; ///< Best currently reachable address for live operations.
+ std::string uuid; ///< Host UUID reported by Sunshine or GeForce Experience.
+ std::string localAddress; ///< Reported LAN address from the host status response.
+ std::string remoteAddress; ///< Reported WAN address from the host status response.
+ std::string ipv6Address; ///< Reported IPv6 address from the host status response.
+ std::string manualAddress; ///< Original manually entered address, when different from address.
+ std::string macAddress; ///< Reported MAC address for the host.
+ uint16_t httpsPort = 0; ///< HTTPS port reported by the host for asset requests.
+ uint32_t runningGameId = 0; ///< Currently running app identifier, or zero when idle.
+ std::vector apps; ///< Latest fetched app list for the host.
+ HostAppListState appListState = HostAppListState::idle; ///< Fetch state for the cached app list.
+ std::string appListStatusMessage; ///< User-visible status for the most recent app list operation.
+ uint16_t resolvedHttpPort = 0; ///< Effective HTTP port confirmed by the latest status query.
+ uint64_t appListContentHash = 0; ///< Stable hash of the last fetched app list contents.
+ uint32_t lastAppListRefreshTick = 0; ///< Tick count for the most recent app list refresh.
+ };
+
+ /**
+ * @brief Result of parsing a serialized host record list.
+ */
+ struct ParseHostRecordsResult {
+ std::vector records; ///< Parsed host records accepted from the serialized input.
+ std::vector errors; ///< Non-fatal line-level parse or validation errors.
+ };
+
+ /**
+ * @brief Return a stable lowercase label for a pairing state.
+ *
+ * @param pairingState Pairing state to stringify.
+ * @return Stable lowercase label.
+ */
+ const char *to_string(PairingState pairingState);
+
+ /**
+ * @brief Return a stable lowercase label for a host reachability state.
+ *
+ * @param reachability Reachability state to stringify.
+ * @return Stable lowercase label.
+ */
+ const char *to_string(HostReachability reachability);
+
+ /**
+ * @brief Normalize a user-provided IPv4 address.
+ *
+ * @param address Candidate IPv4 address.
+ * @return Canonical dotted-quad form, or an empty string when invalid.
+ */
+ std::string normalize_ipv4_address(std::string_view address);
+
+ /**
+ * @brief Return whether a string is a valid IPv4 address.
+ *
+ * @param address Candidate IPv4 address.
+ * @return true when the address can be normalized.
+ */
+ bool is_valid_ipv4_address(std::string_view address);
+
+ /**
+ * @brief Build a controller-friendly default display name for a host.
+ *
+ * @param normalizedAddress Canonical IPv4 address.
+ * @return Generated display label.
+ */
+ std::string build_default_host_display_name(std::string_view normalizedAddress);
+
+ /**
+ * @brief Return the effective TCP port for a host record.
+ *
+ * @param port Stored port override where zero means default.
+ * @return Effective host port.
+ */
+ uint16_t effective_host_port(uint16_t port);
+
+ /**
+ * @brief Parse a user-supplied TCP port string.
+ *
+ * @param portText Text entered by the user.
+ * @param parsedPort Output port override where zero means default.
+ * @return true when the port string is valid.
+ */
+ bool try_parse_host_port(std::string_view portText, uint16_t *parsedPort);
+
+ /**
+ * @brief Return whether a host list already contains an endpoint.
+ *
+ * @param records Saved hosts to search.
+ * @param normalizedAddress Canonical IPv4 address to match.
+ * @param port Stored port override to match.
+ * @return true when a saved host uses the same address and effective port.
+ */
+ bool contains_host_address(const std::vector &records, std::string_view normalizedAddress, uint16_t port = 0);
+
+ /**
+ * @brief Return whether a host record matches a specific endpoint.
+ *
+ * A host matches when the canonical host address equals @p normalizedAddress and
+ * @p port matches any known effective host endpoint (stored HTTP port, resolved
+ * HTTP port, or HTTPS port).
+ *
+ * @param host Host record to test.
+ * @param normalizedAddress Canonical IPv4 address to compare.
+ * @param port Endpoint port where zero means DEFAULT_HOST_PORT.
+ * @return true when the host record can be reached by the given endpoint.
+ */
+ bool host_matches_endpoint(const HostRecord &host, std::string_view normalizedAddress, uint16_t port);
+
+ /**
+ * @brief Validate a host record before saving or serializing it.
+ *
+ * @param record Host record to validate.
+ * @param errorMessage Optional output for a validation error.
+ * @return true when the record is valid.
+ */
+ bool validate_host_record(const HostRecord &record, std::string *errorMessage = nullptr);
+
+ /**
+ * @brief Serialize host records into a stable tab-separated text format.
+ *
+ * The serialized form preserves the saved host identity plus any cached app-list
+ * entries and their local visibility or artwork metadata.
+ *
+ * @param records Host records to serialize.
+ * @return Serialized text suitable for disk persistence.
+ */
+ std::string serialize_host_records(const std::vector &records);
+
+ /**
+ * @brief Parse host records from the stable serialized text format.
+ *
+ * @param serializedRecords Serialized host record text.
+ * @return Parsed records plus any non-fatal line errors.
+ */
+ ParseHostRecordsResult parse_host_records(std::string_view serializedRecords);
+
+} // namespace app
diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp
new file mode 100644
index 0000000..a4cf548
--- /dev/null
+++ b/src/app/pairing_flow.cpp
@@ -0,0 +1,34 @@
+/**
+ * @file src/app/pairing_flow.cpp
+ * @brief Implements the host pairing flow.
+ */
+// class header include
+#include "src/app/pairing_flow.h"
+
+// standard includes
+#include
+
+namespace app {
+
+ PairingDraft create_pairing_draft(std::string_view targetAddress, uint16_t targetPort, std::string generatedPin) {
+ PairingDraft draft {
+ std::string(targetAddress),
+ targetPort,
+ std::move(generatedPin),
+ PairingStage::pin_ready,
+ "Enter the PIN on the host. Pairing will continue automatically.",
+ };
+ return draft;
+ }
+
+ bool is_valid_pairing_pin(std::string_view pin) {
+ if (pin.size() != 4) {
+ return false;
+ }
+
+ return std::all_of(pin.begin(), pin.end(), [](char digit) {
+ return digit >= '0' && digit <= '9';
+ });
+ }
+
+} // namespace app
diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h
new file mode 100644
index 0000000..f462789
--- /dev/null
+++ b/src/app/pairing_flow.h
@@ -0,0 +1,54 @@
+/**
+ * @file src/app/pairing_flow.h
+ * @brief Declares the host pairing flow.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+
+namespace app {
+
+ /**
+ * @brief Reducer-driven stages for the manual pairing shell flow.
+ */
+ enum class PairingStage {
+ idle, ///< Pairing has not started yet.
+ pin_ready, ///< A PIN is available and ready to display to the user.
+ in_progress, ///< A pairing request is currently running.
+ paired, ///< Pairing completed successfully.
+ failed, ///< Pairing failed and an error message is available.
+ };
+
+ /**
+ * @brief Controller-friendly state for a client-generated pairing PIN.
+ */
+ struct PairingDraft {
+ std::string targetAddress; ///< Host address currently targeted by pairing.
+ uint16_t targetPort; ///< Effective HTTP port currently targeted by pairing.
+ std::string generatedPin; ///< Client-generated PIN shown to the user.
+ PairingStage stage; ///< Current stage of the reducer-driven pairing flow.
+ std::string statusMessage; ///< User-visible progress or error message for the pairing flow.
+ };
+
+ /**
+ * @brief Create a fresh pairing draft for the provided host.
+ *
+ * @param targetAddress Host address being paired.
+ * @param targetPort Effective host port being paired.
+ * @param generatedPin Client-generated PIN to show to the user.
+ * @return Initialized pairing draft.
+ */
+ PairingDraft create_pairing_draft(std::string_view targetAddress, uint16_t targetPort, std::string generatedPin);
+
+ /**
+ * @brief Return whether a PIN string is a valid Moonlight-style four-digit PIN.
+ *
+ * @param pin Candidate PIN string.
+ * @return true when the PIN contains exactly four digits.
+ */
+ bool is_valid_pairing_pin(std::string_view pin);
+
+} // namespace app
diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp
new file mode 100644
index 0000000..eef1e44
--- /dev/null
+++ b/src/app/settings_storage.cpp
@@ -0,0 +1,399 @@
+/**
+ * @file src/app/settings_storage.cpp
+ * @brief Implements application settings persistence.
+ */
+// class header include
+#include "src/app/settings_storage.h"
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if defined(_WIN32) && !defined(__MINGW32__) && !defined(__MINGW64__)
+/**
+ * @brief Declare the wide-character fopen variant required by toml++ on native Windows builds.
+ *
+ * @param path Wide-character file path.
+ * @param mode Wide-character fopen mode string.
+ * @return Open FILE handle, or nullptr on failure.
+ */
+extern "C" FILE *_wfopen(const wchar_t *path, const wchar_t *mode);
+#endif
+
+#include
+
+// local includes
+#include "src/platform/error_utils.h"
+#include "src/platform/filesystem_utils.h"
+#include "src/startup/storage_paths.h"
+
+namespace {
+
+ using namespace std::string_view_literals;
+ using platform::append_error;
+
+ constexpr std::string_view SETTINGS_FILE_NAME = "moonlight.toml"; ///< Stable settings file name stored under the app data directory.
+
+ /**
+ * @brief Read an entire text stream into memory.
+ *
+ * @param file Open file stream to consume.
+ * @return Complete file contents.
+ */
+ std::string read_all_text(FILE *file) {
+ std::string content;
+ std::vector buffer(4096);
+
+ while (true) {
+ const std::size_t bytesRead = std::fread(buffer.data(), 1, buffer.size(), file);
+ if (bytesRead > 0U) {
+ content.append(buffer.data(), bytesRead);
+ }
+ if (bytesRead < buffer.size()) {
+ break;
+ }
+ }
+
+ return content;
+ }
+
+ bool write_text_file(const std::string &filePath, std::string_view content, std::string *errorMessage) {
+ if (!platform::ensure_directory_exists(platform::parent_directory(filePath), errorMessage)) {
+ return false;
+ }
+
+ FILE *file = std::fopen(filePath.c_str(), "wb");
+ if (file == nullptr) {
+ return append_error(errorMessage, "Failed to open settings file '" + filePath + "' for writing: " + std::strerror(errno));
+ }
+
+ if (const std::size_t bytesWritten = std::fwrite(content.data(), 1, content.size(), file); bytesWritten != content.size()) {
+ const std::string writeError = std::strerror(errno);
+ std::fclose(file);
+ return append_error(errorMessage, "Failed to write settings file '" + filePath + "': " + writeError);
+ }
+
+ if (std::fclose(file) != 0) {
+ return append_error(errorMessage, "Failed to finalize settings file '" + filePath + "': " + std::strerror(errno));
+ }
+
+ return true;
+ }
+
+ std::string ascii_lowercase(std::string_view text) {
+ std::string normalized;
+ normalized.reserve(text.size());
+ for (const unsigned char character : text) {
+ normalized.push_back(static_cast(std::tolower(character)));
+ }
+ return normalized;
+ }
+
+ const char *logging_level_text(logging::LogLevel level) {
+ switch (level) {
+ case logging::LogLevel::trace:
+ return "trace";
+ case logging::LogLevel::debug:
+ return "debug";
+ case logging::LogLevel::info:
+ return "info";
+ case logging::LogLevel::warning:
+ return "warning";
+ case logging::LogLevel::error:
+ return "error";
+ case logging::LogLevel::none:
+ return "none";
+ }
+
+ return "none";
+ }
+
+ const char *log_viewer_placement_text(app::LogViewerPlacement placement) {
+ switch (placement) {
+ case app::LogViewerPlacement::full:
+ return "full";
+ case app::LogViewerPlacement::left:
+ return "left";
+ case app::LogViewerPlacement::right:
+ return "right";
+ }
+
+ return "full";
+ }
+
+ bool try_parse_logging_level(std::string_view text, logging::LogLevel *level) {
+ const std::string normalized = ascii_lowercase(text);
+ if (normalized == "trace") {
+ if (level != nullptr) {
+ *level = logging::LogLevel::trace;
+ }
+ return true;
+ }
+ if (normalized == "debug") {
+ if (level != nullptr) {
+ *level = logging::LogLevel::debug;
+ }
+ return true;
+ }
+ if (normalized == "info") {
+ if (level != nullptr) {
+ *level = logging::LogLevel::info;
+ }
+ return true;
+ }
+ if (normalized == "warning" || normalized == "warn") {
+ if (level != nullptr) {
+ *level = logging::LogLevel::warning;
+ }
+ return true;
+ }
+ if (normalized == "error") {
+ if (level != nullptr) {
+ *level = logging::LogLevel::error;
+ }
+ return true;
+ }
+ if (normalized == "none") {
+ if (level != nullptr) {
+ *level = logging::LogLevel::none;
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ bool try_parse_log_viewer_placement(std::string_view text, app::LogViewerPlacement *placement) {
+ const std::string normalized = ascii_lowercase(text);
+ if (normalized == "full") {
+ if (placement != nullptr) {
+ *placement = app::LogViewerPlacement::full;
+ }
+ return true;
+ }
+ if (normalized == "left") {
+ if (placement != nullptr) {
+ *placement = app::LogViewerPlacement::left;
+ }
+ return true;
+ }
+ if (normalized == "right") {
+ if (placement != nullptr) {
+ *placement = app::LogViewerPlacement::right;
+ }
+ return true;
+ }
+
+ return false;
+ }
+
+ void append_invalid_value_warning(std::vector *warnings, const std::string &filePath, std::string_view keyPath, std::string_view valueText) {
+ if (warnings == nullptr) {
+ return;
+ }
+
+ warnings->push_back(
+ "Ignoring invalid value '" + std::string(valueText) + "' for settings key '" + std::string(keyPath) + "' in '" + filePath + "'"
+ );
+ }
+
+ void append_cleanup_warning(std::vector *warnings, const std::string &filePath, std::string_view keyPath, std::string_view reason) {
+ if (warnings == nullptr) {
+ return;
+ }
+
+ warnings->push_back(
+ "Will remove " + std::string(reason) + " settings key '" + std::string(keyPath) + "' from '" + filePath + "' on the next save"
+ );
+ }
+
+ void mark_cleanup_required(app::LoadAppSettingsResult *result, const std::string &filePath, std::string_view keyPath, std::string_view reason) {
+ if (result == nullptr) {
+ return;
+ }
+
+ result->cleanupRequired = true;
+ append_cleanup_warning(&result->warnings, filePath, keyPath, reason);
+ }
+
+ void load_logging_level_setting(
+ toml::node_view settingNode,
+ const std::string &filePath,
+ std::string_view keyPath,
+ logging::LogLevel *level,
+ std::vector *warnings
+ ) {
+ if (!settingNode) {
+ return;
+ }
+
+ if (const auto loggingLevelText = settingNode.value(); loggingLevelText) {
+ if (!try_parse_logging_level(*loggingLevelText, level)) {
+ append_invalid_value_warning(warnings, filePath, keyPath, *loggingLevelText);
+ }
+ return;
+ }
+
+ append_invalid_value_warning(warnings, filePath, keyPath, "");
+ }
+
+ void load_log_viewer_placement_setting(
+ toml::node_view settingNode,
+ const std::string &filePath,
+ app::LogViewerPlacement *placement,
+ std::vector *warnings
+ ) {
+ if (!settingNode) {
+ return;
+ }
+
+ if (const auto logViewerPlacementText = settingNode.value(); logViewerPlacementText) {
+ if (!try_parse_log_viewer_placement(*logViewerPlacementText, placement)) {
+ append_invalid_value_warning(warnings, filePath, "ui.log_viewer_placement", *logViewerPlacementText);
+ }
+ return;
+ }
+
+ append_invalid_value_warning(warnings, filePath, "ui.log_viewer_placement", "");
+ }
+
+ std::string format_settings_toml(const app::AppSettings &settings) {
+ std::string content;
+ content += "# Moonlight Xbox OG user settings\n";
+ content += "# This file is safe to edit by hand.\n\n";
+ content += "[logging]\n";
+ content += "# Controls runtime file logging. Use none to avoid disk writes on slow Xbox drives.\n";
+ content += std::string("file_minimum_level = \"") + logging_level_text(settings.loggingLevel) + "\"\n";
+ content += "# Controls runtime DbgPrint() output for xemu's serial console.\n";
+ content += "# Start xemu with -device lpc47m157 -serial stdio to see this output.\n";
+ content += std::string("xemu_console_minimum_level = \"") + logging_level_text(settings.xemuConsoleLoggingLevel) + "\"\n\n";
+ content += "[ui]\n";
+ content += "# Preferred placement for the in-app log viewer.\n";
+ content += std::string("log_viewer_placement = \"") + log_viewer_placement_text(settings.logViewerPlacement) + "\"\n";
+ return content;
+ }
+
+ void inspect_logging_keys(const toml::table &loggingTable, const std::string &filePath, app::LoadAppSettingsResult *result) {
+ for (const auto &[rawKey, node] : loggingTable) {
+ const std::string key(rawKey.str());
+ if (key == "file_minimum_level" || key == "xemu_console_minimum_level") {
+ continue;
+ }
+ if (key == "minimum_level") {
+ (void) node;
+ mark_cleanup_required(result, filePath, "logging.minimum_level", "legacy");
+ continue;
+ }
+
+ (void) node;
+ mark_cleanup_required(result, filePath, std::string("logging.") + key, "obsolete");
+ }
+ }
+
+ void inspect_ui_keys(const toml::table &uiTable, const std::string &filePath, app::LoadAppSettingsResult *result) {
+ for (const auto &[rawKey, node] : uiTable) {
+ const std::string key(rawKey.str());
+ if (key == "log_viewer_placement") {
+ continue;
+ }
+
+ (void) node;
+ mark_cleanup_required(result, filePath, std::string("ui.") + key, "obsolete");
+ }
+ }
+
+ void inspect_top_level_keys(const toml::table &settingsTable, const std::string &filePath, app::LoadAppSettingsResult *result) {
+ for (const auto &[rawKey, node] : settingsTable) {
+ const std::string key(rawKey.str());
+ if (key == "logging") {
+ if (const auto *loggingTable = node.as_table(); loggingTable != nullptr) {
+ inspect_logging_keys(*loggingTable, filePath, result);
+ }
+ continue;
+ }
+ if (key == "ui") {
+ if (const auto *uiTable = node.as_table(); uiTable != nullptr) {
+ inspect_ui_keys(*uiTable, filePath, result);
+ }
+ continue;
+ }
+ if (key == "debug") {
+ mark_cleanup_required(result, filePath, "debug", "obsolete");
+ continue;
+ }
+
+ mark_cleanup_required(result, filePath, key, "obsolete");
+ }
+ }
+
+} // namespace
+
+namespace app {
+
+ std::string default_settings_path() {
+ return startup::default_storage_path(SETTINGS_FILE_NAME);
+ }
+
+ LoadAppSettingsResult load_app_settings(const std::string &filePath) {
+ LoadAppSettingsResult result {};
+
+ FILE *file = std::fopen(filePath.c_str(), "rb");
+ if (file == nullptr) {
+ if (errno != ENOENT) {
+ result.warnings.push_back("Failed to open settings file '" + filePath + "': " + std::strerror(errno));
+ }
+ return result;
+ }
+
+ result.fileFound = true;
+ const std::string fileContent = read_all_text(file);
+ if (std::ferror(file) != 0) {
+ result.warnings.push_back("Failed while reading settings file '" + filePath + "': " + std::strerror(errno));
+ std::fclose(file);
+ return result;
+ }
+ std::fclose(file);
+
+ const toml::parse_result parsed = toml::parse(std::string_view {fileContent}, std::string_view {filePath});
+ if (!parsed) {
+ result.warnings.push_back("Failed to parse settings file '" + filePath + "': " + std::string(parsed.error().description()));
+ return result;
+ }
+
+ const toml::table &settingsTable = parsed.table();
+ inspect_top_level_keys(settingsTable, filePath, &result);
+
+ const auto loggingLevelNode = settingsTable["logging"]["file_minimum_level"];
+ load_logging_level_setting(loggingLevelNode, filePath, "logging.file_minimum_level", &result.settings.loggingLevel, &result.warnings);
+
+ if (const auto legacyLoggingLevelNode = settingsTable["logging"]["minimum_level"]; legacyLoggingLevelNode && !loggingLevelNode) {
+ load_logging_level_setting(legacyLoggingLevelNode, filePath, "logging.minimum_level", &result.settings.loggingLevel, &result.warnings);
+ }
+
+ load_logging_level_setting(
+ settingsTable["logging"]["xemu_console_minimum_level"],
+ filePath,
+ "logging.xemu_console_minimum_level",
+ &result.settings.xemuConsoleLoggingLevel,
+ &result.warnings
+ );
+ load_log_viewer_placement_setting(settingsTable["ui"]["log_viewer_placement"], filePath, &result.settings.logViewerPlacement, &result.warnings);
+
+ return result;
+ }
+
+ SaveAppSettingsResult save_app_settings(const AppSettings &settings, const std::string &filePath) {
+ if (std::string errorMessage; !write_text_file(filePath, format_settings_toml(settings), &errorMessage)) {
+ return {false, errorMessage};
+ }
+
+ return {true, {}};
+ }
+
+} // namespace app
diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h
new file mode 100644
index 0000000..21d8fe2
--- /dev/null
+++ b/src/app/settings_storage.h
@@ -0,0 +1,67 @@
+/**
+ * @file src/app/settings_storage.h
+ * @brief Declares application settings persistence.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+
+// local includes
+#include "src/app/client_state.h"
+
+namespace app {
+
+ /**
+ * @brief Persisted Moonlight user settings stored in TOML.
+ */
+ struct AppSettings {
+ logging::LogLevel loggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written to the log file.
+ logging::LogLevel xemuConsoleLoggingLevel = logging::LogLevel::none; ///< Minimum runtime log level written through DbgPrint() for xemu's serial console.
+ app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; ///< Preferred placement for the in-app log viewer.
+ };
+
+ /**
+ * @brief Result of loading the persisted Moonlight settings file.
+ */
+ struct LoadAppSettingsResult {
+ AppSettings settings; ///< Loaded settings, or defaults when the file is missing or invalid.
+ std::vector warnings; ///< Non-fatal warnings encountered while loading the file.
+ bool fileFound = false; ///< True when the settings file existed on disk.
+ bool cleanupRequired = false; ///< True when legacy or unknown keys should be removed by rewriting the file.
+ };
+
+ /**
+ * @brief Result of saving the persisted Moonlight settings file.
+ */
+ struct SaveAppSettingsResult {
+ bool success = false; ///< True when the settings file was written successfully.
+ std::string errorMessage; ///< Error detail when writing failed.
+ };
+
+ /**
+ * @brief Return the default path used for the persisted Moonlight settings file.
+ *
+ * @return Default TOML settings file path.
+ */
+ std::string default_settings_path();
+
+ /**
+ * @brief Load the persisted Moonlight settings file.
+ *
+ * @param filePath Settings file to read.
+ * @return Loaded settings plus any non-fatal warnings.
+ */
+ LoadAppSettingsResult load_app_settings(const std::string &filePath = default_settings_path());
+
+ /**
+ * @brief Save the persisted Moonlight settings file.
+ *
+ * @param settings Settings snapshot to write.
+ * @param filePath Settings file to write.
+ * @return Save result including success state and error detail.
+ */
+ SaveAppSettingsResult save_app_settings(const AppSettings &settings, const std::string &filePath = default_settings_path());
+
+} // namespace app
diff --git a/src/input/navigation_input.cpp b/src/input/navigation_input.cpp
new file mode 100644
index 0000000..f60bd59
--- /dev/null
+++ b/src/input/navigation_input.cpp
@@ -0,0 +1,90 @@
+/**
+ * @file src/input/navigation_input.cpp
+ * @brief Implements controller navigation input handling.
+ */
+// class header include
+#include "src/input/navigation_input.h"
+
+namespace input {
+
+ UiCommand map_gamepad_button_to_ui_command(GamepadButton button) {
+ switch (button) {
+ case GamepadButton::dpad_up:
+ return UiCommand::move_up;
+ case GamepadButton::dpad_down:
+ return UiCommand::move_down;
+ case GamepadButton::dpad_left:
+ return UiCommand::move_left;
+ case GamepadButton::dpad_right:
+ return UiCommand::move_right;
+ case GamepadButton::a:
+ return UiCommand::activate;
+ case GamepadButton::start:
+ return UiCommand::confirm;
+ case GamepadButton::b:
+ case GamepadButton::back:
+ return UiCommand::back;
+ case GamepadButton::x:
+ return UiCommand::delete_character;
+ case GamepadButton::y:
+ return UiCommand::open_context_menu;
+ case GamepadButton::left_shoulder:
+ return UiCommand::previous_page;
+ case GamepadButton::right_shoulder:
+ return UiCommand::next_page;
+ }
+
+ return UiCommand::none;
+ }
+
+ UiCommand map_gamepad_axis_direction_to_ui_command(GamepadAxisDirection direction) {
+ switch (direction) {
+ case GamepadAxisDirection::left_stick_up:
+ return UiCommand::move_up;
+ case GamepadAxisDirection::left_stick_down:
+ return UiCommand::move_down;
+ case GamepadAxisDirection::left_stick_left:
+ return UiCommand::move_left;
+ case GamepadAxisDirection::left_stick_right:
+ return UiCommand::move_right;
+ }
+
+ return UiCommand::none;
+ }
+
+ UiCommand map_keyboard_key_to_ui_command(KeyboardKey key, bool shiftPressed) {
+ switch (key) {
+ case KeyboardKey::up:
+ return UiCommand::move_up;
+ case KeyboardKey::down:
+ return UiCommand::move_down;
+ case KeyboardKey::left:
+ return UiCommand::move_left;
+ case KeyboardKey::right:
+ return UiCommand::move_right;
+ case KeyboardKey::enter:
+ return UiCommand::confirm;
+ case KeyboardKey::backspace:
+ case KeyboardKey::delete_key:
+ return UiCommand::delete_character;
+ case KeyboardKey::space:
+ return UiCommand::activate;
+ case KeyboardKey::escape:
+ return UiCommand::back;
+ case KeyboardKey::tab:
+ return shiftPressed ? UiCommand::previous_page : UiCommand::next_page;
+ case KeyboardKey::page_up:
+ return UiCommand::previous_page;
+ case KeyboardKey::page_down:
+ return UiCommand::next_page;
+ case KeyboardKey::i:
+ case KeyboardKey::m:
+ return UiCommand::open_context_menu;
+ case KeyboardKey::f3:
+ return UiCommand::toggle_overlay;
+ }
+
+ return UiCommand::none;
+ }
+
+} // namespace input
diff --git a/src/input/navigation_input.h b/src/input/navigation_input.h
new file mode 100644
index 0000000..e133890
--- /dev/null
+++ b/src/input/navigation_input.h
@@ -0,0 +1,104 @@
+/**
+ * @file src/input/navigation_input.h
+ * @brief Declares controller navigation input handling.
+ */
+#pragma once
+
+namespace input {
+
+ /**
+ * @brief Abstract UI command emitted by controller or keyboard input.
+ */
+ enum class UiCommand {
+ none, ///< No UI action should be performed.
+ move_up, ///< Move selection upward.
+ move_down, ///< Move selection downward.
+ move_left, ///< Move selection left.
+ move_right, ///< Move selection right.
+ activate, ///< Activate the focused item.
+ confirm, ///< Confirm the current action.
+ back, ///< Navigate back or cancel.
+ open_context_menu, ///< Open the focused item's context menu.
+ delete_character, ///< Delete one character from text input.
+ previous_page, ///< Move to the previous page.
+ next_page, ///< Move to the next page.
+ fast_previous_page, ///< Jump backward by a larger page increment.
+ fast_next_page, ///< Jump forward by a larger page increment.
+ toggle_overlay, ///< Toggle the diagnostics overlay.
+ };
+
+ /**
+ * @brief Controller buttons used by the Moonlight client UI.
+ */
+ enum class GamepadButton {
+ dpad_up, ///< D-pad up button.
+ dpad_down, ///< D-pad down button.
+ dpad_left, ///< D-pad left button.
+ dpad_right, ///< D-pad right button.
+ a, ///< South face button.
+ b, ///< East face button.
+ x, ///< West face button.
+ y, ///< North face button.
+ left_shoulder, ///< Left shoulder button.
+ right_shoulder, ///< Right shoulder button.
+ start, ///< Start button.
+ back, ///< Back button.
+ };
+
+ /**
+ * @brief Controller axis directions mapped onto UI navigation commands.
+ */
+ enum class GamepadAxisDirection {
+ left_stick_up, ///< Left stick moved upward past the navigation threshold.
+ left_stick_down, ///< Left stick moved downward past the navigation threshold.
+ left_stick_left, ///< Left stick moved left past the navigation threshold.
+ left_stick_right, ///< Left stick moved right past the navigation threshold.
+ };
+
+ /**
+ * @brief Keyboard keys mapped onto the same abstract UI commands.
+ */
+ enum class KeyboardKey {
+ up, ///< Up arrow key.
+ down, ///< Down arrow key.
+ left, ///< Left arrow key.
+ right, ///< Right arrow key.
+ enter, ///< Enter or return key.
+ escape, ///< Escape key.
+ backspace, ///< Backspace key.
+ delete_key, ///< Delete key.
+ space, ///< Space bar.
+ tab, ///< Tab key.
+ page_up, ///< Page Up key.
+ page_down, ///< Page Down key.
+ i, ///< Letter I key.
+ m, ///< Letter M key.
+ f3, ///< F3 function key.
+ };
+
+ /**
+ * @brief Map a controller button to a UI command.
+ *
+ * @param button Controller button that was pressed.
+ * @return The abstract UI command to process.
+ */
+ UiCommand map_gamepad_button_to_ui_command(GamepadButton button);
+
+ /**
+ * @brief Map a controller axis direction to a UI command.
+ *
+ * @param direction Controller axis direction that crossed the navigation threshold.
+ * @return The abstract UI command to process.
+ */
+ UiCommand map_gamepad_axis_direction_to_ui_command(GamepadAxisDirection direction);
+
+ /**
+ * @brief Map a keyboard key to a UI command.
+ *
+ * @param key Keyboard key that was pressed.
+ * @param shiftPressed Whether Shift was held for keys such as Tab.
+ * @return The abstract UI command to process.
+ */
+ UiCommand map_keyboard_key_to_ui_command(KeyboardKey key, bool shiftPressed = false);
+
+} // namespace input
diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp
new file mode 100644
index 0000000..6dd2cab
--- /dev/null
+++ b/src/logging/log_file.cpp
@@ -0,0 +1,156 @@
+/**
+ * @file src/logging/log_file.cpp
+ * @brief Implements log file lifecycle helpers.
+ */
+// class header include
+#include "src/logging/log_file.h"
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+
+// local includes
+#include "src/platform/filesystem_utils.h"
+#include "src/startup/storage_paths.h"
+
+namespace {
+
+ std::string persisted_log_line(const logging::LogEntry &entry) {
+ return std::string("[") + logging::format_timestamp(entry.timestamp) + "] " + logging::format_entry(entry);
+ }
+
+} // namespace
+
+namespace logging {
+
+ std::string default_log_file_path() {
+ return startup::default_storage_path("moonlight.log");
+ }
+
+ bool reset_log_file(const std::string &filePath, std::string *errorMessage) {
+ if (!platform::ensure_directory_exists(platform::parent_directory(filePath), errorMessage)) {
+ return false;
+ }
+
+ FILE *file = std::fopen(filePath.c_str(), "wb");
+ if (file == nullptr) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Failed to reset log file '" + filePath + "': " + std::strerror(errno);
+ }
+ return false;
+ }
+
+ if (std::fclose(file) != 0) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Failed to finalize log file '" + filePath + "': " + std::strerror(errno);
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ bool append_log_file_entry(const LogEntry &entry, const std::string &filePath, std::string *errorMessage) {
+ if (!platform::ensure_directory_exists(platform::parent_directory(filePath), errorMessage)) {
+ return false;
+ }
+
+ FILE *file = std::fopen(filePath.c_str(), "ab");
+ if (file == nullptr) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Failed to open log file '" + filePath + "' for appending: " + std::strerror(errno);
+ }
+ return false;
+ }
+
+ const std::string line = persisted_log_line(entry) + "\r\n";
+ if (const std::size_t bytesWritten = std::fwrite(line.data(), 1, line.size(), file); bytesWritten != line.size()) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Failed to append to log file '" + filePath + "': " + std::strerror(errno);
+ }
+ std::fclose(file);
+ return false;
+ }
+
+ if (std::fclose(file) != 0) {
+ if (errorMessage != nullptr) {
+ *errorMessage = "Failed to finalize log file '" + filePath + "': " + std::strerror(errno);
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ RuntimeLogFileSink::RuntimeLogFileSink(std::string filePath):
+ filePath_(std::move(filePath)) {}
+
+ const std::string &RuntimeLogFileSink::file_path() const {
+ return filePath_;
+ }
+
+ bool RuntimeLogFileSink::reset(std::string *errorMessage) const {
+ return reset_log_file(filePath_, errorMessage);
+ }
+
+ bool RuntimeLogFileSink::consume(const LogEntry &entry, std::string *errorMessage) const {
+ return append_log_file_entry(entry, filePath_, errorMessage);
+ }
+
+ LoadLogFileResult load_log_file(const std::string &filePath, std::size_t maxLines) {
+ LoadLogFileResult result {};
+ result.filePath = filePath;
+
+ FILE *file = std::fopen(filePath.c_str(), "rb");
+ if (file == nullptr) {
+ if (errno != ENOENT) {
+ result.errorMessage = "Failed to open log file '" + filePath + "': " + std::strerror(errno);
+ }
+ return result;
+ }
+
+ result.fileFound = true;
+ std::deque bufferedLines;
+ auto append_line = [&](std::string line) {
+ while (!line.empty() && (line.back() == '\n' || line.back() == '\r')) {
+ line.pop_back();
+ }
+
+ if (maxLines > 0U && bufferedLines.size() == maxLines) {
+ bufferedLines.pop_front();
+ }
+ bufferedLines.push_back(std::move(line));
+ };
+
+ std::array buffer {};
+ std::string pendingLine;
+ while (std::fgets(buffer.data(), static_cast(buffer.size()), file) != nullptr) {
+ pendingLine += buffer.data();
+ if (const std::size_t pendingLength = pendingLine.size(); pendingLength == 0U) {
+ continue;
+ }
+
+ if (pendingLine.back() == '\n' || pendingLine.back() == '\r') {
+ append_line(std::move(pendingLine));
+ pendingLine.clear();
+ }
+ }
+
+ if (!pendingLine.empty()) {
+ append_line(std::move(pendingLine));
+ }
+
+ if (std::ferror(file) != 0) {
+ result.errorMessage = "Failed while reading log file '" + filePath + "': " + std::strerror(errno);
+ }
+ std::fclose(file);
+
+ result.lines.assign(bufferedLines.begin(), bufferedLines.end());
+ return result;
+ }
+
+} // namespace logging
diff --git a/src/logging/log_file.h b/src/logging/log_file.h
new file mode 100644
index 0000000..5fd985d
--- /dev/null
+++ b/src/logging/log_file.h
@@ -0,0 +1,102 @@
+/**
+ * @file src/logging/log_file.h
+ * @brief Declares log file lifecycle helpers.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+
+// local includes
+#include "src/logging/logger.h"
+
+namespace logging {
+
+ /**
+ * @brief Result of loading the persisted log file for the shell viewer.
+ */
+ struct LoadLogFileResult {
+ std::string filePath; ///< Path that was requested for loading.
+ std::vector lines; ///< Loaded log lines in display order.
+ bool fileFound = false; ///< True when the target file existed on disk.
+ std::string errorMessage; ///< Error detail when loading failed.
+ };
+
+ /**
+ * @brief Return the default path used for persisted log output.
+ *
+ * @return Default log file path.
+ */
+ std::string default_log_file_path();
+
+ /**
+ * @brief Truncate or recreate the persisted log file.
+ *
+ * @param filePath Path to reset.
+ * @param errorMessage Optional output for I/O failures.
+ * @return true when the file was reset successfully.
+ */
+ bool reset_log_file(const std::string &filePath = default_log_file_path(), std::string *errorMessage = nullptr);
+
+ /**
+ * @brief Append one formatted log entry to the persisted log file.
+ *
+ * @param entry Structured log entry to append.
+ * @param filePath Target log file path.
+ * @param errorMessage Optional output for I/O failures.
+ * @return true when the entry was written successfully.
+ */
+ bool append_log_file_entry(const LogEntry &entry, const std::string &filePath = default_log_file_path(), std::string *errorMessage = nullptr);
+
+ /**
+ * @brief Small helper that targets one persisted runtime log file.
+ */
+ class RuntimeLogFileSink {
+ public:
+ /**
+ * @brief Construct a runtime log-file sink for the requested file path.
+ *
+ * @param filePath Target log file path.
+ */
+ explicit RuntimeLogFileSink(std::string filePath = default_log_file_path());
+
+ /**
+ * @brief Return the configured runtime log-file path.
+ *
+ * @return Target log file path.
+ */
+ const std::string &file_path() const;
+
+ /**
+ * @brief Truncate or recreate the configured runtime log file.
+ *
+ * @param errorMessage Optional output for I/O failures.
+ * @return true when the file was reset successfully.
+ */
+ bool reset(std::string *errorMessage = nullptr) const;
+
+ /**
+ * @brief Append one log entry to the configured runtime log file.
+ *
+ * @param entry Structured log entry to write.
+ * @param errorMessage Optional output for file-append failures.
+ * @return true when the entry was written successfully.
+ */
+ bool consume(const LogEntry &entry, std::string *errorMessage = nullptr) const;
+
+ private:
+ std::string filePath_;
+ };
+
+ /**
+ * @brief Load recent lines from the persisted log file.
+ *
+ * @param filePath Target log file path.
+ * @param maxLines Maximum number of trailing lines to retain.
+ * @return Loaded log file contents and any error details.
+ */
+ LoadLogFileResult load_log_file(const std::string &filePath = default_log_file_path(), std::size_t maxLines = 64U);
+
+} // namespace logging
diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp
new file mode 100644
index 0000000..ffe3cb7
--- /dev/null
+++ b/src/logging/logger.cpp
@@ -0,0 +1,445 @@
+/**
+ * @file src/logging/logger.cpp
+ * @brief Implements logging configuration and output.
+ */
+// class header include
+#include "src/logging/logger.h"
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#if defined(_WIN32)
+ #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names
+#endif
+
+#if defined(NXDK)
+ #include
+ #include
+#endif
+
+namespace {
+
+ bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) {
+ return static_cast(candidateLevel) >= static_cast(minimumLevel);
+ }
+
+ logging::Logger *registered_logger() {
+ return logging::detail::GlobalLoggingState::registeredLogger;
+ }
+
+ logging::LogLevel startup_console_level(logging::LogLevel level) {
+ return level == logging::LogLevel::none ? logging::LogLevel::info : level;
+ }
+
+ std::string normalized_source_path(const char *filePath) {
+ // NOTE: do not trim file extensions, it's needed so IDE can link the file and line number
+ if (filePath == nullptr || filePath[0] == '\0') {
+ return {};
+ }
+
+ std::string normalized(filePath);
+ for (char &character : normalized) {
+ if (character == '\\') {
+ character = '/';
+ }
+ }
+
+ for (const char *marker : {"src/", "tests/"}) {
+ if (const std::size_t markerOffset = normalized.find(marker); markerOffset != std::string::npos) {
+ return normalized.substr(markerOffset);
+ }
+ }
+
+ if (const std::size_t lastSeparator = normalized.find_last_of('/'); lastSeparator != std::string::npos) {
+ return normalized.substr(lastSeparator + 1U);
+ }
+
+ return normalized;
+ }
+
+ std::string debugger_console_line(const logging::LogEntry &entry) {
+ return std::string("[") + logging::format_timestamp(entry.timestamp) + "] " + logging::format_entry(entry);
+ }
+
+ void emit_debugger_console_line(const logging::LogEntry &entry) {
+#if defined(NXDK)
+ const std::string line = debugger_console_line(entry);
+ DbgPrint("%s\r\n", line.c_str());
+#else
+ (void) entry;
+#endif
+ }
+
+ logging::LogTimestamp current_local_timestamp() {
+#if defined(_WIN32)
+ SYSTEMTIME localTime {};
+ GetLocalTime(&localTime);
+ return {
+ static_cast(localTime.wYear),
+ static_cast(localTime.wMonth),
+ static_cast(localTime.wDay),
+ static_cast(localTime.wHour),
+ static_cast(localTime.wMinute),
+ static_cast(localTime.wSecond),
+ static_cast(localTime.wMilliseconds),
+ };
+#else
+ const auto now = std::chrono::system_clock::now();
+ const std::time_t nowTime = std::chrono::system_clock::to_time_t(now);
+ std::tm localTime {};
+ #if defined(_MSC_VER)
+ localtime_s(&localTime, &nowTime);
+ #else
+ localtime_r(&nowTime, &localTime);
+ #endif
+ const auto milliseconds = std::chrono::duration_cast(now.time_since_epoch()) % 1000;
+ return {
+ localTime.tm_year + 1900,
+ localTime.tm_mon + 1,
+ localTime.tm_mday,
+ localTime.tm_hour,
+ localTime.tm_min,
+ localTime.tm_sec,
+ static_cast(milliseconds.count()),
+ };
+#endif
+ }
+
+ bool is_valid_timestamp(const logging::LogTimestamp ×tamp) {
+ return timestamp.year > 0 && timestamp.month >= 1 && timestamp.month <= 12 && timestamp.day >= 1 && timestamp.day <= 31 && timestamp.hour >= 0 && timestamp.hour <= 23 && timestamp.minute >= 0 && timestamp.minute <= 59 && timestamp.second >= 0 && timestamp.second <= 60 && timestamp.millisecond >= 0 && timestamp.millisecond <= 999;
+ }
+
+} // namespace
+
+namespace logging {
+
+ const char *to_string(LogLevel level) {
+ switch (level) {
+ case LogLevel::trace:
+ return "TRACE";
+ case LogLevel::debug:
+ return "DEBUG";
+ case LogLevel::info:
+ return "INFO";
+ case LogLevel::warning:
+ return "WARN";
+ case LogLevel::error:
+ return "ERROR";
+ case LogLevel::none:
+ return "NONE";
+ }
+
+ return "UNKNOWN";
+ }
+
+ std::string format_timestamp(const LogTimestamp ×tamp) {
+ std::array buffer {};
+ const bool validTimestamp = is_valid_timestamp(timestamp);
+ std::snprintf(
+ buffer.data(),
+ buffer.size(),
+ "%04d-%02d-%02d %02d:%02d:%02d.%03d",
+ validTimestamp ? timestamp.year : 0,
+ validTimestamp ? timestamp.month : 0,
+ validTimestamp ? timestamp.day : 0,
+ validTimestamp ? timestamp.hour : 0,
+ validTimestamp ? timestamp.minute : 0,
+ validTimestamp ? timestamp.second : 0,
+ validTimestamp ? timestamp.millisecond : 0
+ );
+ return {buffer.data()};
+ }
+
+ std::string format_source_location(const LogSourceLocation &location) {
+ if (!location.valid()) {
+ return {};
+ }
+
+ const std::string normalizedPath = normalized_source_path(location.file);
+ if (normalizedPath.empty()) {
+ return {};
+ }
+
+ return normalizedPath + ":" + std::to_string(location.line);
+ }
+
+ std::string format_entry(const LogEntry &entry) {
+ const std::string sourceLocationText = format_source_location(entry.sourceLocation);
+ std::string line = std::string("[") + to_string(entry.level) + "] ";
+ if (!sourceLocationText.empty()) {
+ line += "[" + sourceLocationText + "] ";
+ if (!entry.category.empty()) {
+ line += entry.category + ": ";
+ }
+ line += entry.message;
+ return line;
+ }
+
+ if (!entry.category.empty()) {
+ line += entry.category + ": ";
+ }
+ line += entry.message;
+ return line;
+ }
+
+ void set_global_logger(Logger *logger) {
+ detail::GlobalLoggingState::registeredLogger = logger;
+ }
+
+ bool has_global_logger() {
+ return registered_logger() != nullptr;
+ }
+
+ bool log(LogLevel level, std::string category, std::string message, LogSourceLocation location) {
+ if (Logger *logger = registered_logger(); logger != nullptr) {
+ return logger->log(level, std::move(category), std::move(message), location);
+ }
+
+ return false;
+ }
+
+ bool trace(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::trace, std::move(category), std::move(message), location);
+ }
+
+ bool debug(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::debug, std::move(category), std::move(message), location);
+ }
+
+ bool info(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::info, std::move(category), std::move(message), location);
+ }
+
+ bool warn(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::warning, std::move(category), std::move(message), location);
+ }
+
+ bool error(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::error, std::move(category), std::move(message), location);
+ }
+
+ void set_minimum_level(LogLevel minimumLevel) {
+ if (Logger *logger = registered_logger(); logger != nullptr) {
+ logger->set_minimum_level(minimumLevel);
+ }
+ }
+
+ void set_file_sink(LogSink sink) {
+ if (Logger *logger = registered_logger(); logger != nullptr) {
+ logger->set_file_sink(std::move(sink));
+ }
+ }
+
+ void set_file_minimum_level(LogLevel minimumLevel) {
+ if (Logger *logger = registered_logger(); logger != nullptr) {
+ logger->set_file_minimum_level(minimumLevel);
+ }
+ }
+
+ void set_debugger_console_minimum_level(LogLevel minimumLevel) {
+ if (Logger *logger = registered_logger(); logger != nullptr) {
+ logger->set_debugger_console_minimum_level(minimumLevel);
+ }
+ }
+
+ void set_startup_debug_enabled(bool enabled) {
+ if (Logger *logger = registered_logger(); logger != nullptr) {
+ logger->set_startup_debug_enabled(enabled);
+ }
+ }
+
+ std::vector snapshot(LogLevel minimumLevel) {
+ if (const Logger *logger = registered_logger(); logger != nullptr) {
+ return logger->snapshot(minimumLevel);
+ }
+
+ return {};
+ }
+
+ std::string format_startup_console_line(LogLevel level, std::string_view category, std::string_view message) {
+ std::string line = std::string("[") + to_string(startup_console_level(level)) + "] ";
+ if (!category.empty()) {
+ line.append(category.data(), category.size());
+ line.append(": ");
+ }
+ line.append(message.data(), message.size());
+ return line;
+ }
+
+ void set_startup_console_enabled(bool enabled) {
+ detail::GlobalLoggingState::startupConsoleEnabled = enabled;
+ }
+
+ bool startup_console_enabled() {
+ return detail::GlobalLoggingState::startupConsoleEnabled;
+ }
+
+ void print_startup_console_line(LogLevel level, std::string_view category, std::string_view message) {
+ if (!startup_console_enabled()) {
+ return;
+ }
+
+#if defined(NXDK)
+ const std::string line = format_startup_console_line(level, category, message);
+ debugPrint("%s\n", line.c_str());
+#else
+ (void) level;
+ (void) category;
+ (void) message;
+#endif
+ }
+
+ Logger::Logger(std::size_t capacity, TimestampProvider timestampProvider):
+ capacity_(capacity == 0 ? 1 : capacity),
+ timestampProvider_(timestampProvider ? std::move(timestampProvider) : TimestampProvider(current_local_timestamp)) {}
+
+ std::size_t Logger::capacity() const {
+ return capacity_;
+ }
+
+ void Logger::set_minimum_level(LogLevel minimumLevel) {
+ minimumLevel_ = minimumLevel;
+ }
+
+ LogLevel Logger::minimum_level() const {
+ return minimumLevel_;
+ }
+
+ void Logger::set_file_sink(LogSink sink) {
+ fileSink_ = std::move(sink);
+ }
+
+ void Logger::set_file_minimum_level(LogLevel minimumLevel) {
+ fileMinimumLevel_ = minimumLevel;
+ }
+
+ LogLevel Logger::file_minimum_level() const {
+ return fileMinimumLevel_;
+ }
+
+ void Logger::set_startup_debug_enabled(bool enabled) {
+ startupDebugEnabled_ = enabled;
+ }
+
+ bool Logger::startup_debug_enabled() const {
+ return startupDebugEnabled_;
+ }
+
+ void Logger::set_debugger_console_minimum_level(LogLevel minimumLevel) {
+ debuggerConsoleMinimumLevel_ = minimumLevel;
+ }
+
+ LogLevel Logger::debugger_console_minimum_level() const {
+ return debuggerConsoleMinimumLevel_;
+ }
+
+ bool Logger::should_log(LogLevel level) const {
+ if (is_enabled(level, minimumLevel_)) {
+ return true;
+ }
+ if (startupDebugEnabled_) {
+ return true;
+ }
+ if (fileSink_ && is_enabled(level, fileMinimumLevel_)) {
+ return true;
+ }
+ if (is_enabled(level, debuggerConsoleMinimumLevel_)) {
+ return true;
+ }
+
+ return std::any_of(sinks_.begin(), sinks_.end(), [level](const RegisteredSink ®isteredSink) {
+ return registeredSink.sink && is_enabled(level, registeredSink.minimumLevel);
+ });
+ }
+
+ bool Logger::log(LogLevel level, std::string category, std::string message, LogSourceLocation location) {
+ if (!should_log(level)) {
+ return false;
+ }
+
+ LogEntry entry {
+ nextSequence_,
+ level,
+ std::move(category),
+ std::move(message),
+ timestampProvider_(),
+ location,
+ };
+ ++nextSequence_;
+
+ if (is_enabled(level, minimumLevel_)) {
+ if (entries_.size() == capacity_) {
+ entries_.pop_front();
+ }
+
+ entries_.push_back(entry);
+ }
+
+ if (startupDebugEnabled_) {
+ print_startup_console_line(entry.level, entry.category, entry.message);
+ }
+ if (fileSink_ && is_enabled(level, fileMinimumLevel_)) {
+ fileSink_(entry);
+ }
+ if (is_enabled(level, debuggerConsoleMinimumLevel_)) {
+ emit_debugger_console_line(entry);
+ }
+
+ for (const RegisteredSink ®isteredSink : sinks_) {
+ if (registeredSink.sink && is_enabled(level, registeredSink.minimumLevel)) {
+ registeredSink.sink(entry);
+ }
+ }
+
+ return true;
+ }
+
+ bool Logger::trace(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::trace, std::move(category), std::move(message), location);
+ }
+
+ bool Logger::debug(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::debug, std::move(category), std::move(message), location);
+ }
+
+ bool Logger::info(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::info, std::move(category), std::move(message), location);
+ }
+
+ bool Logger::warn(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::warning, std::move(category), std::move(message), location);
+ }
+
+ bool Logger::error(std::string category, std::string message, LogSourceLocation location) {
+ return log(LogLevel::error, std::move(category), std::move(message), location);
+ }
+
+ void Logger::add_sink(LogSink sink, LogLevel minimumLevel) {
+ if (sink) {
+ sinks_.push_back({minimumLevel, std::move(sink)});
+ }
+ }
+
+ const std::deque &Logger::entries() const {
+ return entries_;
+ }
+
+ std::vector Logger::snapshot(LogLevel minimumLevel) const {
+ std::vector filteredEntries;
+
+ for (const LogEntry &entry : entries_) {
+ if (is_enabled(entry.level, minimumLevel)) {
+ filteredEntries.push_back(entry);
+ }
+ }
+
+ return filteredEntries;
+ }
+
+} // namespace logging
diff --git a/src/logging/logger.h b/src/logging/logger.h
new file mode 100644
index 0000000..6243d08
--- /dev/null
+++ b/src/logging/logger.h
@@ -0,0 +1,491 @@
+/**
+ * @file src/logging/logger.h
+ * @brief Declares logging configuration and output.
+ */
+#pragma once
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace logging {
+
+ /**
+ * @brief Severity levels used by the Moonlight client logger.
+ */
+ enum class LogLevel {
+ trace = 0, ///< Verbose diagnostic output intended for deep tracing.
+ debug = 1, ///< Debug output for development and troubleshooting.
+ info = 2, ///< Informational output for normal application activity.
+ warning = 3, ///< Recoverable issue or degraded behavior.
+ error = 4, ///< Failure that prevented an operation from completing.
+ none = 5, ///< Sentinel level used to disable output.
+ };
+
+ /**
+ * @brief Local wall-clock timestamp captured for each retained log entry.
+ */
+ struct LogTimestamp {
+ int year = 0; ///< Full calendar year in local time.
+ int month = 0; ///< One-based calendar month in local time.
+ int day = 0; ///< One-based day of month in local time.
+ int hour = 0; ///< Hour component in 24-hour local time.
+ int minute = 0; ///< Minute component in local time.
+ int second = 0; ///< Second component in local time.
+ int millisecond = 0; ///< Millisecond component in local time.
+ };
+
+ /**
+ * @brief Optional source location captured for a structured log entry.
+ */
+ struct LogSourceLocation {
+ const char *file = nullptr; ///< Translation-unit file path where the entry originated.
+ int line = 0; ///< One-based source line number where the entry originated.
+
+ /**
+ * @brief Capture the current call-site source location when the compiler supports it.
+ *
+ * @param currentFile Source file reported by the compiler builtin.
+ * @param currentLine Source line reported by the compiler builtin.
+ * @return A source-location payload for the current call site.
+ */
+ [[nodiscard]] static constexpr LogSourceLocation current(
+#if defined(__clang__) || defined(__GNUC__)
+ const char *currentFile = __builtin_FILE(),
+ int currentLine = __builtin_LINE()
+#else
+ const char *currentFile = nullptr,
+ int currentLine = 0
+#endif
+ ) noexcept {
+ return {currentFile, currentLine};
+ }
+
+ /**
+ * @brief Return whether this source-location payload contains usable data.
+ *
+ * @return true when both the file path and line number are valid.
+ */
+ [[nodiscard]] bool valid() const {
+ return file != nullptr && file[0] != '\0' && line > 0;
+ }
+ };
+
+ /**
+ * @brief Structured log entry stored by the in-memory logger.
+ */
+ struct LogEntry {
+ uint64_t sequence = 0; ///< Monotonic sequence number assigned by the logger.
+ LogLevel level = LogLevel::info; ///< Severity associated with the entry.
+ std::string category; ///< Subsystem category such as ui or network.
+ std::string message; ///< Human-readable log message.
+ LogTimestamp timestamp {}; ///< Local wall-clock timestamp captured for the entry.
+ LogSourceLocation sourceLocation {}; ///< Optional file-and-line source location for the entry.
+ };
+
+ /**
+ * @brief Callback invoked for each accepted log entry.
+ */
+ using LogSink = std::function;
+
+ /**
+ * @brief Callback that supplies timestamps for new log entries.
+ */
+ using TimestampProvider = std::function;
+
+ class Logger;
+
+ namespace detail {
+
+ /**
+ * @brief Process-wide mutable logger state shared by the logging helpers.
+ */
+ struct GlobalLoggingState {
+ inline static Logger *registeredLogger = nullptr; ///< Process-wide logger used by namespace-level helpers.
+ inline static bool startupConsoleEnabled = true; ///< True when startup console output is enabled.
+ };
+
+ } // namespace detail
+
+ /**
+ * @brief Return the display label for a log level.
+ *
+ * @param level The level to stringify.
+ * @return A stable, uppercase label.
+ */
+ const char *to_string(LogLevel level);
+
+ /**
+ * @brief Format a local wall-clock timestamp for log prefixes.
+ *
+ * @param timestamp Local timestamp to format.
+ * @return A stable YYYY-MM-DD HH:MM:SS.mmm timestamp string.
+ */
+ std::string format_timestamp(const LogTimestamp ×tamp);
+
+ /**
+ * @brief Format a source location for text consoles or overlays.
+ *
+ * @param location Source location to format.
+ * @return A normalized file:line string, or an empty string when unavailable.
+ */
+ std::string format_source_location(const LogSourceLocation &location);
+
+ /**
+ * @brief Format a log entry for text consoles or overlays.
+ *
+ * @param entry The entry to format.
+ * @return A formatted log line.
+ */
+ std::string format_entry(const LogEntry &entry);
+
+ /**
+ * @brief Register the process-wide logger used by convenience logging helpers.
+ *
+ * @param logger Logger instance to expose globally, or nullptr to clear it.
+ */
+ void set_global_logger(Logger *logger);
+
+ /**
+ * @brief Return whether a global logger is currently available.
+ *
+ * @return true when the convenience logging helpers can emit entries.
+ */
+ [[nodiscard]] bool has_global_logger();
+
+ /**
+ * @brief Record a structured entry through the registered global logger.
+ *
+ * @param level Severity for the entry.
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Source location for the entry.
+ * @return true if the entry was accepted by the registered logger.
+ */
+ bool log(LogLevel level, std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record a trace entry through the registered global logger.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Source location for the entry.
+ * @return true if the entry was accepted by the registered logger.
+ */
+ bool trace(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record a debug entry through the registered global logger.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Source location for the entry.
+ * @return true if the entry was accepted by the registered logger.
+ */
+ bool debug(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record an info entry through the registered global logger.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Source location for the entry.
+ * @return true if the entry was accepted by the registered logger.
+ */
+ bool info(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record a warning entry through the registered global logger.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Source location for the entry.
+ * @return true if the entry was accepted by the registered logger.
+ */
+ bool warn(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record an error entry through the registered global logger.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Source location for the entry.
+ * @return true if the entry was accepted by the registered logger.
+ */
+ bool error(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Update the retained in-memory minimum level on the registered global logger.
+ *
+ * @param minimumLevel Entries below this level are not kept in memory.
+ */
+ void set_minimum_level(LogLevel minimumLevel);
+
+ /**
+ * @brief Install or replace the runtime file sink on the registered global logger.
+ *
+ * @param sink Callback invoked for entries accepted by the file minimum level.
+ */
+ void set_file_sink(LogSink sink);
+
+ /**
+ * @brief Update the runtime file sink minimum level on the registered global logger.
+ *
+ * @param minimumLevel Entries below this level are not mirrored to the file sink.
+ */
+ void set_file_minimum_level(LogLevel minimumLevel);
+
+ /**
+ * @brief Update the debugger-console minimum level on the registered global logger.
+ *
+ * @param minimumLevel Entries below this level are not mirrored to DbgPrint().
+ */
+ void set_debugger_console_minimum_level(LogLevel minimumLevel);
+
+ /**
+ * @brief Enable or disable startup debug mirroring on the registered global logger.
+ *
+ * @param enabled True to mirror future entries to the pre-splash startup console.
+ */
+ void set_startup_debug_enabled(bool enabled);
+
+ /**
+ * @brief Return a filtered snapshot from the registered global logger.
+ *
+ * @param minimumLevel Minimum level to include in the returned snapshot.
+ * @return Filtered retained entries, or an empty vector when no logger is registered.
+ */
+ [[nodiscard]] std::vector snapshot(LogLevel minimumLevel = LogLevel::trace);
+
+ /**
+ * @brief Format one startup console line without timestamps or source locations.
+ *
+ * @param level Structured log level to display.
+ * @param category Short subsystem category such as startup or sdl.
+ * @param message Human-readable console text.
+ * @return Formatted startup console line without a trailing newline.
+ */
+ [[nodiscard]] std::string format_startup_console_line(LogLevel level, std::string_view category, std::string_view message);
+
+ /**
+ * @brief Enable or disable startup console output.
+ *
+ * @param enabled True to allow future startup console writes.
+ */
+ void set_startup_console_enabled(bool enabled);
+
+ /**
+ * @brief Return whether startup console output is currently enabled.
+ *
+ * @return true when pre-splash console lines should still be emitted.
+ */
+ [[nodiscard]] bool startup_console_enabled();
+
+ /**
+ * @brief Print one startup console line when output is enabled.
+ *
+ * @param level Structured log level to display.
+ * @param category Short subsystem category such as startup or sdl.
+ * @param message Human-readable console text.
+ */
+ void print_startup_console_line(LogLevel level, std::string_view category, std::string_view message);
+
+ /**
+ * @brief Small in-memory logger with a ring buffer and optional sinks.
+ */
+ class Logger {
+ public:
+ /**
+ * @brief Construct a logger with the provided entry capacity.
+ *
+ * @param capacity Maximum number of retained entries.
+ * @param timestampProvider Optional timestamp callback used for new entries.
+ */
+ explicit Logger(std::size_t capacity = 256, TimestampProvider timestampProvider = {});
+
+ /**
+ * @brief Return the maximum number of retained entries.
+ *
+ * @return Maximum number of retained entries.
+ */
+ std::size_t capacity() const;
+
+ /**
+ * @brief Set the minimum retained in-memory log level.
+ *
+ * @param minimumLevel Entries below this level are not stored in the ring buffer.
+ */
+ void set_minimum_level(LogLevel minimumLevel);
+
+ /**
+ * @brief Return the minimum retained in-memory log level.
+ *
+ * @return Minimum retained level.
+ */
+ LogLevel minimum_level() const;
+
+ /**
+ * @brief Enable or disable pre-splash startup output through debugPrint().
+ *
+ * @param enabled True to mirror future log entries to the startup console.
+ */
+ void set_startup_debug_enabled(bool enabled);
+
+ /**
+ * @brief Return whether pre-splash startup output is currently enabled.
+ *
+ * @return true when future log entries are still mirrored to debugPrint().
+ */
+ bool startup_debug_enabled() const;
+
+ /**
+ * @brief Install or replace the runtime file sink callback.
+ *
+ * @param sink Callback invoked for entries accepted by the file minimum level.
+ */
+ void set_file_sink(LogSink sink);
+
+ /**
+ * @brief Set the minimum level written to the configured file sink.
+ *
+ * @param minimumLevel Entries below this level are not written to the file sink.
+ */
+ void set_file_minimum_level(LogLevel minimumLevel);
+
+ /**
+ * @brief Return the minimum level written to the configured file sink.
+ *
+ * @return Minimum accepted level for the file sink.
+ */
+ LogLevel file_minimum_level() const;
+
+ /**
+ * @brief Set the minimum level mirrored through DbgPrint() for xemu.
+ *
+ * @param minimumLevel Entries below this level are not written to the debugger console.
+ */
+ void set_debugger_console_minimum_level(LogLevel minimumLevel);
+
+ /**
+ * @brief Return the minimum level mirrored through DbgPrint() for xemu.
+ *
+ * @return Minimum accepted level for debugger-console output.
+ */
+ LogLevel debugger_console_minimum_level() const;
+
+ /**
+ * @brief Return whether a log level would be recorded by any enabled sink.
+ *
+ * @param level The candidate level.
+ * @return true if the entry would be stored or dispatched.
+ */
+ bool should_log(LogLevel level) const;
+
+ /**
+ * @brief Record a structured entry.
+ *
+ * @param level Severity for the entry.
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Optional source location for the entry.
+ * @return true if the entry was accepted.
+ */
+ bool log(LogLevel level, std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record a trace entry.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Optional source location for the entry.
+ * @return true if the entry was accepted.
+ */
+ bool trace(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record a debug entry.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Optional source location for the entry.
+ * @return true if the entry was accepted.
+ */
+ bool debug(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record an info entry.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Optional source location for the entry.
+ * @return true if the entry was accepted.
+ */
+ bool info(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record a warning entry.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Optional source location for the entry.
+ * @return true if the entry was accepted.
+ */
+ bool warn(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Record an error entry.
+ *
+ * @param category Subsystem name such as ui or streaming.
+ * @param message User-visible message text.
+ * @param location Optional source location for the entry.
+ * @return true if the entry was accepted.
+ */
+ bool error(std::string category, std::string message, LogSourceLocation location = LogSourceLocation::current());
+
+ /**
+ * @brief Register an observer that receives accepted entries.
+ *
+ * @param sink Callback invoked synchronously during logging.
+ * @param minimumLevel Entries below this level are not dispatched to the sink.
+ */
+ void add_sink(LogSink sink, LogLevel minimumLevel = LogLevel::trace);
+
+ /**
+ * @brief Return the retained entries.
+ *
+ * @return Immutable view of the retained ring-buffer contents.
+ */
+ const std::deque &entries() const;
+
+ /**
+ * @brief Copy retained entries at or above the requested level.
+ *
+ * @param minimumLevel Minimum level to include in the snapshot.
+ * @return Filtered log entries in insertion order.
+ */
+ std::vector snapshot(LogLevel minimumLevel = LogLevel::trace) const;
+
+ private:
+ struct RegisteredSink {
+ LogLevel minimumLevel = LogLevel::trace; ///< Minimum accepted level for the sink.
+ LogSink sink; ///< Callback invoked for matching entries.
+ };
+
+ std::size_t capacity_;
+ LogLevel minimumLevel_ = LogLevel::none;
+ bool startupDebugEnabled_ = true;
+ LogSink fileSink_;
+ LogLevel fileMinimumLevel_ = LogLevel::none;
+ LogLevel debuggerConsoleMinimumLevel_ = LogLevel::none;
+ uint64_t nextSequence_ = 1;
+ TimestampProvider timestampProvider_;
+ std::deque entries_;
+ std::vector sinks_;
+ };
+
+} // namespace logging
diff --git a/src/main.cpp b/src/main.cpp
index b9adef7..0a55582 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -1,24 +1,261 @@
+/**
+ * @file src/main.cpp
+ * @brief Runs the Moonlight Xbox startup sequence and main loop.
+ */
// nxdk includes
-#include
+#include
+#include
+#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
// local includes
+#include "src/app/client_state.h"
+#include "src/app/settings_storage.h"
+#include "src/logging/log_file.h"
+#include "src/logging/logger.h"
+#include "src/network/runtime_network.h"
#include "src/splash/splash_screen.h"
+#include "src/startup/host_storage.h"
#include "src/startup/memory_stats.h"
#include "src/startup/video_mode.h"
+#include "src/ui/shell_screen.h"
+
+namespace {
+
+ struct StartupTaskState {
+ SDL_Thread *thread = nullptr;
+ std::atomic completed = false;
+ startup::LoadSavedHostsResult loadedHosts;
+ network::RuntimeNetworkStatus runtimeNetworkStatus;
+ };
+
+ void apply_persisted_settings(app::ClientState &state, const app::AppSettings &settings) {
+ state.settings.loggingLevel = settings.loggingLevel;
+ state.settings.xemuConsoleLoggingLevel = settings.xemuConsoleLoggingLevel;
+ state.settings.logViewerPlacement = settings.logViewerPlacement;
+ state.settings.dirty = false;
+ }
+
+ void load_persisted_settings(app::ClientState &state) {
+ const app::LoadAppSettingsResult loadResult = app::load_app_settings();
+ apply_persisted_settings(state, loadResult.settings);
+
+ for (const std::string &warning : loadResult.warnings) {
+ logging::warn("settings", warning);
+ }
+
+ if (!loadResult.fileFound) {
+ logging::info("settings", "No persisted settings file found. Using defaults.");
+ return;
+ }
+
+ logging::info("settings", "Loaded persisted Moonlight settings");
+ if (!loadResult.cleanupRequired) {
+ return;
+ }
+
+ const app::SaveAppSettingsResult saveResult = app::save_app_settings(loadResult.settings);
+ if (saveResult.success) {
+ logging::info("settings", "Removed obsolete settings keys from the persisted configuration");
+ return;
+ }
+
+ logging::warn("settings", saveResult.errorMessage.empty() ? "Failed to rewrite the settings file after cleaning obsolete keys" : saveResult.errorMessage);
+ }
+
+ int report_startup_failure(const char *category, const std::string &message) {
+ logging::error(category, message);
+ logging::print_startup_console_line(logging::LogLevel::warning, category, "Holding failure screen for 5 seconds before exit.");
+ Sleep(5000);
+ return 1;
+ }
+
+ void debug_print_startup_checkpoint(const char *message) {
+ if (message == nullptr) {
+ return;
+ }
+
+ logging::print_startup_console_line(logging::LogLevel::info, "startup", message);
+ }
+
+ void debug_print_video_mode_selection(const startup::VideoModeSelection &selection) {
+ logging::print_startup_console_line(
+ logging::LogLevel::info,
+ "video",
+ "Detected " + std::to_string(selection.availableVideoModes.size()) + " video mode(s)"
+ );
+ for (const std::string &line : startup::format_video_mode_lines(selection)) {
+ logging::print_startup_console_line(logging::LogLevel::info, "video", line);
+ }
+ }
+
+ void debug_print_encoder_settings(DWORD encoderSettings) {
+ std::array messageBuffer {};
+ std::snprintf(
+ messageBuffer.data(),
+ messageBuffer.size(),
+ "Encoder settings: 0x%08lX (widescreen=%s, 480p=%s, 720p=%s, 1080i=%s)",
+ encoderSettings,
+ (encoderSettings & VIDEO_WIDESCREEN) != 0 ? "on" : "off",
+ (encoderSettings & VIDEO_MODE_480P) != 0 ? "on" : "off",
+ (encoderSettings & VIDEO_MODE_720P) != 0 ? "on" : "off",
+ (encoderSettings & VIDEO_MODE_1080I) != 0 ? "on" : "off"
+ );
+ logging::print_startup_console_line(logging::LogLevel::info, "video", messageBuffer.data());
+ }
+
+ int run_startup_task(void *context) {
+ auto *task = static_cast(context);
+ if (task == nullptr) {
+ return -1;
+ }
+
+ task->loadedHosts = startup::load_saved_hosts();
+ task->runtimeNetworkStatus = network::initialize_runtime_networking();
+ task->completed.store(true);
+ return 0;
+ }
+ void finish_startup_task(app::ClientState &clientState, StartupTaskState *task) {
+ if (task == nullptr) {
+ return;
+ }
+
+ if (task->thread != nullptr) {
+ int threadResult = 0;
+ SDL_WaitThread(task->thread, &threadResult);
+ (void) threadResult;
+ task->thread = nullptr;
+ }
+
+ for (const std::string &warning : task->loadedHosts.warnings) {
+ logging::warn("hosts", warning);
+ }
+ if (task->loadedHosts.fileFound) {
+ app::replace_hosts(clientState, task->loadedHosts.hosts, "Loaded " + std::to_string(task->loadedHosts.hosts.size()) + " saved host(s)");
+ logging::info("hosts", "Loaded " + std::to_string(task->loadedHosts.hosts.size()) + " saved host record(s)");
+ }
+
+ for (const std::string &line : network::format_runtime_network_status_lines(task->runtimeNetworkStatus)) {
+ logging::log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line);
+ }
+ if (!task->runtimeNetworkStatus.ready) {
+ clientState.shell.statusMessage = task->runtimeNetworkStatus.summary;
+ }
+ }
+
+} // namespace
+
+/**
+ * @brief Initialize the client runtime and enter the main shell loop.
+ *
+ * @return Process exit code.
+ */
int main() {
+ logging::Logger logger;
+ logging::set_global_logger(&logger);
+ logging::set_minimum_level(logging::LogLevel::trace);
+
+ app::ClientState clientState = app::create_initial_state();
+ load_persisted_settings(clientState);
+ logging::set_file_minimum_level(clientState.settings.loggingLevel);
+ logging::set_debugger_console_minimum_level(clientState.settings.xemuConsoleLoggingLevel);
+
+ const std::string logFilePath = logging::default_log_file_path();
+ logging::RuntimeLogFileSink runtimeLogFile(logFilePath);
+ app::set_log_file_path(clientState, logFilePath);
+
+ if (std::string logFileResetError; !runtimeLogFile.reset(&logFileResetError)) {
+ logging::print_startup_console_line(
+ logging::LogLevel::warning,
+ "logging",
+ logFileResetError.empty() ? "Failed to reset the runtime log file." : logFileResetError
+ );
+ }
+ logging::set_file_sink([&runtimeLogFile](const logging::LogEntry &entry) {
+ std::string ignoredError;
+ runtimeLogFile.consume(entry, &ignoredError);
+ });
+
+ logging::info("app", std::string("Initial screen: ") + app::to_string(clientState.shell.activeScreen));
+ debug_print_startup_checkpoint("Runtime logging initialized");
+ debug_print_encoder_settings(XVideoGetEncoderSettings());
+
const startup::VideoModeSelection videoModeSelection = startup::select_best_video_mode();
const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode;
+ debug_print_video_mode_selection(videoModeSelection);
+ startup::log_memory_statistics();
+
+ debug_print_startup_checkpoint(
+ (std::string("About to call XVideoSetMode with ") + std::to_string(bestVideoMode.width) + "x" + std::to_string(bestVideoMode.height) + ", bpp=" + std::to_string(bestVideoMode.bpp) + ", refresh=" + std::to_string(bestVideoMode.refresh)).c_str()
+ );
+
+ const BOOL setVideoModeResult = XVideoSetMode(bestVideoMode.width, bestVideoMode.height, bestVideoMode.bpp, bestVideoMode.refresh);
+ debug_print_startup_checkpoint(setVideoModeResult ? "Returned from XVideoSetMode successfully" : "XVideoSetMode returned failure");
- XVideoSetMode(640, 480, 32, REFRESH_DEFAULT);
+ debug_print_startup_checkpoint("About to call SDL_Init");
+ if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) {
+ return report_startup_failure("sdl", std::string("SDL_Init failed: ") + SDL_GetError());
+ }
+ debug_print_startup_checkpoint("SDL_Init succeeded");
+ debug_print_startup_checkpoint("About to create SDL window");
+ SDL_Window *window = SDL_CreateWindow(
+ "Moonlight Xbox",
+ SDL_WINDOWPOS_UNDEFINED,
+ SDL_WINDOWPOS_UNDEFINED,
+ bestVideoMode.width,
+ bestVideoMode.height,
+ SDL_WINDOW_SHOWN
+ );
+ if (window == nullptr) {
+ const int exitCode = report_startup_failure("sdl", std::string("SDL_CreateWindow failed: ") + SDL_GetError());
+ SDL_Quit();
+ return exitCode;
+ }
+ debug_print_startup_checkpoint("SDL window creation succeeded");
+
+ StartupTaskState startupTask {};
+ debug_print_startup_checkpoint("Starting background startup task");
+ startupTask.thread = SDL_CreateThread(run_startup_task, "startup-init", &startupTask);
+ if (startupTask.thread == nullptr) {
+ debug_print_startup_checkpoint("SDL_CreateThread failed; running startup task synchronously");
+ run_startup_task(&startupTask);
+ } else {
+ debug_print_startup_checkpoint("Background startup task created");
+ }
+
+ logging::info("app", "Showing splash screen");
+ debug_print_startup_checkpoint("About to show splash screen");
+ logging::set_startup_debug_enabled(false);
+ logging::set_startup_console_enabled(false);
+ splash::show_splash_screen(window, bestVideoMode, [&startupTask]() {
+ return !startupTask.completed.load();
+ });
+
+ finish_startup_task(clientState, &startupTask);
startup::log_video_modes(videoModeSelection);
- startup::log_memory_statistics();
- Sleep(4000);
+ logging::info("app", "Starting interactive shell");
+ const int exitCode = ui::run_shell(window, bestVideoMode, clientState);
- XVideoSetMode(bestVideoMode.width, bestVideoMode.height, bestVideoMode.bpp, bestVideoMode.refresh);
+ if (clientState.hosts.dirty) {
+ const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(clientState.hosts.items);
+ if (saveResult.success) {
+ logging::info("hosts", "Saved host records before exit");
+ clientState.hosts.dirty = false;
+ } else {
+ logging::error("hosts", saveResult.errorMessage);
+ }
+ }
- splash::show_splash_screen(bestVideoMode);
- return 0;
+ SDL_DestroyWindow(window);
+ SDL_Quit();
+ return exitCode;
}
diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp
new file mode 100644
index 0000000..7e06a2d
--- /dev/null
+++ b/src/network/host_pairing.cpp
@@ -0,0 +1,2665 @@
+/**
+ * @file src/network/host_pairing.cpp
+ * @brief Implements host pairing helpers.
+ */
+// class header include
+#include "src/network/host_pairing.h"
+
+// standard includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// lib includes
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+// local includes
+#include "src/network/runtime_network.h"
+#include "src/platform/error_utils.h"
+
+// platform includes
+#ifdef NXDK
+ #include
+ #include
+ #include
+ #include
+#elif defined(_WIN32)
+// clang-format off
+ // winsock2 must be included before windows.h
+ #include
+ #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names
+// clang-format on
+#else
+ #include
+ #include
+ #include
+ #include
+ #include
+ #include
+ #include
+#endif
+
+#if defined(NXDK) || !defined(_WIN32)
+using SOCKET = int; ///< Native socket handle type used on nxdk and POSIX builds.
+
+ #ifndef INVALID_SOCKET
+ /**
+ * @brief Sentinel socket value used when socket creation fails.
+ */
+ #define INVALID_SOCKET (-1)
+ #endif
+
+ #ifndef SOCKET_ERROR
+ /**
+ * @brief Sentinel return value used when a socket operation fails.
+ */
+ #define SOCKET_ERROR (-1)
+ #endif
+#endif
+
+/**
+ * @brief Suppress deprecated OpenSSL APIs used for compatibility with bundled dependencies.
+ */
+#define OPENSSL_SUPPRESS_DEPRECATED
+
+#ifdef NXDK
+ /**
+ * @brief Request the rand_s prototype on nxdk builds.
+ */
+ #define _CRT_RAND_S
+#endif
+
+#ifdef NXDK
+/**
+ * @brief Fill a buffer with secure random bytes using the nxdk compatibility entry point.
+ *
+ * @param randomValue Output integer populated with secure random bits.
+ * @return Zero on success, or a non-zero platform error code on failure.
+ */
+extern "C" int rand_s(unsigned int *randomValue);
+#endif
+
+namespace {
+
+ using platform::append_error;
+
+ void trace_pairing_phase(const char *message) {
+ (void) message;
+ }
+
+ void trace_pairing_detail(const std::string &message) {
+ (void) message;
+ }
+
+ constexpr std::size_t UNIQUE_ID_BYTE_COUNT = 8;
+ constexpr std::size_t CLIENT_CHALLENGE_BYTE_COUNT = 16;
+ constexpr std::size_t CLIENT_SECRET_BYTE_COUNT = 16;
+ constexpr int SOCKET_TIMEOUT_MILLISECONDS = 5000;
+ constexpr uint16_t DEFAULT_SERVERINFO_HTTP_PORT = 47989;
+ constexpr uint16_t FALLBACK_SERVERINFO_HTTP_PORT = 47984;
+ constexpr std::string_view DEFAULT_SERVERINFO_UNIQUE_ID = "0123456789ABCDEF";
+ constexpr std::string_view DEFAULT_SERVERINFO_UUID = "11111111-2222-3333-4444-555555555555";
+ constexpr std::string_view UNPAIRED_CLIENT_ERROR_MESSAGE = "The host reports that this client is no longer paired. Pair the host again.";
+
+ network::testing::HostPairingHttpTestHandler &host_pairing_http_test_handler() {
+ static network::testing::HostPairingHttpTestHandler handler; ///< Optional scripted transport used by host-native unit tests.
+ return handler;
+ }
+
+ struct WsaGuard {
+ WsaGuard():
+ initialized(initialize()) {
+ }
+
+ static bool initialize() {
+#if defined(NXDK) || !defined(_WIN32)
+ return true;
+#else
+ WSADATA wsaData {};
+ return WSAStartup(MAKEWORD(2, 2), &wsaData) == 0;
+#endif
+ }
+
+ ~WsaGuard() {
+#if defined(_WIN32) && !defined(NXDK)
+ if (initialized) {
+ WSACleanup();
+ }
+#endif
+ }
+
+ bool initialized = false;
+ };
+
+ struct SocketGuard {
+ SocketGuard() = default;
+ SocketGuard(const SocketGuard &) = delete;
+ SocketGuard &operator=(const SocketGuard &) = delete;
+ SocketGuard(SocketGuard &&) = delete;
+ SocketGuard &operator=(SocketGuard &&) = delete;
+
+ ~SocketGuard() {
+ if (handle != INVALID_SOCKET) {
+#if defined(_WIN32) && !defined(NXDK)
+ closesocket(handle);
+#else
+ close(handle);
+#endif
+ }
+ }
+
+ SOCKET handle = INVALID_SOCKET;
+ };
+
+ bool pairing_cancel_requested(const std::atomic *cancelRequested) {
+ return cancelRequested != nullptr && cancelRequested->load(std::memory_order_acquire);
+ }
+
+ bool append_cancelled_pairing_error(std::string *errorMessage) {
+ return append_error(errorMessage, "Pairing cancelled");
+ }
+
+ void append_hash_bytes(uint64_t *hash, const std::byte *bytes, std::size_t byteCount) {
+ if (hash == nullptr || bytes == nullptr) {
+ return;
+ }
+
+ for (std::size_t index = 0; index < byteCount; ++index) {
+ *hash ^= static_cast(std::to_integer(bytes[index]));
+ *hash *= 1099511628211ULL;
+ }
+ }
+
+ void append_hash_string(uint64_t *hash, std::string_view text) {
+ append_hash_bytes(hash, reinterpret_cast(text.data()), text.size());
+ static constexpr std::byte delimiter {0x1F};
+ append_hash_bytes(hash, &delimiter, 1U);
+ }
+
+ int last_socket_error() {
+#if defined(NXDK) || !defined(_WIN32)
+ return errno;
+#else
+ return WSAGetLastError();
+#endif
+ }
+
+ bool is_connect_in_progress_error(int errorCode) {
+#if defined(NXDK) || !defined(_WIN32)
+ return errorCode == EWOULDBLOCK || errorCode == EINPROGRESS || errorCode == EALREADY;
+#else
+ return errorCode == WSAEWOULDBLOCK || errorCode == WSAEINPROGRESS || errorCode == WSAEALREADY;
+#endif
+ }
+
+ bool is_timeout_error(int errorCode) {
+#if defined(NXDK) || !defined(_WIN32)
+ return errorCode == ETIMEDOUT;
+#else
+ return errorCode == WSAETIMEDOUT;
+#endif
+ }
+
+ bool set_socket_non_blocking(SOCKET socketHandle, bool enabled, std::string *errorMessage) {
+#ifdef NXDK
+ int nonBlockingMode = enabled ? 1 : 0;
+#elif defined(_WIN32)
+ u_long nonBlockingMode = enabled ? 1UL : 0UL;
+#endif
+
+#if defined(NXDK) || defined(_WIN32)
+ if (ioctlsocket(socketHandle, FIONBIO, &nonBlockingMode) != 0) { // NOSONAR(cpp:S6004) cannot init variable inside if statement due to macros
+ return append_error(errorMessage, std::string("Failed to configure the host pairing socket mode (socket error ") + std::to_string(last_socket_error()) + ")");
+ }
+#else
+ const int currentFlags = fcntl(socketHandle, F_GETFL, 0);
+ if (currentFlags < 0) {
+ return append_error(errorMessage, std::string("Failed to query the host pairing socket mode (socket error ") + std::to_string(last_socket_error()) + ")");
+ }
+
+ const int updatedFlags = enabled ? (currentFlags | O_NONBLOCK) : (currentFlags & ~O_NONBLOCK);
+ if (fcntl(socketHandle, F_SETFL, updatedFlags) != 0) {
+ return append_error(errorMessage, std::string("Failed to configure the host pairing socket mode (socket error ") + std::to_string(last_socket_error()) + ")");
+ }
+#endif
+
+ return true;
+ }
+
+ void set_socket_timeouts(SOCKET socketHandle) {
+#if defined(NXDK) || !defined(_WIN32)
+ timeval timeout {
+ SOCKET_TIMEOUT_MILLISECONDS / 1000,
+ (SOCKET_TIMEOUT_MILLISECONDS % 1000) * 1000,
+ };
+ setsockopt(socketHandle, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeout), sizeof(timeout));
+ setsockopt(socketHandle, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&timeout), sizeof(timeout));
+#else
+ const DWORD timeoutMilliseconds = SOCKET_TIMEOUT_MILLISECONDS;
+ setsockopt(socketHandle, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast