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. -![Splash Screen](./docs/images/loading.png) +![Splash Screen](./docs/images/screenshots/01-splash.png) +![Hosts Screen](./docs/images/screenshots/02-hosts.png) +![Apps Screen](./docs/images/screenshots/03-apps.png) ## 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(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); + setsockopt(socketHandle, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); +#endif + } + + struct SslCtxDeleter { + void operator()(SSL_CTX *context) const { + if (context != nullptr) { + SSL_CTX_free(context); + } + } + }; + + struct SslDeleter { + void operator()(SSL *ssl) const { + if (ssl != nullptr) { + SSL_free(ssl); + } + } + }; + + struct BioDeleter { + void operator()(BIO *bio) const { + if (bio != nullptr) { + BIO_free(bio); + } + } + }; + + struct X509Deleter { + void operator()(X509 *certificate) const { + if (certificate != nullptr) { + X509_free(certificate); + } + } + }; + + struct PkeyDeleter { + void operator()(EVP_PKEY *key) const { + if (key != nullptr) { + EVP_PKEY_free(key); + } + } + }; + + struct RsaDeleter { + void operator()(RSA *rsa) const { + if (rsa != nullptr) { + RSA_free(rsa); + } + } + }; + + struct BignumDeleter { + void operator()(BIGNUM *value) const { + if (value != nullptr) { + BN_free(value); + } + } + }; + + struct MdCtxDeleter { + void operator()(EVP_MD_CTX *context) const { + if (context != nullptr) { + EVP_MD_CTX_free(context); + } + } + }; + + struct CipherCtxDeleter { + void operator()(EVP_CIPHER_CTX *context) const { + if (context != nullptr) { + EVP_CIPHER_CTX_free(context); + } + } + }; + + struct HttpResponse { + int statusCode; + std::string body; + }; + + bool hex_value(char character, unsigned char *value); + bool http_get( + const std::string &address, + uint16_t port, + std::string_view pathAndQuery, + bool useTls, + const network::PairingIdentity *tlsClientIdentity, + std::string_view expectedTlsCertificatePem, + HttpResponse *response, + std::string *errorMessage, + const std::atomic *cancelRequested = nullptr + ); + + std::string summarize_http_payload_preview(std::string_view text) { + constexpr std::size_t MAX_PREVIEW_BYTES = 48; + + if (text.empty()) { + return ""; + } + + std::string preview; + const std::size_t previewLength = std::min(text.size(), MAX_PREVIEW_BYTES); + for (std::size_t index = 0; index < previewLength; ++index) { + const auto character = static_cast(text[index]); + if (character >= 0x20U && character <= 0x7EU) { + preview.push_back(static_cast(character)); + } else if (character == '\r') { + preview += "\\r"; + } else if (character == '\n') { + preview += "\\n"; + } else if (character == '\t') { + preview += "\\t"; + } else { + std::array buffer {}; + std::snprintf(buffer.data(), buffer.size(), "\\x%02X", character); + preview += buffer.data(); + } + } + + if (text.size() > previewLength) { + preview += "..."; + } + + return preview; + } + + void append_unique_port(std::vector *ports, uint16_t port) { + if (ports == nullptr || port == 0) { + return; + } + + if (std::find(ports->begin(), ports->end(), port) == ports->end()) { + ports->push_back(port); + } + } + + std::vector build_serverinfo_port_candidates(uint16_t preferredHttpPort) { + std::vector ports; + append_unique_port(&ports, preferredHttpPort == 0 ? DEFAULT_SERVERINFO_HTTP_PORT : preferredHttpPort); + append_unique_port(&ports, DEFAULT_SERVERINFO_HTTP_PORT); + append_unique_port(&ports, FALLBACK_SERVERINFO_HTTP_PORT); + return ports; + } + + std::string build_serverinfo_path(std::string_view uniqueId) { + const std::string_view resolvedUniqueId = uniqueId.empty() ? DEFAULT_SERVERINFO_UNIQUE_ID : uniqueId; + return "/serverinfo?uniqueid=" + std::string(resolvedUniqueId) + "&uuid=" + std::string(DEFAULT_SERVERINFO_UUID); + } + + std::string build_applist_path(std::string_view uniqueId) { + const std::string_view resolvedUniqueId = uniqueId.empty() ? DEFAULT_SERVERINFO_UNIQUE_ID : uniqueId; + return "/applist?uniqueid=" + std::string(resolvedUniqueId) + "&uuid=" + std::string(DEFAULT_SERVERINFO_UUID); + } + + std::string_view resolve_client_unique_id(const network::PairingIdentity *clientIdentity) { + if (clientIdentity == nullptr || clientIdentity->uniqueId.empty()) { + return DEFAULT_SERVERINFO_UNIQUE_ID; + } + + return clientIdentity->uniqueId; + } + + std::vector build_app_asset_paths(std::string_view uniqueId, int appId) { + const std::string_view resolvedUniqueId = uniqueId.empty() ? DEFAULT_SERVERINFO_UNIQUE_ID : uniqueId; + const std::string appIdText = std::to_string(appId); + const std::string queryPrefix = "?uniqueid=" + std::string(resolvedUniqueId) + "&uuid=" + std::string(DEFAULT_SERVERINFO_UUID); + return { + "/appasset" + queryPrefix + "&appid=" + appIdText + "&AssetType=2&AssetIdx=0", + "/appasset" + queryPrefix + "&appid=" + appIdText + "&AssetType=2", + "/appasset" + queryPrefix + "&appId=" + appIdText + "&AssetType=2&AssetIdx=0", + "/appasset" + queryPrefix + "&appid=" + appIdText, + "/appasset?appid=" + appIdText + "&AssetType=2&AssetIdx=0", + "/appasset?appId=" + appIdText + "&AssetType=2&AssetIdx=0", + }; + } + + std::string_view trim_ascii_whitespace(std::string_view text) { + while (!text.empty() && (text.front() == ' ' || text.front() == '\t' || text.front() == '\r' || text.front() == '\n')) { + text.remove_prefix(1); + } + while (!text.empty() && (text.back() == ' ' || text.back() == '\t' || text.back() == '\r' || text.back() == '\n')) { + text.remove_suffix(1); + } + return text; + } + + bool ascii_iequals(std::string_view left, std::string_view right) { + if (left.size() != right.size()) { + return false; + } + + for (std::size_t index = 0; index < left.size(); ++index) { + if (std::tolower(static_cast(left[index])) != std::tolower(static_cast(right[index]))) { + return false; + } + } + + return true; + } + + bool header_value_contains_token(std::string_view value, std::string_view token) { + std::size_t start = 0; + while (start < value.size()) { + const std::size_t end = value.find(',', start); + if (const std::string_view item = trim_ascii_whitespace(value.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start)); ascii_iequals(item, token)) { + return true; + } + + if (end == std::string_view::npos) { + break; + } + start = end + 1; + } + + return false; + } + + bool try_parse_decimal_size(std::string_view text, std::size_t *value) { + std::size_t parsedValue = 0; + if (text.empty()) { + return false; + } + + for (char character : text) { + if (character < '0' || character > '9') { + return false; + } + parsedValue = (parsedValue * 10U) + static_cast(character - '0'); + } + + if (value != nullptr) { + *value = parsedValue; + } + return true; + } + + bool try_parse_hex_size(std::string_view text, std::size_t *value) { + std::size_t parsedValue = 0; + if (text.empty()) { + return false; + } + + for (char character : text) { + unsigned char digit = 0; + if (!hex_value(character, &digit)) { + return false; + } + parsedValue = (parsedValue * 16U) + digit; + } + + if (value != nullptr) { + *value = parsedValue; + } + return true; + } + + bool try_parse_chunked_message_length(std::string_view responseText, std::size_t bodyStart, std::size_t *messageLength, std::string *errorMessage) { + std::size_t cursor = bodyStart; + while (true) { + const std::size_t chunkLineEnd = responseText.find("\r\n", cursor); + if (chunkLineEnd == std::string_view::npos) { + return false; + } + + std::string_view chunkSizeText = responseText.substr(cursor, chunkLineEnd - cursor); + if (const std::size_t chunkExtensionSeparator = chunkSizeText.find(';'); chunkExtensionSeparator != std::string_view::npos) { + chunkSizeText = chunkSizeText.substr(0, chunkExtensionSeparator); + } + chunkSizeText = trim_ascii_whitespace(chunkSizeText); + + std::size_t chunkSize = 0; + if (!try_parse_hex_size(chunkSizeText, &chunkSize)) { + return append_error(errorMessage, "Received an invalid chunked HTTP response while pairing"); + } + + const std::size_t chunkDataStart = chunkLineEnd + 2; + const std::size_t chunkDataEnd = chunkDataStart + chunkSize; + if (responseText.size() < chunkDataEnd + 2) { + return false; + } + if (responseText.substr(chunkDataEnd, 2) != "\r\n") { + return append_error(errorMessage, "Received a malformed chunked HTTP response while pairing"); + } + + cursor = chunkDataEnd + 2; + if (chunkSize == 0U) { + if (messageLength != nullptr) { + *messageLength = cursor; + } + return true; + } + } + } + + bool apply_http_message_length_header( + std::string_view headerName, + std::string_view headerValue, + bool *hasContentLength, + std::size_t *contentLength, + bool *isChunked, + std::string *errorMessage + ) { + if (ascii_iequals(headerName, "Content-Length")) { + std::size_t parsedContentLength = 0; + if (!try_parse_decimal_size(headerValue, &parsedContentLength)) { + return append_error(errorMessage, "Received an invalid Content-Length header while pairing"); + } + if (hasContentLength != nullptr) { + *hasContentLength = true; + } + if (contentLength != nullptr) { + *contentLength = parsedContentLength; + } + return true; + } + + if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked") && isChunked != nullptr) { + *isChunked = true; + } + return true; + } + + bool try_get_http_response_length(std::string_view responseText, std::size_t *messageLength, std::string *errorMessage) { + const std::size_t headerTerminator = responseText.find("\r\n\r\n"); + if (headerTerminator == std::string_view::npos) { + return false; + } + + bool hasContentLength = false; + bool isChunked = false; + std::size_t contentLength = 0; + + const std::size_t statusLineEnd = responseText.find("\r\n"); + std::size_t lineStart = statusLineEnd == std::string_view::npos ? headerTerminator : statusLineEnd + 2; + while (lineStart < headerTerminator) { + const std::size_t lineEnd = responseText.find("\r\n", lineStart); + if (lineEnd == std::string_view::npos || lineEnd > headerTerminator) { + break; + } + + const std::string_view headerLine = responseText.substr(lineStart, lineEnd - lineStart); + if (const std::size_t separator = headerLine.find(':'); separator != std::string_view::npos) { + const std::string_view headerName = trim_ascii_whitespace(headerLine.substr(0, separator)); + const std::string_view headerValue = trim_ascii_whitespace(headerLine.substr(separator + 1)); + + if (!apply_http_message_length_header(headerName, headerValue, &hasContentLength, &contentLength, &isChunked, errorMessage)) { + return false; + } + } + + lineStart = lineEnd + 2; + } + + const std::size_t bodyStart = headerTerminator + 4; + if (isChunked) { + return try_parse_chunked_message_length(responseText, bodyStart, messageLength, errorMessage); + } + if (hasContentLength) { + const std::size_t completeLength = bodyStart + contentLength; + if (responseText.size() < completeLength) { + return false; + } + if (messageLength != nullptr) { + *messageLength = completeLength; + } + return true; + } + + return false; + } + + std::string take_openssl_error_queue() { + std::string details; + unsigned long errorCode = 0; + while ((errorCode = ERR_get_error()) != 0) { + std::array errorBuffer {}; + ERR_error_string_n(errorCode, errorBuffer.data(), errorBuffer.size()); + if (!details.empty()) { + details += "; "; + } + details += errorBuffer.data(); + } + return details; + } + + bool append_openssl_error(std::string *errorMessage, std::string message) { + if (const std::string details = take_openssl_error_queue(); !details.empty()) { + message += ": " + details; + } + return append_error(errorMessage, std::move(message)); + } + + void initialize_openssl() { + static bool initialized = false; + if (!initialized) { + SSL_library_init(); + SSL_load_error_strings(); + OpenSSL_add_all_algorithms(); + initialized = true; + } + } + +#ifdef NXDK + int nxdk_rand_seed(const void *, int) { // NOSONAR(cpp:S5008) signature required by OpenSSL RAND_METHOD + return 1; + } + + int nxdk_rand_bytes(unsigned char *buffer, int size) { + if (buffer == nullptr || size < 0) { + return 0; + } + + int offset = 0; + while (offset < size) { + unsigned int randomWord = 0; + if (::rand_s(&randomWord) != 0) { + return 0; + } + + const int chunkSize = std::min(static_cast(sizeof(randomWord)), size - offset); + std::memcpy(buffer + offset, &randomWord, static_cast(chunkSize)); + offset += chunkSize; + } + + return 1; + } + + void nxdk_rand_cleanup() { + // intentionally empty - no cleanup required for nxdk random method + } + + int nxdk_rand_add(const void *, int, double) { // NOSONAR(cpp:S5008) signature required by OpenSSL RAND_METHOD + return 1; + } + + int nxdk_rand_status() { + return 1; + } + + const RAND_METHOD g_nxdk_rand_method = { + &nxdk_rand_seed, + &nxdk_rand_bytes, + &nxdk_rand_cleanup, + &nxdk_rand_add, + &nxdk_rand_bytes, + &nxdk_rand_status, + }; + + bool ensure_nxdk_rand_method(std::string *errorMessage) { + static bool configured = false; + if (configured) { + return true; + } + + ERR_clear_error(); + if (RAND_set_rand_method(&g_nxdk_rand_method) != 1) { + return append_openssl_error(errorMessage, "Failed to install the Xbox OpenSSL random source"); + } + + configured = true; + take_openssl_error_queue(); + return true; + } +#endif + + bool ensure_pairing_entropy(std::string *errorMessage) { + initialize_openssl(); + +#ifdef NXDK + return ensure_nxdk_rand_method(errorMessage); +#else + + if (RAND_status() == 1) { + return true; + } + + ERR_clear_error(); + RAND_poll(); + if (RAND_status() == 1) { + take_openssl_error_queue(); + return true; + } + + return append_openssl_error(errorMessage, "OpenSSL could not gather enough entropy for pairing"); +#endif + } + + bool fill_random_bytes(unsigned char *buffer, std::size_t size, std::string *errorMessage) { + if (!ensure_pairing_entropy(errorMessage)) { + return false; + } + + ERR_clear_error(); + if (RAND_bytes(buffer, static_cast(size)) == 1) { + return true; + } + + return append_openssl_error(errorMessage, "Failed to generate secure random bytes for pairing"); + } + + std::string hex_encode(const unsigned char *data, std::size_t size) { + static constexpr char HEX_DIGITS[] = "0123456789abcdef"; + + std::string output; + output.resize(size * 2); + for (std::size_t index = 0; index < size; ++index) { + output[index * 2] = HEX_DIGITS[(data[index] >> 4) & 0x0F]; // NOSONAR(cpp:S6022) hex encoding is byte-oriented by design + output[(index * 2) + 1] = HEX_DIGITS[data[index] & 0x0F]; // NOSONAR(cpp:S6022) hex encoding is byte-oriented by design + } + + return output; + } + + bool hex_value(char character, unsigned char *value) { + if (character >= '0' && character <= '9') { + *value = static_cast(character - '0'); + return true; + } + if (character >= 'a' && character <= 'f') { + *value = static_cast(10 + (character - 'a')); + return true; + } + if (character >= 'A' && character <= 'F') { + *value = static_cast(10 + (character - 'A')); + return true; + } + + return false; + } + + bool hex_decode(std::string_view text, std::vector *bytes, std::string *errorMessage) { + if (text.size() % 2 != 0) { + return append_error(errorMessage, "Expected an even number of hexadecimal characters"); + } + + std::vector decoded; + decoded.reserve(text.size() / 2); + for (std::size_t index = 0; index < text.size(); index += 2) { + unsigned char upper = 0; + unsigned char lower = 0; + if (!hex_value(text[index], &upper) || !hex_value(text[index + 1], &lower)) { + return append_error(errorMessage, "Encountered invalid hexadecimal data during pairing"); + } + decoded.push_back(static_cast((upper << 4) | lower)); // NOSONAR(cpp:S6022) hex decoding is byte-oriented by design + } + + if (bytes != nullptr) { + *bytes = std::move(decoded); + } + return true; + } + + bool generate_unique_id(std::string *uniqueId, std::string *errorMessage) { + std::array bytes {}; + if (!fill_random_bytes(bytes.data(), bytes.size(), errorMessage)) { + return false; + } + + std::string generatedUniqueId = hex_encode(bytes.data(), bytes.size()); + std::transform(generatedUniqueId.begin(), generatedUniqueId.end(), generatedUniqueId.begin(), [](unsigned char character) { + return static_cast(std::toupper(character)); + }); + + if (uniqueId != nullptr) { + *uniqueId = std::move(generatedUniqueId); + } + return true; + } + + bool generate_uuid(std::string *uuid, std::string *errorMessage) { + std::array bytes {}; + if (!fill_random_bytes(bytes.data(), bytes.size(), errorMessage)) { + return false; + } + + bytes[6] = static_cast((bytes[6] & 0x0F) | 0x40); // NOSONAR(cpp:S6022) UUID version bits are byte-oriented by definition + bytes[8] = static_cast((bytes[8] & 0x3F) | 0x80); // NOSONAR(cpp:S6022) UUID variant bits are byte-oriented by definition + + std::string hex = hex_encode(bytes.data(), bytes.size()); + if (uuid != nullptr) { + *uuid = hex.substr(0, 8) + "-" + hex.substr(8, 4) + "-" + hex.substr(12, 4) + "-" + hex.substr(16, 4) + "-" + hex.substr(20, 12); + } + return true; + } + + std::string extract_pem(BIO *bio) { + BUF_MEM *memory = nullptr; + BIO_get_mem_ptr(bio, &memory); + if (memory == nullptr || memory->data == nullptr || memory->length == 0) { + return {}; + } + + return {memory->data, memory->length}; + } + + std::string x509_to_pem(X509 *certificate) { + std::unique_ptr bio(BIO_new(BIO_s_mem())); + if (bio == nullptr || PEM_write_bio_X509(bio.get(), certificate) != 1) { + return {}; + } + + return extract_pem(bio.get()); + } + + std::string private_key_to_pem(EVP_PKEY *key) { + std::unique_ptr bio(BIO_new(BIO_s_mem())); + if (bio == nullptr || PEM_write_bio_PrivateKey(bio.get(), key, nullptr, nullptr, 0, nullptr, nullptr) != 1) { + return {}; + } + + return extract_pem(bio.get()); + } + + std::unique_ptr load_certificate(std::string_view certificatePem) { + std::unique_ptr bio(BIO_new_mem_buf(certificatePem.data(), static_cast(certificatePem.size()))); + if (bio == nullptr) { + return nullptr; + } + + return std::unique_ptr(PEM_read_bio_X509(bio.get(), nullptr, nullptr, nullptr)); + } + + std::unique_ptr load_private_key(std::string_view privateKeyPem) { + std::unique_ptr bio(BIO_new_mem_buf(privateKeyPem.data(), static_cast(privateKeyPem.size()))); + if (bio == nullptr) { + return nullptr; + } + + return std::unique_ptr(PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr)); + } + + bool configure_tls_pairing_identity(SSL_CTX *context, const network::PairingIdentity &identity, std::string *errorMessage) { + if (context == nullptr) { + return append_error(errorMessage, "Failed to initialize the TLS client identity for pairing"); + } + + std::unique_ptr certificate = load_certificate(identity.certificatePem); + if (certificate == nullptr) { + return append_openssl_error(errorMessage, "Failed to load the client pairing certificate for TLS"); + } + + std::unique_ptr privateKey = load_private_key(identity.privateKeyPem); + if (privateKey == nullptr) { + return append_openssl_error(errorMessage, "Failed to load the client pairing private key for TLS"); + } + + ERR_clear_error(); + if (SSL_CTX_use_certificate(context, certificate.get()) != 1) { + return append_openssl_error(errorMessage, "Failed to attach the client pairing certificate to TLS"); + } + if (SSL_CTX_use_PrivateKey(context, privateKey.get()) != 1) { + return append_openssl_error(errorMessage, "Failed to attach the client pairing private key to TLS"); + } + if (SSL_CTX_check_private_key(context) != 1) { + return append_openssl_error(errorMessage, "The client pairing certificate and private key do not match"); + } + + return true; + } + + bool verify_tls_peer_certificate(const SSL *ssl, std::string_view expectedCertificatePem, std::string *errorMessage) { + if (ssl == nullptr || expectedCertificatePem.empty()) { + return true; + } + + std::unique_ptr expectedCertificate = load_certificate(expectedCertificatePem); + if (expectedCertificate == nullptr) { + return append_error(errorMessage, "The saved host pairing certificate was invalid before the TLS verification step"); + } + + std::unique_ptr peerCertificate(SSL_get_peer_certificate(ssl)); + if (peerCertificate == nullptr) { + return append_error(errorMessage, "The host did not present a TLS certificate during pairing"); + } + + if (X509_cmp(peerCertificate.get(), expectedCertificate.get()) != 0) { + return append_error(errorMessage, "The host presented an unexpected TLS certificate during pairing"); + } + + return true; + } + + bool create_self_signed_certificate(network::PairingIdentity *identity, std::string *errorMessage) { + if (!ensure_pairing_entropy(errorMessage)) { + return false; + } + + ERR_clear_error(); + +#ifdef NXDK + std::unique_ptr exponent(BN_new()); + if (exponent == nullptr || BN_set_word(exponent.get(), RSA_F4) != 1) { + return append_openssl_error(errorMessage, "Failed to initialize the client key generation exponent for pairing"); + } + + std::unique_ptr rsa(RSA_new()); + if (rsa == nullptr || RSA_generate_key_ex(rsa.get(), 2048, exponent.get(), nullptr) != 1) { + return append_openssl_error(errorMessage, "Failed to generate the client key used for pairing"); + } + + EVP_PKEY *rawKey = EVP_PKEY_new(); + if (rawKey == nullptr) { + return append_openssl_error(errorMessage, "Failed to allocate the client key container used for pairing"); + } + + std::unique_ptr key(rawKey); + if (EVP_PKEY_assign_RSA(key.get(), rsa.release()) != 1) { + return append_openssl_error(errorMessage, "Failed to attach the generated client RSA key for pairing"); + } +#else + std::unique_ptr keyContext(EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, nullptr), &EVP_PKEY_CTX_free); + if (keyContext == nullptr || EVP_PKEY_keygen_init(keyContext.get()) != 1 || EVP_PKEY_CTX_set_rsa_keygen_bits(keyContext.get(), 2048) != 1) { + return append_openssl_error(errorMessage, "Failed to initialize client key generation for pairing"); + } + + EVP_PKEY *rawKey = nullptr; + if (EVP_PKEY_keygen(keyContext.get(), &rawKey) != 1 || rawKey == nullptr) { + return append_openssl_error(errorMessage, "Failed to generate the client key used for pairing"); + } + std::unique_ptr key(rawKey); +#endif + + std::unique_ptr certificate(X509_new()); + if (certificate == nullptr) { + return append_openssl_error(errorMessage, "Failed to allocate the client certificate used for pairing"); + } + + X509_set_version(certificate.get(), 2); + ASN1_INTEGER_set(X509_get_serialNumber(certificate.get()), 1); + X509_gmtime_adj(X509_get_notBefore(certificate.get()), 0); + X509_gmtime_adj(X509_get_notAfter(certificate.get()), 60L * 60L * 24L * 365L * 10L); + X509_set_pubkey(certificate.get(), key.get()); + + if (auto *certificateSubject = X509_get_subject_name(certificate.get()); certificateSubject == nullptr) { + return append_openssl_error(errorMessage, "Failed to populate the client certificate subject for pairing"); + } else { + X509_NAME_add_entry_by_txt(certificateSubject, "CN", MBSTRING_ASC, reinterpret_cast("NVIDIA GameStream Client"), -1, -1, 0); + X509_set_issuer_name(certificate.get(), certificateSubject); + } + + if (X509_sign(certificate.get(), key.get(), EVP_sha256()) == 0) { + return append_openssl_error(errorMessage, "Failed to sign the client certificate used for pairing"); + } + + identity->certificatePem = x509_to_pem(certificate.get()); + identity->privateKeyPem = private_key_to_pem(key.get()); + if (identity->certificatePem.empty() || identity->privateKeyPem.empty()) { + return append_openssl_error(errorMessage, "Failed to serialize the client pairing certificate or private key"); + } + + return true; + } + + std::string certificate_hex(std::string_view certificatePem) { + return hex_encode(reinterpret_cast(certificatePem.data()), certificatePem.size()); + } + + bool extract_xml_tag_value(std::string_view xml, std::string_view tagName, std::string *value) { + const std::string openTag = "<" + std::string(tagName) + ">"; + const std::string closeTag = ""; + const std::size_t openIndex = xml.find(openTag); + if (openIndex == std::string_view::npos) { + return false; + } + + const std::size_t contentStart = openIndex + openTag.size(); + const std::size_t closeIndex = xml.find(closeTag, contentStart); + if (closeIndex == std::string_view::npos) { + return false; + } + + if (value != nullptr) { + *value = std::string(xml.substr(contentStart, closeIndex - contentStart)); + } + return true; + } + + struct XmlElementView { + std::string_view openTag; + std::string_view innerXml; + }; + + bool try_parse_flag(std::string_view text, bool *value); + bool try_parse_uint32(std::string_view text, uint32_t *value); + + std::size_t skip_xml_attribute_whitespace(std::string_view text, std::size_t index) { + while (index < text.size() && std::isspace(static_cast(text[index]))) { + ++index; + } + return index; + } + + bool xml_attribute_name_has_valid_prefix(std::string_view openTag, std::size_t nameIndex) { + if (nameIndex == 0U) { + return true; + } + + const char previousCharacter = openTag[nameIndex - 1U]; + return previousCharacter == '<' || std::isspace(static_cast(previousCharacter)); + } + + bool find_xml_attribute_value_bounds(std::string_view openTag, std::size_t nameIndex, std::size_t *valueStart, char *quoteCharacter) { + std::size_t separatorIndex = skip_xml_attribute_whitespace(openTag, nameIndex); + if (separatorIndex >= openTag.size() || openTag[separatorIndex] != '=') { + return false; + } + + separatorIndex = skip_xml_attribute_whitespace(openTag, separatorIndex + 1U); + if (separatorIndex >= openTag.size()) { + return false; + } + if (openTag[separatorIndex] != '"' && openTag[separatorIndex] != '\'') { + return false; + } + + if (quoteCharacter != nullptr) { + *quoteCharacter = openTag[separatorIndex]; + } + if (valueStart != nullptr) { + *valueStart = separatorIndex + 1U; + } + return true; + } + + bool extract_xml_attribute_value(std::string_view openTag, std::string_view attributeName, std::string *value) { + std::size_t cursor = 0; + while (cursor < openTag.size()) { + const std::size_t nameIndex = openTag.find(attributeName, cursor); + if (nameIndex == std::string_view::npos) { + return false; + } + + if (!xml_attribute_name_has_valid_prefix(openTag, nameIndex)) { + cursor = nameIndex + 1U; + continue; + } + + std::size_t valueStart = 0; + char quoteCharacter = '\0'; + if (!find_xml_attribute_value_bounds(openTag, nameIndex + attributeName.size(), &valueStart, "eCharacter)) { + cursor = nameIndex + 1U; + continue; + } + + const std::size_t valueEnd = openTag.find(quoteCharacter, valueStart); + if (valueEnd == std::string_view::npos) { + return false; + } + + if (value != nullptr) { + *value = std::string(openTag.substr(valueStart, valueEnd - valueStart)); + } + return true; + } + + return false; + } + + std::vector extract_xml_elements(std::string_view xml, std::string_view tagName) { + std::vector elements; + const std::string openPrefix = "<" + std::string(tagName); + const std::string closeTag = ""; + std::size_t cursor = 0; + + while (cursor < xml.size()) { // NOSONAR(cpp:S924) permissive XML scanning uses multiple early breaks for malformed payloads + const std::size_t openIndex = xml.find(openPrefix, cursor); + if (openIndex == std::string_view::npos) { + break; + } + + const std::size_t tagNameEnd = openIndex + openPrefix.size(); + if (tagNameEnd < xml.size() && xml[tagNameEnd] != '>' && xml[tagNameEnd] != '/' && !std::isspace(static_cast(xml[tagNameEnd]))) { + cursor = openIndex + 1; + continue; + } + + const std::size_t openEnd = xml.find('>', tagNameEnd); + if (openEnd == std::string_view::npos) { + break; + } + + const std::string_view openTag = xml.substr(openIndex, openEnd - openIndex + 1); + if (openEnd > openIndex && xml[openEnd - 1] == '/') { + elements.push_back({openTag, {}}); + cursor = openEnd + 1; + continue; + } + + const std::size_t closeIndex = xml.find(closeTag, openEnd + 1); + if (closeIndex == std::string_view::npos) { + break; + } + + elements.push_back({openTag, xml.substr(openEnd + 1, closeIndex - openEnd - 1)}); + cursor = closeIndex + closeTag.size(); + } + + return elements; + } + + std::vector extract_candidate_app_elements(std::string_view xml) { + for (std::string_view tagName : {std::string_view("App"), std::string_view("app"), std::string_view("Game"), std::string_view("game"), std::string_view("Application"), std::string_view("application"), std::string_view("Program"), std::string_view("program")}) { + std::vector elements = extract_xml_elements(xml, tagName); + if (!elements.empty()) { + return elements; + } + } + + return {}; + } + + bool append_applist_status_error(std::string_view responseBody, std::string *errorMessage) { + const std::vector roots = extract_xml_elements(responseBody, "root"); + if (roots.empty()) { + return append_error(errorMessage, "The host applist response did not contain any app entries (payload preview: " + summarize_http_payload_preview(responseBody) + ")"); + } + + std::string statusCodeText; + std::string statusMessage; + extract_xml_attribute_value(roots.front().openTag, "status_code", &statusCodeText); + extract_xml_attribute_value(roots.front().openTag, "status_message", &statusMessage); + + if (uint32_t statusCode = 200; !statusCodeText.empty() && try_parse_uint32(trim_ascii_whitespace(statusCodeText), &statusCode) && statusCode != 200U) { + const std::string normalizedStatusMessage = statusMessage.empty() ? "The host returned status " + std::to_string(statusCode) + " while requesting /applist" : statusMessage; + return append_error(errorMessage, normalizedStatusMessage); + } + + return append_error(errorMessage, "The host applist response did not contain any app entries (payload preview: " + summarize_http_payload_preview(responseBody) + ")"); + } + + bool extract_root_status(std::string_view responseBody, uint32_t *statusCode, std::string *statusMessage) { + const std::vector roots = extract_xml_elements(responseBody, "root"); + if (roots.empty()) { + return false; + } + + std::string statusCodeText; + extract_xml_attribute_value(roots.front().openTag, "status_code", &statusCodeText); + if (statusMessage != nullptr) { + extract_xml_attribute_value(roots.front().openTag, "status_message", statusMessage); + } + + uint32_t parsedStatusCode = 200; + if (!statusCodeText.empty() && !try_parse_uint32(trim_ascii_whitespace(statusCodeText), &parsedStatusCode)) { + return false; + } + + if (statusCode != nullptr) { + *statusCode = parsedStatusCode; + } + return true; + } + + bool response_indicates_unpaired_client(const HttpResponse &response) { + if (response.statusCode == 401 || response.statusCode == 403) { + return true; + } + + uint32_t rootStatusCode = 200; + std::string rootStatusMessage; + if (!extract_root_status(response.body, &rootStatusCode, &rootStatusMessage)) { + return false; + } + + return rootStatusCode == 401U || rootStatusCode == 403U || network::error_indicates_unpaired_client(rootStatusMessage); + } + + bool try_parse_flag(std::string_view text, bool *value) { + const std::string_view trimmed = trim_ascii_whitespace(text); + if (trimmed == "1" || ascii_iequals(trimmed, "true") || ascii_iequals(trimmed, "yes")) { + if (value != nullptr) { + *value = true; + } + return true; + } + + if (trimmed == "0" || ascii_iequals(trimmed, "false") || ascii_iequals(trimmed, "no")) { + if (value != nullptr) { + *value = false; + } + return true; + } + + return false; + } + + bool body_looks_like_xml(std::string_view body) { + const std::string_view trimmed = trim_ascii_whitespace(body); + return !trimmed.empty() && trimmed.front() == '<'; + } + + bool try_parse_port(std::string_view text, uint16_t *port) { + if (text.empty()) { + return false; + } + + unsigned long value = 0; + for (char character : text) { + if (character < '0' || character > '9') { + return false; + } + value = (value * 10UL) + static_cast(character - '0'); + if (value > 65535UL) { + return false; + } + } + + if (port != nullptr) { + *port = static_cast(value); + } + return value != 0; + } + + bool try_parse_uint32(std::string_view text, uint32_t *value) { + if (text.empty()) { + return false; + } + + unsigned long parsedValue = 0; + for (char character : text) { + if (character < '0' || character > '9') { + return false; + } + parsedValue = (parsedValue * 10UL) + static_cast(character - '0'); + if (parsedValue > 0xFFFFFFFFUL) { + return false; + } + } + + if (value != nullptr) { + *value = static_cast(parsedValue); + } + return true; + } + + bool prepare_pairing_socket_address(const std::string &address, uint16_t port, sockaddr_in *socketAddress, std::string *errorMessage) { + if (socketAddress == nullptr) { + return append_error(errorMessage, "Internal pairing error while preparing the host connection"); + } + + trace_pairing_phase("preparing IPv4 socket address"); + *socketAddress = {}; + socketAddress->sin_family = AF_INET; + socketAddress->sin_port = htons(port); + socketAddress->sin_addr.s_addr = inet_addr(address.c_str()); + if (socketAddress->sin_addr.s_addr == INADDR_NONE && address != "255.255.255.255") { + return append_error(errorMessage, "Pairing currently requires a dotted IPv4 host address"); + } + trace_pairing_phase("IPv4 socket address ready"); + return true; + } + + bool wait_for_socket_connect_completion( + SOCKET socketHandle, + const std::string &address, + uint16_t port, + std::string *errorMessage, + const std::atomic *cancelRequested + ) { + trace_pairing_phase("waiting for timed connect completion"); + constexpr int CONNECT_POLL_INTERVAL_MILLISECONDS = 100; + int remainingWaitMilliseconds = SOCKET_TIMEOUT_MILLISECONDS; + int selectResult = 0; + while (remainingWaitMilliseconds > 0) { // NOSONAR(cpp:S924) timed connect polling intentionally uses multiple early breaks and returns + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + fd_set writeSet; + FD_ZERO(&writeSet); + FD_SET(socketHandle, &writeSet); + const int waitMilliseconds = std::min(remainingWaitMilliseconds, CONNECT_POLL_INTERVAL_MILLISECONDS); + timeval timeout { + waitMilliseconds / 1000, + (waitMilliseconds % 1000) * 1000, + }; + + selectResult = select(static_cast(socketHandle) + 1, nullptr, &writeSet, nullptr, &timeout); + if (selectResult != 0) { + break; + } + + remainingWaitMilliseconds -= waitMilliseconds; + } + if (selectResult <= 0) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + return append_error( + errorMessage, + selectResult == 0 ? "Timed out connecting to the host pairing endpoint at " + address + ":" + std::to_string(port) : "Connection test failed while waiting for the host pairing endpoint at " + address + ":" + std::to_string(port) + ); + } + + int socketError = 0; +#if defined(_WIN32) && !defined(NXDK) + if (int socketErrorLength = sizeof(socketError); getsockopt(socketHandle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { +#else + if (socklen_t socketErrorLength = sizeof(socketError); getsockopt(socketHandle, SOL_SOCKET, SO_ERROR, &socketError, &socketErrorLength) != 0) { +#endif + return append_error(errorMessage, "Failed to query the host pairing socket status after connect (socket error " + std::to_string(last_socket_error()) + ")"); + } + + if (socketError != 0) { + return append_error(errorMessage, "Host refused the pairing connection on " + address + ":" + std::to_string(port) + " (socket error " + std::to_string(socketError) + ")"); + } + + return true; + } + + bool finalize_connected_socket(SOCKET socketHandle, std::string *errorMessage) { + trace_pairing_phase("restoring blocking mode after connect"); + if (!set_socket_non_blocking(socketHandle, false, errorMessage)) { + return false; + } + + set_socket_timeouts(socketHandle); + trace_pairing_phase("socket connected"); + return true; + } + + bool connect_socket(const std::string &address, uint16_t port, SocketGuard *socketGuard, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { + if (socketGuard == nullptr) { + return append_error(errorMessage, "Internal pairing error while preparing the host connection"); + } + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + trace_pairing_phase("checking runtime network state"); + if (!network::runtime_network_ready()) { + return append_error(errorMessage, network::runtime_network_status().summary); + } + + trace_pairing_phase("creating socket"); + socketGuard->handle = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + if (socketGuard->handle == INVALID_SOCKET) { + return append_error(errorMessage, "Failed to create the host pairing socket"); + } + trace_pairing_phase("socket created"); + + sockaddr_in socketAddress {}; + if (!prepare_pairing_socket_address(address, port, &socketAddress, errorMessage)) { + return false; + } + + trace_pairing_phase("setting non-blocking connect mode"); + if (!set_socket_non_blocking(socketGuard->handle, true, errorMessage)) { + return false; + } + + trace_pairing_detail("connecting to " + address + ":" + std::to_string(port)); + if (const int connectResult = connect(socketGuard->handle, reinterpret_cast(&socketAddress), sizeof(socketAddress)); connectResult == SOCKET_ERROR) { + if (const int connectError = last_socket_error(); !is_connect_in_progress_error(connectError)) { + return append_error(errorMessage, "Failed to connect to the host pairing endpoint at " + address + ":" + std::to_string(port) + " (socket error " + std::to_string(connectError) + ")"); + } + if (!wait_for_socket_connect_completion(socketGuard->handle, address, port, errorMessage, cancelRequested)) { + return false; + } + } + + return finalize_connected_socket(socketGuard->handle, errorMessage); + } + + bool recv_all_plain(SOCKET socketHandle, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { + std::string received; + std::array buffer {}; + std::size_t completeLength = 0; + + while (true) { // NOSONAR(cpp:S924) response framing intentionally uses multiple early breaks and returns + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + const int bytesRead = recv(socketHandle, buffer.data(), static_cast(buffer.size()), 0); + if (bytesRead == 0) { + break; + } + if (bytesRead < 0) { + const int socketError = last_socket_error(); + return append_error(errorMessage, is_timeout_error(socketError) ? "Timed out while reading the host pairing response" : "Failed while reading the host pairing response (socket error " + std::to_string(socketError) + ")"); + } + received.append(buffer.data(), buffer.data() + bytesRead); + + std::string framingError; + if (try_get_http_response_length(received, &completeLength, &framingError)) { + received.resize(completeLength); + break; + } + if (!framingError.empty()) { + return append_error(errorMessage, framingError); + } + } + + if (response != nullptr) { + *response = std::move(received); + } + return true; + } + + bool recv_all_ssl(SSL *ssl, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { + std::string received; + std::array buffer {}; + std::size_t completeLength = 0; + + while (true) { // NOSONAR(cpp:S924) response framing intentionally uses multiple early breaks and returns + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + const int bytesRead = SSL_read(ssl, buffer.data(), static_cast(buffer.size())); + if (bytesRead == 0) { + break; + } + if (bytesRead < 0) { + const int errorCode = SSL_get_error(ssl, bytesRead); + if (errorCode == SSL_ERROR_ZERO_RETURN) { + break; + } + if (errorCode == SSL_ERROR_WANT_READ || errorCode == SSL_ERROR_WANT_WRITE) { + continue; + } + return append_openssl_error(errorMessage, errorCode == SSL_ERROR_SYSCALL && is_timeout_error(last_socket_error()) ? "Timed out while reading the encrypted host pairing response" : "Failed while reading the encrypted host pairing response"); + } + received.append(buffer.data(), buffer.data() + bytesRead); + + std::string framingError; + if (try_get_http_response_length(received, &completeLength, &framingError)) { + received.resize(completeLength); + break; + } + if (!framingError.empty()) { + return append_error(errorMessage, framingError); + } + } + + if (response != nullptr) { + *response = std::move(received); + } + return true; + } + + bool send_all_plain(SOCKET socketHandle, std::string_view request, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { + std::size_t sent = 0; + while (sent < request.size()) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + const int bytesSent = send(socketHandle, request.data() + sent, static_cast(request.size() - sent), 0); + if (bytesSent <= 0) { + return append_error(errorMessage, "Failed to send the host pairing request (socket error " + std::to_string(last_socket_error()) + ")"); + } + sent += static_cast(bytesSent); + } + + return true; + } + + bool send_all_ssl(SSL *ssl, std::string_view request, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { + std::size_t sent = 0; + while (sent < request.size()) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + const int bytesSent = SSL_write(ssl, request.data() + sent, static_cast(request.size() - sent)); + if (bytesSent <= 0) { + return append_openssl_error(errorMessage, "Failed to send the encrypted host pairing request"); + } + sent += static_cast(bytesSent); + } + + return true; + } + + bool parse_http_response(std::string_view responseText, HttpResponse *response, std::string *errorMessage) { + if (responseText.empty()) { + return append_error(errorMessage, "The host closed the connection without returning an HTTP response while pairing"); + } + + const std::size_t lineEnd = responseText.find("\r\n"); + if (lineEnd == std::string_view::npos) { + return append_error(errorMessage, "Received a non-HTTP response while pairing (first bytes: " + summarize_http_payload_preview(responseText) + ")"); + } + + const std::string_view statusLine = responseText.substr(0, lineEnd); + const std::size_t firstSpace = statusLine.find(' '); + if (firstSpace == std::string_view::npos || firstSpace + 4 > statusLine.size()) { + return append_error(errorMessage, "Received an invalid HTTP status line while pairing"); + } + + const int statusCode = std::atoi(std::string(statusLine.substr(firstSpace + 1, 3)).c_str()); + const std::size_t bodyStart = responseText.find("\r\n\r\n"); + if (bodyStart == std::string_view::npos) { + return append_error(errorMessage, "Received an incomplete HTTP response while pairing"); + } + + if (response != nullptr) { + response->statusCode = statusCode; + response->body = std::string(responseText.substr(bodyStart + 4)); + } + return true; + } + + bool query_server_info_internal( + const std::string &address, + uint16_t preferredHttpPort, + std::string_view uniqueId, + HttpResponse *response, + network::HostPairingServerInfo *serverInfo, + std::string *errorMessage, + const std::atomic *cancelRequested = nullptr + ) { + if (address.empty()) { + return append_error(errorMessage, "Pairing requires a valid host address"); + } + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + const std::vector candidatePorts = build_serverinfo_port_candidates(preferredHttpPort); + const std::string serverInfoPath = build_serverinfo_path(uniqueId); + std::vector attemptFailures; + + for (uint16_t candidatePort : candidatePorts) { + trace_pairing_detail("query_server_info: trying port " + std::to_string(candidatePort)); + + HttpResponse candidateResponse {}; + std::string attemptError; + if (!http_get(address, candidatePort, serverInfoPath, false, nullptr, {}, &candidateResponse, &attemptError, cancelRequested)) { + attemptFailures.push_back(std::to_string(candidatePort) + ": " + attemptError); + continue; + } + + network::HostPairingServerInfo candidateServerInfo {}; + if (!network::parse_server_info_response(candidateResponse.body, candidatePort, &candidateServerInfo, &attemptError)) { + attemptFailures.push_back(std::to_string(candidatePort) + ": " + attemptError); + continue; + } + + if (response != nullptr) { + *response = std::move(candidateResponse); + } + if (serverInfo != nullptr) { + *serverInfo = candidateServerInfo; + } + return true; + } + + std::string combinedMessage = "Failed to query /serverinfo from " + address; + if (!attemptFailures.empty()) { + combinedMessage += " ("; + for (std::size_t index = 0; index < attemptFailures.size(); ++index) { + if (index > 0) { + combinedMessage += "; "; + } + combinedMessage += attemptFailures[index]; + } + combinedMessage += ")"; + } + return append_error(errorMessage, std::move(combinedMessage)); + } + + std::string build_http_get_request(const std::string &address, uint16_t port, std::string_view pathAndQuery) { + return "GET " + std::string(pathAndQuery) + + " HTTP/1.1\r\n" + "Host: " + + address + ":" + std::to_string(port) + + "\r\n" + "User-Agent: Moonlight-XboxOG\r\n" + "Connection: close\r\n\r\n"; + } + + bool execute_plain_http_get( + SOCKET socketHandle, + std::string_view request, + std::string *rawResponse, + std::string *errorMessage, + const std::atomic *cancelRequested + ) { + trace_pairing_phase("http_get: sending plain request"); + if (!send_all_plain(socketHandle, request, errorMessage, cancelRequested)) { + return false; + } + return recv_all_plain(socketHandle, rawResponse, errorMessage, cancelRequested); + } + + bool execute_tls_http_get( + SOCKET socketHandle, + std::string_view request, + const network::PairingIdentity *tlsClientIdentity, + std::string_view expectedTlsCertificatePem, + std::string *rawResponse, + std::string *errorMessage, + const std::atomic *cancelRequested + ) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + trace_pairing_phase("http_get: preparing TLS"); + if (!ensure_pairing_entropy(errorMessage)) { + return false; + } + + std::unique_ptr context(SSL_CTX_new(TLS_client_method())); + if (context == nullptr) { + return append_openssl_error(errorMessage, "Failed to create the TLS context for host pairing"); + } + + SSL_CTX_set_verify(context.get(), SSL_VERIFY_NONE, nullptr); // NOSONAR(cpp:S4830) certificate pinning is enforced by verify_tls_peer_certificate() + if (tlsClientIdentity != nullptr && !configure_tls_pairing_identity(context.get(), *tlsClientIdentity, errorMessage)) { + return false; + } + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + std::unique_ptr ssl(SSL_new(context.get())); + if (ssl == nullptr) { + return append_openssl_error(errorMessage, "Failed to create the TLS session for host pairing"); + } + +#ifdef NXDK + std::unique_ptr socketBio(BIO_new_fd(socketHandle, BIO_NOCLOSE)); + if (socketBio == nullptr || BIO_up_ref(socketBio.get()) != 1) { + return append_openssl_error(errorMessage, "Failed to attach the host pairing socket to TLS"); + } + + SSL_set_bio(ssl.get(), socketBio.get(), socketBio.get()); + socketBio.release(); +#else + if (SSL_set_fd(ssl.get(), static_cast(socketHandle)) != 1) { + return append_openssl_error(errorMessage, "Failed to attach the host pairing socket to TLS"); + } +#endif + + ERR_clear_error(); + trace_pairing_phase("http_get: SSL_connect"); + if (const int connectResult = SSL_connect(ssl.get()); connectResult != 1) { + const int sslError = SSL_get_error(ssl.get(), connectResult); + std::string tlsError = "Failed to establish the encrypted host pairing session (SSL error " + std::to_string(sslError); + if (sslError == SSL_ERROR_SYSCALL) { + tlsError += ", socket error " + std::to_string(last_socket_error()); + } + tlsError += ")"; + return append_openssl_error(errorMessage, std::move(tlsError)); + } + if (!verify_tls_peer_certificate(ssl.get(), expectedTlsCertificatePem, errorMessage)) { + return false; + } + + trace_pairing_phase("http_get: sending TLS request"); + if (!send_all_ssl(ssl.get(), request, errorMessage, cancelRequested)) { + return false; + } + return recv_all_ssl(ssl.get(), rawResponse, errorMessage, cancelRequested); + } + + bool http_get( // NOSONAR(cpp:S107) HTTP transport keeps the request inputs explicit for callers + const std::string &address, + uint16_t port, + std::string_view pathAndQuery, + bool useTls, + const network::PairingIdentity *tlsClientIdentity, + std::string_view expectedTlsCertificatePem, + HttpResponse *response, + std::string *errorMessage, + const std::atomic *cancelRequested + ) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + if (const network::testing::HostPairingHttpTestHandler &testHandler = host_pairing_http_test_handler(); testHandler) { + network::testing::HostPairingHttpTestRequest testRequest { + address, + port, + std::string(pathAndQuery), + useTls, + tlsClientIdentity, + std::string(expectedTlsCertificatePem), + }; + network::testing::HostPairingHttpTestResponse testResponse {}; + if (std::string testError; !testHandler(testRequest, &testResponse, &testError, cancelRequested)) { + return append_error(errorMessage, testError.empty() ? "The scripted host pairing request failed" : testError); + } + + if (response != nullptr) { + *response = {testResponse.statusCode, testResponse.body}; + } + if (errorMessage != nullptr) { + errorMessage->clear(); + } + return true; + } + + trace_pairing_phase("http_get: socket initialization"); + if (WsaGuard wsaGuard; !wsaGuard.initialized) { + return append_error(errorMessage, "Failed to initialize socket support for host pairing"); + } + + SocketGuard socketGuard; + trace_pairing_phase("http_get: connect_socket"); + if (!connect_socket(address, port, &socketGuard, errorMessage, cancelRequested)) { + return false; + } + + const std::string request = build_http_get_request(address, port, pathAndQuery); + + std::string rawResponse; + if (!useTls) { + if (!execute_plain_http_get(socketGuard.handle, request, &rawResponse, errorMessage, cancelRequested)) { + return false; + } + } else { + if (!execute_tls_http_get(socketGuard.handle, request, tlsClientIdentity, expectedTlsCertificatePem, &rawResponse, errorMessage, cancelRequested)) { + return false; + } + } + + trace_pairing_phase("http_get: parsing HTTP response"); + return parse_http_response(rawResponse, response, errorMessage); + } + + const EVP_MD *pairing_digest() { + return EVP_sha256(); + } + + std::size_t pairing_hash_length() { + return 32U; + } + + bool compute_digest(const unsigned char *data, std::size_t size, std::vector *digest, std::string *errorMessage) { + unsigned int digestLength = 0; + std::vector output(EVP_MAX_MD_SIZE); + if (EVP_Digest(data, size, output.data(), &digestLength, pairing_digest(), nullptr) != 1) { + return append_error(errorMessage, "Failed to compute the host pairing digest"); + } + + output.resize(digestLength); + if (digest != nullptr) { + *digest = std::move(output); + } + return true; + } + + bool aes_ecb_encrypt(const unsigned char *plaintext, std::size_t size, const std::vector &key, std::vector *ciphertext, std::string *errorMessage) { + std::unique_ptr context(EVP_CIPHER_CTX_new()); + if (context == nullptr) { + return append_error(errorMessage, "Failed to initialize the pairing cipher"); + } + + if (EVP_EncryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr) != 1 || EVP_CIPHER_CTX_set_padding(context.get(), 0) != 1) { // NOSONAR(cpp:S5542) Moonlight pairing protocol requires AES-ECB with no padding + return append_error(errorMessage, "Failed to configure the pairing cipher"); + } + + std::vector output(size + 16U); + int outputLength = 0; + if (EVP_EncryptUpdate(context.get(), output.data(), &outputLength, plaintext, static_cast(size)) != 1) { + return append_error(errorMessage, "Failed to encrypt the pairing payload"); + } + + output.resize(static_cast(outputLength)); + if (ciphertext != nullptr) { + *ciphertext = std::move(output); + } + return true; + } + + bool aes_ecb_decrypt(const std::vector &ciphertext, const std::vector &key, std::vector *plaintext, std::string *errorMessage) { + std::unique_ptr context(EVP_CIPHER_CTX_new()); + if (context == nullptr) { + return append_error(errorMessage, "Failed to initialize the pairing decipher"); + } + + if (EVP_DecryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr) != 1 || EVP_CIPHER_CTX_set_padding(context.get(), 0) != 1) { // NOSONAR(cpp:S5542) Moonlight pairing protocol requires AES-ECB with no padding + return append_error(errorMessage, "Failed to configure the pairing decipher"); + } + + std::vector output(ciphertext.size()); + int outputLength = 0; + if (EVP_DecryptUpdate(context.get(), output.data(), &outputLength, ciphertext.data(), static_cast(ciphertext.size())) != 1) { + return append_error(errorMessage, "Failed to decrypt the pairing payload"); + } + + output.resize(static_cast(outputLength)); + if (plaintext != nullptr) { + *plaintext = std::move(output); + } + return true; + } + + bool sign_sha256(const std::vector &data, EVP_PKEY *privateKey, std::vector *signature, std::string *errorMessage) { + std::unique_ptr context(EVP_MD_CTX_new()); + if (context == nullptr || EVP_DigestSignInit(context.get(), nullptr, EVP_sha256(), nullptr, privateKey) != 1 || EVP_DigestSignUpdate(context.get(), data.data(), data.size()) != 1) { + return append_error(errorMessage, "Failed to initialize the pairing signature"); + } + + std::size_t signatureLength = 0; + if (EVP_DigestSignFinal(context.get(), nullptr, &signatureLength) != 1 || signatureLength == 0U) { + return append_error(errorMessage, "Failed to size the pairing signature"); + } + + std::vector generatedSignature(signatureLength); + if (EVP_DigestSignFinal(context.get(), generatedSignature.data(), &signatureLength) != 1) { + return append_error(errorMessage, "Failed to generate the pairing signature"); + } + + generatedSignature.resize(signatureLength); + if (signature != nullptr) { + *signature = std::move(generatedSignature); + } + return true; + } + + bool verify_sha256_signature(const std::vector &data, const std::vector &signature, X509 *certificate, std::string *errorMessage) { + std::unique_ptr publicKey(X509_get_pubkey(certificate)); + std::unique_ptr context(EVP_MD_CTX_new()); + if (publicKey == nullptr || context == nullptr || EVP_DigestVerifyInit(context.get(), nullptr, EVP_sha256(), nullptr, publicKey.get()) != 1 || EVP_DigestVerifyUpdate(context.get(), data.data(), data.size()) != 1) { + return append_error(errorMessage, "Failed to initialize server certificate verification for pairing"); + } + + if (EVP_DigestVerifyFinal(context.get(), signature.data(), signature.size()) != 1) { + return append_error(errorMessage, "Host pairing verification failed"); + } + return true; + } + + bool derive_aes_key(std::string_view saltHex, std::string_view pin, std::vector *key, std::string *errorMessage) { + std::vector saltBytes; + if (!hex_decode(saltHex, &saltBytes, errorMessage) || saltBytes.size() != 16U || pin.size() != 4U) { + return append_error(errorMessage, "The host pairing salt or PIN was invalid"); + } + + std::vector combined; + combined.reserve(saltBytes.size() + pin.size()); + combined.insert(combined.end(), saltBytes.begin(), saltBytes.end()); + combined.insert(combined.end(), pin.begin(), pin.end()); + return compute_digest(combined.data(), combined.size(), key, errorMessage); + } + + bool parse_pairing_tag(const HttpResponse &response, std::string_view tagName, std::string *value, std::string *errorMessage) { + if (response.statusCode != 200) { + return append_error(errorMessage, "The host returned HTTP " + std::to_string(response.statusCode) + " during pairing"); + } + + if (!extract_xml_tag_value(response.body, tagName, value)) { + return append_error(errorMessage, "The host returned an unexpected pairing response"); + } + return true; + } + + bool load_certificate_signature(const X509 *certificate, std::vector *signature, std::string *errorMessage) { + const ASN1_BIT_STRING *asn1Signature = nullptr; + X509_get0_signature(&asn1Signature, nullptr, certificate); + if (asn1Signature == nullptr || asn1Signature->data == nullptr || asn1Signature->length <= 0) { + return append_error(errorMessage, "Failed to read the client certificate signature used for pairing"); + } + + if (signature != nullptr) { + signature->assign(asn1Signature->data, asn1Signature->data + asn1Signature->length); + } + return true; + } + + struct PairingSessionState { + PairingSessionState(const network::HostPairingRequest &requestValue, const std::atomic *cancelRequestedValue): + request(requestValue), + cancelRequested(cancelRequestedValue) { + } + + const network::HostPairingRequest &request; + const std::atomic *cancelRequested = nullptr; + network::HostPairingResult result {false, false, "Pairing failed"}; + std::unique_ptr clientCertificate; + std::unique_ptr clientPrivateKey; + uint16_t httpPort = DEFAULT_SERVERINFO_HTTP_PORT; + std::string uniqueId; + std::string deviceName; + std::string errorMessage; + HttpResponse response {}; + std::string requestUuid; + network::HostPairingServerInfo serverInfo {}; + std::string phaseValue; + std::string plainCertPem; + std::string saltHex; + std::vector aesKey; + std::array clientSecretBytes {}; + }; + + bool pairing_session_cancelled(PairingSessionState *session) { + if (session == nullptr || !pairing_cancel_requested(session->cancelRequested)) { + return false; + } + + session->result = {false, false, "Pairing cancelled"}; + trace_pairing_detail(session->result.message); + return true; + } + + network::HostPairingResult fail_pairing_phase(PairingSessionState *session, std::string_view phase) { + if (session == nullptr) { + return {false, false, "Pairing failed"}; + } + + std::string detail = "Pairing failed"; + if (!session->errorMessage.empty()) { + detail = session->errorMessage; + } else if (!session->result.message.empty()) { + detail = session->result.message; + } + session->result.message = "Pairing failed during " + std::string(phase) + ": " + detail; + trace_pairing_detail(session->result.message); + return session->result; + } + + bool next_pairing_request_uuid(PairingSessionState *session) { + if (session == nullptr) { + return false; + } + if (generate_uuid(&session->requestUuid, &session->errorMessage)) { + return true; + } + + session->result.message = session->errorMessage.empty() ? "Failed to generate the UUID used for pairing" : session->errorMessage; + return false; + } + + bool initialize_pairing_session(PairingSessionState *session) { + if (session == nullptr) { + return false; + } + if (session->request.address.empty()) { + session->result.message = "Pairing requires a valid host address"; + return false; + } + if (session->request.pin.size() != 4U) { + session->result.message = "Pairing requires a four-digit PIN"; + return false; + } + if (!is_valid_pairing_identity(session->request.identity)) { + session->result.message = "Client pairing identity is missing or invalid"; + return false; + } + if (pairing_session_cancelled(session)) { + return false; + } + + session->clientCertificate = load_certificate(session->request.identity.certificatePem); + session->clientPrivateKey = load_private_key(session->request.identity.privateKeyPem); + if (session->clientCertificate == nullptr || session->clientPrivateKey == nullptr) { + session->result.message = "Client pairing credentials could not be loaded"; + return false; + } + + session->httpPort = session->request.httpPort == 0 ? DEFAULT_SERVERINFO_HTTP_PORT : session->request.httpPort; + session->uniqueId = session->request.identity.uniqueId; + session->deviceName = session->request.deviceName.empty() ? "MoonlightXboxOG" : session->request.deviceName; + return true; + } + + bool execute_pairing_phase_request(PairingSessionState *session, const std::string &path, bool useTls, std::string_view expectedTlsCertificatePem = {}) { + if (session == nullptr) { + return false; + } + + if (!http_get(session->request.address, useTls ? session->serverInfo.httpsPort : session->serverInfo.httpPort, path, useTls, useTls ? &session->request.identity : nullptr, expectedTlsCertificatePem, &session->response, &session->errorMessage, session->cancelRequested)) { + return false; + } + return parse_pairing_tag(session->response, "paired", &session->phaseValue, &session->errorMessage); + } + + bool query_pairing_server_info(PairingSessionState *session) { + if (session == nullptr) { + return false; + } + + trace_pairing_phase("requesting /serverinfo"); + return query_server_info_internal(session->request.address, session->httpPort, session->uniqueId, &session->response, &session->serverInfo, &session->errorMessage, session->cancelRequested); + } + + bool request_server_certificate(PairingSessionState *session) { + if (session == nullptr) { + return false; + } + + std::array saltBytes {}; + if (!fill_random_bytes(saltBytes.data(), saltBytes.size(), &session->errorMessage)) { + return false; + } + + session->saltHex = hex_encode(saltBytes.data(), saltBytes.size()); + const std::string certHex = certificate_hex(session->request.identity.certificatePem); + if (!next_pairing_request_uuid(session)) { + return false; + } + + const std::string phasePath = "/pair?uniqueid=" + session->uniqueId + "&uuid=" + session->requestUuid + "&devicename=" + session->deviceName + "&updateState=1&phrase=getservercert&salt=" + session->saltHex + "&clientcert=" + certHex; + trace_pairing_phase("phase 1 getservercert request"); + if (!execute_pairing_phase_request(session, phasePath, false)) { + return false; + } + if (session->phaseValue != "1") { + session->result.message = "The host rejected the initial pairing request"; + return false; + } + + std::string plainCertHex; + if (!parse_pairing_tag(session->response, "plaincert", &plainCertHex, &session->errorMessage)) { + return false; + } + + std::vector plainCertBytes; + if (!hex_decode(plainCertHex, &plainCertBytes, &session->errorMessage)) { + return false; + } + session->plainCertPem.assign(plainCertBytes.begin(), plainCertBytes.end()); + return derive_aes_key(session->saltHex, session->request.pin, &session->aesKey, &session->errorMessage); + } + + bool send_client_challenge(PairingSessionState *session, std::vector *challengeResponsePlaintext) { + if (session == nullptr || challengeResponsePlaintext == nullptr) { + return false; + } + + std::array clientChallengeBytes {}; + if (!fill_random_bytes(clientChallengeBytes.data(), clientChallengeBytes.size(), &session->errorMessage)) { + return false; + } + + std::vector encryptedClientChallenge; + if (!aes_ecb_encrypt(clientChallengeBytes.data(), clientChallengeBytes.size(), session->aesKey, &encryptedClientChallenge, &session->errorMessage)) { + return false; + } + if (!next_pairing_request_uuid(session)) { + return false; + } + + const std::string phasePath = "/pair?uniqueid=" + session->uniqueId + "&uuid=" + session->requestUuid + "&devicename=" + session->deviceName + "&updateState=1&clientchallenge=" + hex_encode(encryptedClientChallenge.data(), encryptedClientChallenge.size()); + trace_pairing_phase("phase 2 clientchallenge request"); + if (!execute_pairing_phase_request(session, phasePath, false)) { + return false; + } + if (session->phaseValue != "1") { + session->result.message = "The host rejected the client challenge during pairing"; + return false; + } + + std::string challengeResponseHex; + if (!parse_pairing_tag(session->response, "challengeresponse", &challengeResponseHex, &session->errorMessage)) { + return false; + } + + std::vector challengeResponseEncrypted; + if (!hex_decode(challengeResponseHex, &challengeResponseEncrypted, &session->errorMessage)) { + return false; + } + if (!aes_ecb_decrypt(challengeResponseEncrypted, session->aesKey, challengeResponsePlaintext, &session->errorMessage)) { + return false; + } + if (challengeResponsePlaintext->size() < pairing_hash_length() + 16U) { + session->result.message = "The host returned an incomplete challenge response during pairing"; + return false; + } + return true; + } + + bool send_server_challenge_response(PairingSessionState *session, const std::vector &challengeResponsePlaintext) { + if (session == nullptr) { + return false; + } + + std::vector certificateSignature; + if (!load_certificate_signature(session->clientCertificate.get(), &certificateSignature, &session->errorMessage)) { + return false; + } + if (!fill_random_bytes(session->clientSecretBytes.data(), session->clientSecretBytes.size(), &session->errorMessage)) { + return false; + } + + const std::size_t hashLength = pairing_hash_length(); + std::vector clientHashSource; + clientHashSource.insert(clientHashSource.end(), challengeResponsePlaintext.begin() + static_cast(hashLength), challengeResponsePlaintext.begin() + static_cast(hashLength + 16U)); + clientHashSource.insert(clientHashSource.end(), certificateSignature.begin(), certificateSignature.end()); + clientHashSource.insert(clientHashSource.end(), session->clientSecretBytes.begin(), session->clientSecretBytes.end()); + + std::vector clientHash; + if (!compute_digest(clientHashSource.data(), clientHashSource.size(), &clientHash, &session->errorMessage)) { + return false; + } + + std::vector encryptedClientHash; + if (!aes_ecb_encrypt(clientHash.data(), clientHash.size(), session->aesKey, &encryptedClientHash, &session->errorMessage)) { + return false; + } + if (!next_pairing_request_uuid(session)) { + return false; + } + + const std::string phasePath = "/pair?uniqueid=" + session->uniqueId + "&uuid=" + session->requestUuid + "&devicename=" + session->deviceName + "&updateState=1&serverchallengeresp=" + hex_encode(encryptedClientHash.data(), encryptedClientHash.size()); + trace_pairing_phase("phase 3 serverchallengeresp request"); + if (!execute_pairing_phase_request(session, phasePath, false)) { + return false; + } + if (session->phaseValue != "1") { + session->result.message = "The host rejected the server challenge response during pairing"; + return false; + } + + std::string pairingSecretHex; + if (!parse_pairing_tag(session->response, "pairingsecret", &pairingSecretHex, &session->errorMessage)) { + return false; + } + + std::vector pairingSecretBytes; + if (!hex_decode(pairingSecretHex, &pairingSecretBytes, &session->errorMessage) || pairingSecretBytes.size() <= 16U) { + session->errorMessage = "The host returned an invalid pairing secret"; + return false; + } + + std::unique_ptr plainCertificate = load_certificate(session->plainCertPem); + if (plainCertificate == nullptr) { + session->errorMessage = "The host returned an invalid server certificate during pairing"; + return false; + } + + std::vector serverSecret(pairingSecretBytes.begin(), pairingSecretBytes.begin() + 16); + std::vector serverSignature(pairingSecretBytes.begin() + 16, pairingSecretBytes.end()); + return verify_sha256_signature(serverSecret, serverSignature, plainCertificate.get(), &session->errorMessage); + } + + bool send_client_pairing_secret(PairingSessionState *session) { + if (session == nullptr) { + return false; + } + + std::vector clientSecretVector(session->clientSecretBytes.begin(), session->clientSecretBytes.end()); + std::vector clientPairingSignature; + if (!sign_sha256(clientSecretVector, session->clientPrivateKey.get(), &clientPairingSignature, &session->errorMessage)) { + return false; + } + + std::vector clientPairingSecret; + clientPairingSecret.insert(clientPairingSecret.end(), session->clientSecretBytes.begin(), session->clientSecretBytes.end()); + clientPairingSecret.insert(clientPairingSecret.end(), clientPairingSignature.begin(), clientPairingSignature.end()); + if (!next_pairing_request_uuid(session)) { + return false; + } + + const std::string phasePath = "/pair?uniqueid=" + session->uniqueId + "&uuid=" + session->requestUuid + "&devicename=" + session->deviceName + "&updateState=1&clientpairingsecret=" + hex_encode(clientPairingSecret.data(), clientPairingSecret.size()); + trace_pairing_phase("phase 4 clientpairingsecret request"); + if (!execute_pairing_phase_request(session, phasePath, false)) { + return false; + } + if (session->phaseValue != "1") { + session->result.message = "The host rejected the client pairing secret"; + return false; + } + return true; + } + + bool send_pair_challenge(PairingSessionState *session) { + if (session == nullptr) { + return false; + } + if (!next_pairing_request_uuid(session)) { + return false; + } + + const std::string phasePath = "/pair?uniqueid=" + session->uniqueId + "&uuid=" + session->requestUuid + "&devicename=" + session->deviceName + "&updateState=1&phrase=pairchallenge"; + trace_pairing_phase("phase 5 pairchallenge request"); + if (!execute_pairing_phase_request(session, phasePath, true, session->plainCertPem)) { + return false; + } + if (session->phaseValue != "1") { + session->result.message = "The host rejected the final encrypted pairing challenge"; + return false; + } + return true; + } + +} // namespace + +namespace network { + + bool is_valid_pairing_identity(const PairingIdentity &identity) { + return !identity.uniqueId.empty() && load_certificate(identity.certificatePem) != nullptr && load_private_key(identity.privateKeyPem) != nullptr; + } + + PairingIdentity create_pairing_identity(std::string *errorMessage) { + PairingIdentity identity {}; + if (errorMessage != nullptr) { + errorMessage->clear(); + } + + if (!generate_unique_id(&identity.uniqueId, errorMessage)) { + return identity; + } + + create_self_signed_certificate(&identity, errorMessage); + return identity; + } + + bool generate_pairing_pin(std::string *pin, std::string *errorMessage) { + static constexpr std::size_t PAIRING_PIN_LENGTH = 4U; + static constexpr unsigned char DECIMAL_REJECTION_LIMIT = 250U; + + if (pin == nullptr) { + return append_error(errorMessage, "A pairing PIN output buffer is required"); + } + if (errorMessage != nullptr) { + errorMessage->clear(); + } + + pin->clear(); + pin->reserve(PAIRING_PIN_LENGTH); + while (pin->size() < PAIRING_PIN_LENGTH) { + unsigned char randomByte = 0; + if (!fill_random_bytes(&randomByte, sizeof(randomByte), errorMessage)) { + pin->clear(); + return false; + } + if (randomByte >= DECIMAL_REJECTION_LIMIT) { + continue; + } + + pin->push_back(static_cast('0' + (randomByte % 10U))); + } + + return true; + } + + bool parse_server_info_response(std::string_view xml, uint16_t fallbackHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { + std::string appVersion; + std::string httpPortText; + std::string httpsPortText; + std::string pairStatus; + if (!extract_xml_tag_value(xml, "appversion", &appVersion) || !extract_xml_tag_value(xml, "PairStatus", &pairStatus)) { + return append_error(errorMessage, "The host serverinfo response was missing required pairing fields"); + } + + uint16_t httpPort = fallbackHttpPort; + if (extract_xml_tag_value(xml, "ExternalPort", &httpPortText) || extract_xml_tag_value(xml, "HttpPort", &httpPortText)) { + try_parse_port(httpPortText, &httpPort); + } + + uint16_t httpsPort = fallbackHttpPort; + if (extract_xml_tag_value(xml, "HttpsPort", &httpsPortText)) { + try_parse_port(httpsPortText, &httpsPort); + } + + std::string hostName; + extract_xml_tag_value(xml, "hostname", &hostName) || extract_xml_tag_value(xml, "HostName", &hostName); + + std::string uuid; + extract_xml_tag_value(xml, "uuid", &uuid) || extract_xml_tag_value(xml, "UniqueId", &uuid); + + std::string localAddress; + extract_xml_tag_value(xml, "LocalIP", &localAddress) || extract_xml_tag_value(xml, "localip", &localAddress); + + std::string remoteAddress; + extract_xml_tag_value(xml, "ExternalIP", &remoteAddress) || extract_xml_tag_value(xml, "externalip", &remoteAddress); + + std::string ipv6Address; + extract_xml_tag_value(xml, "IPv6", &ipv6Address) || extract_xml_tag_value(xml, "LocalIP6", &ipv6Address); + + std::string macAddress; + extract_xml_tag_value(xml, "mac", &macAddress) || extract_xml_tag_value(xml, "MacAddress", &macAddress); + + std::string activeAddress; + if (!localAddress.empty()) { + activeAddress = localAddress; + } else if (!remoteAddress.empty()) { + activeAddress = remoteAddress; + } + + std::string runningGameIdText; + uint32_t runningGameId = 0; + if (extract_xml_tag_value(xml, "currentgame", &runningGameIdText) || extract_xml_tag_value(xml, "CurrentGame", &runningGameIdText)) { + try_parse_uint32(runningGameIdText, &runningGameId); + } + + if (serverInfo != nullptr) { + serverInfo->serverMajorVersion = std::atoi(appVersion.c_str()); + serverInfo->httpPort = httpPort == 0 ? fallbackHttpPort : httpPort; + serverInfo->httpsPort = httpsPort == 0 ? fallbackHttpPort : httpsPort; + serverInfo->paired = pairStatus == "1"; + serverInfo->hostName = std::move(hostName); + serverInfo->uuid = std::move(uuid); + serverInfo->activeAddress = std::move(activeAddress); + serverInfo->localAddress = std::move(localAddress); + serverInfo->remoteAddress = std::move(remoteAddress); + serverInfo->ipv6Address = std::move(ipv6Address); + serverInfo->macAddress = std::move(macAddress); + serverInfo->runningGameId = runningGameId; + } + return true; + } + + bool parse_app_list_response(std::string_view xml, std::vector *apps, std::string *errorMessage) { + const std::string_view trimmedResponse = trim_ascii_whitespace(xml); + const std::vector appElements = extract_candidate_app_elements(trimmedResponse); + + if (appElements.empty()) { + if (!trimmedResponse.empty() && trimmedResponse.front() == '<') { + return append_applist_status_error(trimmedResponse, errorMessage); + } + return append_error(errorMessage, "The applist response was not XML (payload preview: " + summarize_http_payload_preview(trimmedResponse) + ")"); + } + + std::vector parsedApps; + parsedApps.reserve(appElements.size()); + for (const XmlElementView &appElement : appElements) { + std::string name; + std::string idText; + extract_xml_tag_value(appElement.innerXml, "AppTitle", &name) || extract_xml_tag_value(appElement.innerXml, "Title", &name) || extract_xml_tag_value(appElement.innerXml, "Name", &name) || extract_xml_tag_value(appElement.innerXml, "title", &name) || extract_xml_tag_value(appElement.innerXml, "name", &name) || extract_xml_attribute_value(appElement.openTag, "AppTitle", &name) || extract_xml_attribute_value(appElement.openTag, "Title", &name) || extract_xml_attribute_value(appElement.openTag, "Name", &name) || extract_xml_attribute_value(appElement.openTag, "title", &name) || extract_xml_attribute_value(appElement.openTag, "name", &name); + extract_xml_tag_value(appElement.innerXml, "ID", &idText) || extract_xml_tag_value(appElement.innerXml, "Id", &idText) || extract_xml_tag_value(appElement.innerXml, "id", &idText) || extract_xml_attribute_value(appElement.openTag, "ID", &idText) || extract_xml_attribute_value(appElement.openTag, "Id", &idText) || extract_xml_attribute_value(appElement.openTag, "id", &idText) || extract_xml_attribute_value(appElement.openTag, "appid", &idText) || extract_xml_attribute_value(appElement.openTag, "appId", &idText); + + uint32_t parsedId = 0; + if (name.empty() || !try_parse_uint32(trim_ascii_whitespace(idText), &parsedId) || parsedId == 0U) { + continue; + } + + bool hdrSupported = false; + bool hidden = false; + std::string hdrText; + std::string hiddenText; + extract_xml_tag_value(appElement.innerXml, "IsHdrSupported", &hdrText) || extract_xml_tag_value(appElement.innerXml, "HDRSupported", &hdrText) || extract_xml_tag_value(appElement.innerXml, "isHdrSupported", &hdrText) || extract_xml_attribute_value(appElement.openTag, "IsHdrSupported", &hdrText) || extract_xml_attribute_value(appElement.openTag, "HDRSupported", &hdrText) || extract_xml_attribute_value(appElement.openTag, "isHdrSupported", &hdrText); + extract_xml_tag_value(appElement.innerXml, "Hidden", &hiddenText) || extract_xml_tag_value(appElement.innerXml, "IsHidden", &hiddenText) || extract_xml_tag_value(appElement.innerXml, "hidden", &hiddenText) || extract_xml_attribute_value(appElement.openTag, "Hidden", &hiddenText) || extract_xml_attribute_value(appElement.openTag, "IsHidden", &hiddenText) || extract_xml_attribute_value(appElement.openTag, "hidden", &hiddenText); + try_parse_flag(hdrText, &hdrSupported); + try_parse_flag(hiddenText, &hidden); + + parsedApps.push_back({name, static_cast(parsedId), hdrSupported, hidden}); + } + + if (parsedApps.empty()) { + return append_error(errorMessage, "The host applist response did not include any valid app IDs"); + } + + if (apps != nullptr) { + *apps = std::move(parsedApps); + } + return true; + } + + bool query_server_info( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity *clientIdentity, + HostPairingServerInfo *serverInfo, + std::string *errorMessage + ) { + const bool canCheckCurrentClientPairing = clientIdentity != nullptr && is_valid_pairing_identity(*clientIdentity); + if (!query_server_info_internal(address, preferredHttpPort, resolve_client_unique_id(clientIdentity), nullptr, serverInfo, errorMessage)) { + return false; + } + + if (serverInfo != nullptr) { + serverInfo->pairingStatusCurrentClientKnown = false; + serverInfo->pairingStatusCurrentClient = false; + } + + if (!canCheckCurrentClientPairing || serverInfo == nullptr || serverInfo->httpsPort == 0) { + return true; + } + + HttpResponse authorizationResponse {}; + if (std::string authorizationError; !http_get(address, serverInfo->httpsPort, build_serverinfo_path(resolve_client_unique_id(clientIdentity)), true, clientIdentity, {}, &authorizationResponse, &authorizationError)) { + return true; + } + + serverInfo->pairingStatusCurrentClientKnown = true; + if (response_indicates_unpaired_client(authorizationResponse)) { + serverInfo->pairingStatusCurrentClient = false; + serverInfo->paired = false; + return true; + } + + HostPairingServerInfo authorizedServerInfo {}; + if (!network::parse_server_info_response(authorizationResponse.body, serverInfo->httpPort == 0 ? preferredHttpPort : serverInfo->httpPort, &authorizedServerInfo, nullptr)) { + serverInfo->pairingStatusCurrentClientKnown = false; + return true; + } + + authorizedServerInfo.pairingStatusCurrentClientKnown = true; + authorizedServerInfo.pairingStatusCurrentClient = true; + *serverInfo = std::move(authorizedServerInfo); + return true; + } + + /** + * @brief Query host status without providing a client identity. + * + * @param address Host address to query. + * @param preferredHttpPort Preferred HTTP port override. + * @param serverInfo Output structure populated with parsed status data. + * @param errorMessage Optional output for request or parse failures. + * @return true when host status was retrieved successfully. + */ + bool query_server_info(const std::string &address, uint16_t preferredHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { + return query_server_info(address, preferredHttpPort, nullptr, serverInfo, errorMessage); + } + + bool error_indicates_unpaired_client(std::string_view errorMessage) { + std::string normalized; + normalized.reserve(errorMessage.size()); + for (char character : errorMessage) { + normalized.push_back(static_cast(std::tolower(static_cast(character)))); + } + + return normalized.find("no longer paired") != std::string::npos || normalized.find("pair the host again") != std::string::npos || normalized.find("not authorized") != std::string::npos || normalized.find("unauthorized") != std::string::npos || normalized.find("http 401") != std::string::npos || normalized.find("http 403") != std::string::npos; + } + + std::string resolve_reachable_address(const std::string &requestedAddress, const HostPairingServerInfo &serverInfo) { + if (!requestedAddress.empty()) { + return requestedAddress; + } + if (!serverInfo.activeAddress.empty()) { + return serverInfo.activeAddress; + } + if (!serverInfo.localAddress.empty()) { + return serverInfo.localAddress; + } + if (!serverInfo.remoteAddress.empty()) { + return serverInfo.remoteAddress; + } + + return {}; + } + + bool query_app_list( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity *clientIdentity, + std::vector *apps, + HostPairingServerInfo *serverInfo, + std::string *errorMessage + ) { + HostPairingServerInfo resolvedServerInfo {}; + if (!query_server_info(address, preferredHttpPort, clientIdentity, &resolvedServerInfo, errorMessage)) { + return false; + } + + if (serverInfo != nullptr) { + *serverInfo = resolvedServerInfo; + } + + HttpResponse response {}; + const uint16_t appListPort = resolvedServerInfo.httpsPort == 0 ? resolvedServerInfo.httpPort : resolvedServerInfo.httpsPort; + if (const std::string appListAddress = resolve_reachable_address(address, resolvedServerInfo); !http_get(appListAddress, appListPort, build_applist_path(resolve_client_unique_id(clientIdentity)), true, clientIdentity, {}, &response, errorMessage)) { + return false; + } + + if (response.statusCode != 200) { + if (response.statusCode == 401 || response.statusCode == 403) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + return append_error(errorMessage, "The host returned HTTP " + std::to_string(response.statusCode) + " while requesting /applist"); + } + + if (std::string parseError; !parse_app_list_response(response.body, apps, &parseError)) { + if (error_indicates_unpaired_client(parseError)) { + return append_error(errorMessage, std::string(UNPAIRED_CLIENT_ERROR_MESSAGE)); + } + return append_error(errorMessage, std::move(parseError)); + } + + if (serverInfo != nullptr) { + serverInfo->pairingStatusCurrentClient = resolvedServerInfo.paired; + } + return true; + } + + uint64_t hash_app_list_entries(const std::vector &apps) { + uint64_t hash = 1469598103934665603ULL; + for (const HostAppEntry &entry : apps) { + append_hash_string(&hash, entry.name); + append_hash_bytes(&hash, reinterpret_cast(&entry.id), sizeof(entry.id)); + append_hash_bytes(&hash, reinterpret_cast(&entry.hdrSupported), sizeof(entry.hdrSupported)); + append_hash_bytes(&hash, reinterpret_cast(&entry.hidden), sizeof(entry.hidden)); + } + return hash; + } + + bool query_app_asset( + const std::string &address, + uint16_t httpsPort, + const PairingIdentity *clientIdentity, + int appId, + std::vector *assetBytes, + std::string *errorMessage + ) { + if (address.empty() || httpsPort == 0 || appId <= 0) { + return append_error(errorMessage, "The app-asset request requires a valid host address, port, and app ID"); + } + + std::vector attemptFailures; + for (const std::string &path : build_app_asset_paths(resolve_client_unique_id(clientIdentity), appId)) { + HttpResponse response {}; + if (std::string attemptError; !http_get(address, httpsPort, path, true, clientIdentity, {}, &response, &attemptError)) { + attemptFailures.push_back(path + ": " + attemptError); + continue; + } + + if (response.statusCode != 200) { + attemptFailures.push_back(path + ": HTTP " + std::to_string(response.statusCode)); + continue; + } + + if (response.body.empty() || body_looks_like_xml(response.body)) { + attemptFailures.push_back(path + ": returned placeholder XML instead of image data"); + continue; + } + + if (assetBytes != nullptr) { + assetBytes->assign(response.body.begin(), response.body.end()); + } + return true; + } + + std::string combinedMessage = "Failed to fetch app artwork for app ID " + std::to_string(appId); + if (!attemptFailures.empty()) { + combinedMessage += " ("; + for (std::size_t index = 0; index < attemptFailures.size(); ++index) { + if (index > 0) { + combinedMessage += "; "; + } + combinedMessage += attemptFailures[index]; + } + combinedMessage += ")"; + } + return append_error(errorMessage, std::move(combinedMessage)); + } + + HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested) { + trace_pairing_phase("pair_host entered"); + PairingSessionState session(request, cancelRequested); + if (!initialize_pairing_session(&session)) { + return session.result; + } + if (!query_pairing_server_info(&session)) { + return fail_pairing_phase(&session, "serverinfo"); + } + if (pairing_session_cancelled(&session)) { + return session.result; + } + if (session.serverInfo.paired) { + session.result.success = true; + session.result.alreadyPaired = true; + session.result.message = "The host already reports this client as paired"; + return session.result; + } + trace_pairing_phase("deriving AES key"); + if (!request_server_certificate(&session)) { + return fail_pairing_phase(&session, "phase 1 (getservercert)"); + } + if (pairing_session_cancelled(&session)) { + return session.result; + } + + std::vector challengeResponsePlaintext; + if (!send_client_challenge(&session, &challengeResponsePlaintext)) { + return fail_pairing_phase(&session, "phase 2 (client challenge)"); + } + if (pairing_session_cancelled(&session)) { + return session.result; + } + if (!send_server_challenge_response(&session, challengeResponsePlaintext)) { + return fail_pairing_phase(&session, "phase 3 (server challenge response)"); + } + if (pairing_session_cancelled(&session)) { + return session.result; + } + if (!send_client_pairing_secret(&session)) { + return fail_pairing_phase(&session, "phase 4 (client pairing secret)"); + } + if (pairing_session_cancelled(&session)) { + return session.result; + } + if (!send_pair_challenge(&session)) { + return fail_pairing_phase(&session, "phase 5 (pairchallenge)"); + } + if (pairing_session_cancelled(&session)) { + return session.result; + } + + session.result.success = true; + session.result.message = "Paired successfully with " + request.address; + trace_pairing_phase("pair_host succeeded"); + return session.result; + } + + namespace testing { + + void set_host_pairing_http_test_handler(HostPairingHttpTestHandler handler) { + host_pairing_http_test_handler() = std::move(handler); + } + + void clear_host_pairing_http_test_handler() { + host_pairing_http_test_handler() = {}; + } + + } // namespace testing + +} // namespace network diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h new file mode 100644 index 0000000..6f167d6 --- /dev/null +++ b/src/network/host_pairing.h @@ -0,0 +1,255 @@ +/** + * @file src/network/host_pairing.h + * @brief Declares host pairing helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include + +namespace network { + + /** + * @brief Client identity material used for Moonlight host pairing. + */ + struct PairingIdentity { + std::string uniqueId; ///< Stable unique identifier presented to the host. + std::string certificatePem; ///< Client certificate in PEM format. + std::string privateKeyPem; ///< Private key matching the client certificate in PEM format. + }; + + /** + * @brief Parsed host status fields used by browsing and pairing flows. + */ + struct HostPairingServerInfo { + int serverMajorVersion = 0; ///< Major version reported by the host server. + uint16_t httpPort = 0; ///< HTTP port reported or inferred for plaintext requests. + uint16_t httpsPort = 0; ///< HTTPS port reported for encrypted requests and assets. + bool paired = false; ///< True when the host reports that this client is paired. + bool pairingStatusCurrentClientKnown = false; ///< True when the host explicitly reported pairing status for this client. + bool pairingStatusCurrentClient = false; ///< Pairing status reported for this specific client identity. + std::string hostName; ///< User-facing host name reported by the server. + std::string uuid; ///< Stable host UUID. + std::string activeAddress; ///< Best currently reachable address chosen for live requests. + std::string localAddress; ///< Host-reported LAN address. + std::string remoteAddress; ///< Host-reported WAN address. + std::string ipv6Address; ///< Host-reported IPv6 address. + std::string macAddress; ///< Host-reported MAC address. + uint32_t runningGameId = 0; ///< Running game identifier, or zero when idle. + }; + + /** + * @brief One application entry returned by the host app list API. + */ + struct HostAppEntry { + 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 support. + bool hidden = false; ///< True when the app is hidden by default on the host. + }; + + /** + * @brief Parameters required to perform a host pairing request. + */ + struct HostPairingRequest { + std::string address; ///< Host address used for the pairing session. + uint16_t httpPort; ///< Effective host HTTP port used for pairing. + std::string pin; ///< User-entered or generated pairing PIN. + std::string deviceName; ///< Friendly client name presented to the host. + PairingIdentity identity; ///< Client identity material presented during pairing. + }; + + /** + * @brief Outcome of attempting to pair with a host. + */ + struct HostPairingResult { + bool success; ///< True when pairing succeeded. + bool alreadyPaired; ///< True when the host was already paired before the request. + std::string message; ///< User-visible success or failure detail. + }; + + /** + * @brief Return whether a pairing identity contains the required PEM materials. + * + * @param identity Candidate pairing identity. + * @return true when the identity is usable for authenticated requests. + */ + bool is_valid_pairing_identity(const PairingIdentity &identity); + + /** + * @brief Create a fresh client identity for Moonlight pairing. + * + * @param errorMessage Optional output for key or certificate generation failures. + * @return Generated pairing identity, or an empty identity on failure. + */ + PairingIdentity create_pairing_identity(std::string *errorMessage = nullptr); + + /** + * @brief Generate a secure four-digit PIN for host pairing. + * + * @param pin Output buffer populated with exactly four decimal digits. + * @param errorMessage Optional output for entropy or random-byte failures. + * @return true when a secure PIN was generated successfully. + */ + bool generate_pairing_pin(std::string *pin, std::string *errorMessage = nullptr); + + /** + * @brief Parse the XML response returned by the host server-info endpoint. + * + * @param xml Raw XML response body. + * @param fallbackHttpPort HTTP port to use when the response omits it. + * @param serverInfo Output structure populated from the response. + * @param errorMessage Optional output for parse or validation failures. + * @return true when the response was parsed successfully. + */ + bool parse_server_info_response(std::string_view xml, uint16_t fallbackHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage = nullptr); + + /** + * @brief Parse the XML response returned by the host app-list endpoint. + * + * @param xml Raw XML response body. + * @param apps Output vector populated with parsed app entries. + * @param errorMessage Optional output for parse or validation failures. + * @return true when the response was parsed successfully. + */ + bool parse_app_list_response(std::string_view xml, std::vector *apps, std::string *errorMessage = nullptr); + + /** + * @brief Return whether an error message indicates the client is not paired. + * + * @param errorMessage Candidate error text. + * @return true when the text maps to an unpaired-client condition. + */ + bool error_indicates_unpaired_client(std::string_view errorMessage); + + /** + * @brief Compute a stable hash for a fetched app list. + * + * @param apps App entries to hash. + * @return Stable hash value for content-change detection. + */ + uint64_t hash_app_list_entries(const std::vector &apps); + + /** + * @brief Choose the best reachable address for a host. + * + * @param requestedAddress Address originally requested by the user. + * @param serverInfo Parsed host status information. + * @return Reachable address to use for subsequent requests. + */ + std::string resolve_reachable_address(const std::string &requestedAddress, const HostPairingServerInfo &serverInfo); + + /** + * @brief Query live host status using an optional client identity. + * + * @param address Host address to query. + * @param preferredHttpPort Preferred HTTP port override. + * @param clientIdentity Optional client identity for authenticated requests. + * @param serverInfo Output structure populated with parsed status data. + * @param errorMessage Optional output for request or parse failures. + * @return true when host status was retrieved successfully. + */ + bool query_server_info( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity *clientIdentity, + HostPairingServerInfo *serverInfo, + std::string *errorMessage = nullptr + ); + + /** + * @brief Query the app list exported by a host. + * + * @param address Host address to query. + * @param preferredHttpPort Preferred HTTP port override. + * @param clientIdentity Optional client identity for authenticated requests. + * @param apps Output vector populated with parsed app entries. + * @param serverInfo Optional output populated with the latest host status data. + * @param errorMessage Optional output for request or parse failures. + * @return true when the app list was retrieved successfully. + */ + bool query_app_list( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity *clientIdentity, + std::vector *apps, + HostPairingServerInfo *serverInfo = nullptr, + std::string *errorMessage = nullptr + ); + + /** + * @brief Query one app asset such as box art over HTTPS. + * + * @param address Host address to query. + * @param httpsPort Host HTTPS port. + * @param clientIdentity Optional client identity for authenticated requests. + * @param appId Host application identifier. + * @param assetBytes Output vector populated with downloaded asset bytes. + * @param errorMessage Optional output for request failures. + * @return true when the asset was downloaded successfully. + */ + bool query_app_asset( + const std::string &address, + uint16_t httpsPort, + const PairingIdentity *clientIdentity, + int appId, + std::vector *assetBytes, + std::string *errorMessage = nullptr + ); + + /** + * @brief Pair the client with a host using the provided request parameters. + * + * @param request Pairing parameters and client identity. + * @param cancelRequested Optional cancellation flag checked during the request. + * @return Pairing outcome including success state and user-visible detail. + */ + HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested = nullptr); + + namespace testing { + + /** + * @brief Scripted HTTP request details exposed to host-pairing unit tests. + */ + struct HostPairingHttpTestRequest { + std::string address; ///< Destination host address requested by the pairing helper. + uint16_t port = 0; ///< Destination host port requested by the pairing helper. + std::string pathAndQuery; ///< Raw HTTP path and query string. + bool useTls = false; ///< True when the request would normally use TLS. + const PairingIdentity *tlsClientIdentity = nullptr; ///< Optional client identity attached to TLS requests. + std::string expectedTlsCertificatePem; ///< Optional pinned host certificate expected by the request. + }; + + /** + * @brief Scripted HTTP response returned by a host-pairing unit-test handler. + */ + struct HostPairingHttpTestResponse { + int statusCode = 0; ///< HTTP status code returned to the caller. + std::string body; ///< HTTP response body returned to the caller. + }; + + /** + * @brief Callback used by tests to replace host-pairing HTTP and TLS traffic. + */ + using HostPairingHttpTestHandler = std::function *)>; + + /** + * @brief Install a scripted HTTP handler for host-pairing unit tests. + * + * @param handler Callback that should service subsequent host-pairing HTTP requests. + */ + void set_host_pairing_http_test_handler(HostPairingHttpTestHandler handler); + + /** + * @brief Remove any scripted HTTP handler previously installed for host-pairing tests. + */ + void clear_host_pairing_http_test_handler(); + + } // namespace testing + +} // namespace network diff --git a/src/network/runtime_network.cpp b/src/network/runtime_network.cpp new file mode 100644 index 0000000..9ac5639 --- /dev/null +++ b/src/network/runtime_network.cpp @@ -0,0 +1,125 @@ +/** + * @file src/network/runtime_network.cpp + * @brief Implements runtime network status management. + */ +// class header include +#include "src/network/runtime_network.h" + +// nxdk includes +#ifdef NXDK + #include + #include + #include + +/** + * @brief nxdk-provided pointer to the active lwIP network interface. + */ +extern "C" struct netif *g_pnetif; // NOSONAR(cpp:S5421) external symbol declared by nxdk; cannot be const +#endif + +namespace { + + /** @brief Cached runtime network status shared by the public query helpers. */ + network::RuntimeNetworkStatus g_runtimeNetworkStatus {}; // NOSONAR(cpp:S5421) mutable state updated at runtime + +#ifdef NXDK + std::string copy_ipv4_string(const ip4_addr_t *address) { + if (address == nullptr) { + return {}; + } + + const char *text = ip4addr_ntoa(address); + return text == nullptr ? std::string {} : std::string(text); + } +#endif + + [[maybe_unused]] network::RuntimeNetworkStatus make_host_network_status() { + return { + true, + true, + 0, + "Host build networking is provided by the operating system. nxdk network initialization is not required.", + {}, + {}, + {}, + }; + } + +} // namespace + +namespace network { + + std::string describe_runtime_network_initialization_code(int initializationCode) { + switch (initializationCode) { + case 0: + return "nxdk networking initialized successfully"; + case -1: + return "nxdk networking could not read or apply the configured network settings"; + case -2: + return "nxdk networking timed out while waiting for DHCP to supply an IPv4 address"; + default: + return "nxdk networking failed with an unexpected initialization error"; + } + } + + std::vector format_runtime_network_status_lines(const RuntimeNetworkStatus &status) { + std::vector lines; + if (!status.summary.empty()) { + lines.push_back(status.summary); + } + + if (!status.ipAddress.empty()) { + lines.push_back("IPv4 address: " + status.ipAddress); + } + if (!status.subnetMask.empty()) { + lines.push_back("Subnet mask: " + status.subnetMask); + } + if (!status.gateway.empty()) { + lines.push_back("Gateway: " + status.gateway); + } + + if (status.initializationAttempted) { + lines.push_back("Initialization code: " + std::to_string(status.initializationCode)); + } + + return lines; + } + + RuntimeNetworkStatus initialize_runtime_networking() { + if (g_runtimeNetworkStatus.initializationAttempted) { + return g_runtimeNetworkStatus; + } + +#ifdef NXDK + g_runtimeNetworkStatus.initializationAttempted = true; + g_runtimeNetworkStatus.initializationCode = nxNetInit(nullptr); + g_runtimeNetworkStatus.ready = g_runtimeNetworkStatus.initializationCode == 0; + g_runtimeNetworkStatus.summary = describe_runtime_network_initialization_code(g_runtimeNetworkStatus.initializationCode); + + if (g_runtimeNetworkStatus.ready && g_pnetif != nullptr) { + g_runtimeNetworkStatus.ipAddress = copy_ipv4_string(netif_ip4_addr(g_pnetif)); + g_runtimeNetworkStatus.subnetMask = copy_ipv4_string(netif_ip4_netmask(g_pnetif)); + g_runtimeNetworkStatus.gateway = copy_ipv4_string(netif_ip4_gw(g_pnetif)); + } else if (g_runtimeNetworkStatus.ready) { + g_runtimeNetworkStatus.ready = false; + g_runtimeNetworkStatus.initializationCode = -3; + g_runtimeNetworkStatus.summary = "nxdk networking initialized without publishing an lwIP network interface"; + } + +#else + g_runtimeNetworkStatus = make_host_network_status(); +#endif + + return g_runtimeNetworkStatus; + } + + const RuntimeNetworkStatus &runtime_network_status() { + initialize_runtime_networking(); + return g_runtimeNetworkStatus; + } + + bool runtime_network_ready() { + return runtime_network_status().ready; + } + +} // namespace network diff --git a/src/network/runtime_network.h b/src/network/runtime_network.h new file mode 100644 index 0000000..f8d97d2 --- /dev/null +++ b/src/network/runtime_network.h @@ -0,0 +1,64 @@ +/** + * @file src/network/runtime_network.h + * @brief Declares runtime network status management. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace network { + + /** + * @brief Summary of runtime network initialization and the active IPv4 configuration. + */ + struct RuntimeNetworkStatus { + bool initializationAttempted; ///< True after network initialization has been attempted. + bool ready; ///< True when networking is initialized and ready for use. + int initializationCode; ///< Platform-specific status code returned by initialization. + std::string summary; ///< User-visible summary of the current network state. + std::string ipAddress; ///< Active IPv4 address. + std::string subnetMask; ///< Active IPv4 subnet mask. + std::string gateway; ///< Active IPv4 default gateway. + }; + + /** + * @brief Initialize runtime networking and cache the resulting status. + * + * @return Captured runtime network status after initialization. + */ + RuntimeNetworkStatus initialize_runtime_networking(); + + /** + * @brief Return the last cached runtime network status. + * + * @return Cached runtime network status. + */ + const RuntimeNetworkStatus &runtime_network_status(); + + /** + * @brief Return whether runtime networking is ready for host communication. + * + * @return true when networking is initialized successfully. + */ + bool runtime_network_ready(); + + /** + * @brief Convert a platform-specific initialization code into readable text. + * + * @param initializationCode Platform-specific initialization code. + * @return Human-readable description of the code. + */ + std::string describe_runtime_network_initialization_code(int initializationCode); + + /** + * @brief Format status lines for shell display. + * + * @param status Runtime network status to format. + * @return Display-ready text lines describing the status. + */ + std::vector format_runtime_network_status_lines(const RuntimeNetworkStatus &status); + +} // namespace network diff --git a/src/os.h b/src/os.h index 4531ced..240b659 100644 --- a/src/os.h +++ b/src/os.h @@ -1,4 +1,8 @@ +/** + * @file src/os.h + * @brief Declares platform path constants for the Xbox target. + */ #pragma once -inline constexpr auto PATH_SEP = "\\"; -inline constexpr auto DATA_PATH = "D:\\"; +inline constexpr auto PATH_SEP = "\\"; ///< Preferred path separator for persisted Xbox paths. +inline constexpr auto DATA_PATH = "D:\\"; ///< Root data partition used for persisted Moonlight files on Xbox. diff --git a/src/platform/error_utils.h b/src/platform/error_utils.h new file mode 100644 index 0000000..917158b --- /dev/null +++ b/src/platform/error_utils.h @@ -0,0 +1,28 @@ +/** + * @file src/platform/error_utils.h + * @brief Declares error formatting helpers. + */ +#pragma once + +// standard includes +#include +#include + +namespace platform { + + /** + * @brief Store an error message when requested and return false for chaining. + * + * @param errorMessage Optional destination for the error text. + * @param message Error message to store. + * @return Always false. + */ + inline bool append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + + return false; + } + +} // namespace platform diff --git a/src/platform/filesystem_utils.cpp b/src/platform/filesystem_utils.cpp new file mode 100644 index 0000000..a4a5f70 --- /dev/null +++ b/src/platform/filesystem_utils.cpp @@ -0,0 +1,192 @@ +/** + * @file src/platform/filesystem_utils.cpp + * @brief Implements filesystem utility helpers. + */ +// class header include +#include "src/platform/filesystem_utils.h" + +// standard includes +#include +#include +#include +#include + +// platform includes +#if defined(_WIN32) || defined(NXDK) + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names +extern "C" { + #include +} +#else + #include + #include +#endif + +namespace { + + bool is_root_path(std::string_view path) { +#if defined(_WIN32) || defined(NXDK) + return path.size() >= 2 && path.size() <= 3 && path[1] == ':'; +#else + return path == "/"; +#endif + } + + std::string normalize_directory_component(std::string path) { + while (path.size() > 1 && platform::is_path_separator(path.back())) { + if (is_root_path(path)) { + break; + } + path.pop_back(); + } + return path; + } + + bool create_directory_if_missing(const std::string &path, std::string *errorMessage) { +#if defined(_WIN32) || defined(NXDK) + if (_mkdir(path.c_str()) == 0 || errno == EEXIST) { + return true; + } +#else + if (mkdir(path.c_str(), 0750) == 0 || errno == EEXIST) { + return true; + } +#endif + + if (errorMessage != nullptr) { + *errorMessage = "Failed to create directory '" + path + "': " + std::strerror(errno); + } + return false; + } + + bool path_char_equal(char left, char right) { +#if defined(_WIN32) || defined(NXDK) + return std::tolower(static_cast(left)) == std::tolower(static_cast(right)); +#else + return left == right; +#endif + } + +} // namespace + +namespace platform { + + char preferred_path_separator() { +#if defined(_WIN32) || defined(NXDK) + return '\\'; +#else + return '/'; +#endif + } + + bool is_path_separator(char character) { + return character == '\\' || character == '/'; + } + + std::string join_path(const std::string &left, const std::string &right) { + if (left.empty()) { + return right; + } + if (right.empty()) { + return left; + } + if (is_path_separator(left.back())) { + return left + right; + } + return left + preferred_path_separator() + right; + } + + std::string parent_directory(std::string_view filePath) { + const std::size_t separatorIndex = filePath.find_last_of("\\/"); + if (separatorIndex == std::string::npos) { + return {}; + } + return std::string(filePath.substr(0, separatorIndex)); + } + + std::string file_name_from_path(std::string_view path) { + const std::size_t separatorIndex = path.find_last_of("\\/"); + return separatorIndex == std::string::npos ? std::string(path) : std::string(path.substr(separatorIndex + 1U)); + } + + bool ensure_directory_exists(const std::string &directoryPath, std::string *errorMessage) { + if (directoryPath.empty()) { + return true; + } + + std::string partialPath; + std::size_t startIndex = 0; +#if defined(_WIN32) || defined(NXDK) + if (directoryPath.size() >= 2 && directoryPath[1] == ':') { + partialPath = directoryPath.substr(0, 2); + startIndex = 2; + } +#else + if (is_path_separator(directoryPath.front())) { + partialPath = "/"; + startIndex = 1; + } +#endif + + for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { + partialPath.push_back(directoryPath[index]); + const bool atSeparator = is_path_separator(directoryPath[index]); + if (const bool atPathEnd = index + 1 == directoryPath.size(); !atSeparator && !atPathEnd) { + continue; + } + + const std::string normalizedPath = normalize_directory_component(partialPath); + if (normalizedPath.empty() || is_root_path(normalizedPath)) { + continue; + } + + if (!create_directory_if_missing(normalizedPath, errorMessage)) { + return false; + } + } + + return true; + } + + bool try_get_file_size(std::string_view path, std::uint64_t *sizeBytes) { +#if defined(_WIN32) || defined(NXDK) + WIN32_FILE_ATTRIBUTE_DATA fileData {}; + if (const std::string ownedPath(path); !GetFileAttributesExA(ownedPath.c_str(), GetFileExInfoStandard, &fileData)) { + return false; + } + if ((fileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0U) { + return false; + } + + if (sizeBytes != nullptr) { + *sizeBytes = (static_cast(fileData.nFileSizeHigh) << 32U) | + static_cast(fileData.nFileSizeLow); + } + return true; +#else + struct stat status {}; + if (const std::string ownedPath(path); stat(ownedPath.c_str(), &status) != 0 || !S_ISREG(status.st_mode)) { + return false; + } + + if (sizeBytes != nullptr) { + *sizeBytes = static_cast(status.st_size); + } + return true; +#endif + } + + bool path_has_prefix(const std::string &path, const std::string &prefix) { + if (prefix.empty() || path.size() < prefix.size()) { + return false; + } + + for (std::size_t index = 0; index < prefix.size(); ++index) { + if (!path_char_equal(path[index], prefix[index])) { + return false; + } + } + return true; + } + +} // namespace platform diff --git a/src/platform/filesystem_utils.h b/src/platform/filesystem_utils.h new file mode 100644 index 0000000..994a85b --- /dev/null +++ b/src/platform/filesystem_utils.h @@ -0,0 +1,81 @@ +/** + * @file src/platform/filesystem_utils.h + * @brief Declares filesystem utility helpers. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace platform { + + /** + * @brief Return the preferred path separator for the active platform. + * + * @return Preferred path separator character. + */ + char preferred_path_separator(); + + /** + * @brief Return whether a character is recognized as a path separator. + * + * @param character Character to inspect. + * @return True when the character is a supported path separator. + */ + bool is_path_separator(char character); + + /** + * @brief Join two path components using the preferred platform separator. + * + * @param left Left path component. + * @param right Right path component. + * @return Joined path. + */ + std::string join_path(const std::string &left, const std::string &right); + + /** + * @brief Return the parent directory portion of a file path. + * + * @param filePath File path to inspect. + * @return Parent directory path, or an empty string when none exists. + */ + std::string parent_directory(std::string_view filePath); + + /** + * @brief Return the last path component from a file path. + * + * @param path Path to inspect. + * @return Final path component, or the original string when no separator exists. + */ + std::string file_name_from_path(std::string_view path); + + /** + * @brief Ensure that a directory path exists, creating missing segments as needed. + * + * @param directoryPath Directory path to create. + * @param errorMessage Optional output for creation failures. + * @return True when the directory exists or was created successfully. + */ + bool ensure_directory_exists(const std::string &directoryPath, std::string *errorMessage = nullptr); + + /** + * @brief Try to read the size of a regular file. + * + * @param path File path to inspect. + * @param sizeBytes Optional output for the file size in bytes. + * @return True when the path exists and is a regular file. + */ + bool try_get_file_size(std::string_view path, std::uint64_t *sizeBytes = nullptr); + + /** + * @brief Return whether a path begins with the requested prefix. + * + * @param path Full path to inspect. + * @param prefix Prefix to compare against. + * @return True when the path starts with the prefix. + */ + bool path_has_prefix(const std::string &path, const std::string &prefix); + +} // namespace platform diff --git a/src/splash/splash_layout.cpp b/src/splash/splash_layout.cpp index 0a76e4d..b9e1cef 100644 --- a/src/splash/splash_layout.cpp +++ b/src/splash/splash_layout.cpp @@ -1,5 +1,11 @@ +/** + * @file src/splash/splash_layout.cpp + * @brief Implements splash screen layout calculations. + */ +// class header include #include "src/splash/splash_layout.h" +// standard includes #include #include @@ -27,16 +33,17 @@ namespace splash { float get_display_aspect_ratio(const VIDEO_MODE &videoMode, unsigned long encoderSettings) { const float framebufferAspectRatio = get_framebuffer_aspect_ratio(videoMode); - const float preferredDisplayAspectRatio = ((encoderSettings & VIDEO_WIDESCREEN) != 0UL) ? (16.0f / 9.0f) : (4.0f / 3.0f); - const bool isStandardDefinitionRaster = videoMode.height <= 576; - - if (const bool needsAspectCorrection = isStandardDefinitionRaster && std::fabs(framebufferAspectRatio - preferredDisplayAspectRatio) > SPLASH_ASPECT_RATIO_EPSILON) { + if (const float preferredDisplayAspectRatio = ((encoderSettings & VIDEO_WIDESCREEN) != 0UL) ? (16.0f / 9.0f) : (4.0f / 3.0f); std::fabs(framebufferAspectRatio - preferredDisplayAspectRatio) > SPLASH_ASPECT_RATIO_EPSILON) { return preferredDisplayAspectRatio; } return framebufferAspectRatio; } + int calculate_display_width(int screenHeight, const VIDEO_MODE &videoMode, unsigned long encoderSettings) { + return clamp_scaled_dimension(static_cast(screenHeight) * get_display_aspect_ratio(videoMode, encoderSettings)); + } + float get_logo_width_aspect_correction(const VIDEO_MODE &videoMode, unsigned long encoderSettings) { return get_framebuffer_aspect_ratio(videoMode) / get_display_aspect_ratio(videoMode, encoderSettings); } diff --git a/src/splash/splash_layout.h b/src/splash/splash_layout.h index e3aa292..61b6a75 100644 --- a/src/splash/splash_layout.h +++ b/src/splash/splash_layout.h @@ -1,3 +1,7 @@ +/** + * @file src/splash/splash_layout.h + * @brief Declares splash screen layout calculations. + */ #pragma once // nxdk includes @@ -39,6 +43,20 @@ namespace splash { */ float get_display_aspect_ratio(const VIDEO_MODE &videoMode, unsigned long encoderSettings); + /** + * @brief Calculate the logical display width for square-pixel UI layout. + * + * When the framebuffer aspect ratio differs from the effective display aspect, + * this returns the width that should be used for layout before horizontal + * scaling is applied back onto the framebuffer. + * + * @param screenHeight Height of the destination surface. + * @param videoMode The video mode being rendered. + * @param encoderSettings The value returned by XVideoGetEncoderSettings(). + * @return The logical display width corresponding to the effective aspect ratio. + */ + int calculate_display_width(int screenHeight, const VIDEO_MODE &videoMode, unsigned long encoderSettings); + /** * @brief Return the width correction factor applied before scaling the logo. * diff --git a/src/splash/splash_screen.cpp b/src/splash/splash_screen.cpp index 453aa37..0d7e9ec 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -1,40 +1,43 @@ +/** + * @file src/splash/splash_screen.cpp + * @brief Implements the splash screen workflow. + */ // class header include #include "src/splash/splash_screen.h" // standard includes #include -#include #include #include #include // nxdk includes -#include #include #include #include -#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // local includes +#include "src/logging/logger.h" #include "src/os.h" #include "src/splash/splash_layout.h" namespace { - constexpr Uint8 SPLASH_BACKGROUND_RED = 0x2A; - constexpr Uint8 SPLASH_BACKGROUND_GREEN = 0x2D; - constexpr Uint8 SPLASH_BACKGROUND_BLUE = 0x30; + constexpr Uint8 SPLASH_BACKGROUND_RED = 0x56; + constexpr Uint8 SPLASH_BACKGROUND_GREEN = 0x5C; + constexpr Uint8 SPLASH_BACKGROUND_BLUE = 0x64; void printSDLErrorAndReboot() { - debugPrint("SDL_Error: %s\n", SDL_GetError()); - debugPrint("Rebooting in 5 seconds.\n"); + logging::error("splash", std::string("SDL error: ") + SDL_GetError()); + logging::warn("splash", "Rebooting in 5 seconds."); Sleep(5000); XReboot(); } void printIMGErrorAndReboot() { - debugPrint("SDL_Image Error: %s\n", IMG_GetError()); - debugPrint("Rebooting in 5 seconds.\n"); + logging::error("splash", std::string("SDL_image error: ") + IMG_GetError()); + logging::warn("splash", "Rebooting in 5 seconds."); Sleep(5000); XReboot(); } @@ -140,20 +143,20 @@ namespace { const SDL_Rect targetDestination = calculateLogoDestination(screenSurface, sourceSurface->w, sourceSurface->h, videoMode); SDL_Surface *scaledSurface = SDL_CreateRGBSurfaceWithFormat(0, targetDestination.w, targetDestination.h, 32, SDL_PIXELFORMAT_ARGB8888); if (scaledSurface == nullptr) { - debugPrint("Failed to create scaled splash asset surface: %s\n", SDL_GetError()); + logging::error("splash", std::string("Failed to create scaled splash asset surface: ") + SDL_GetError()); SDL_FreeSurface(sourceSurface); return nullptr; } if (SDL_LockSurface(sourceSurface) < 0) { - debugPrint("Failed to lock source splash asset surface: %s\n", SDL_GetError()); + logging::error("splash", std::string("Failed to lock source splash asset surface: ") + SDL_GetError()); SDL_FreeSurface(sourceSurface); SDL_FreeSurface(scaledSurface); return nullptr; } if (SDL_LockSurface(scaledSurface) < 0) { - debugPrint("Failed to lock scaled splash asset surface: %s\n", SDL_GetError()); + logging::error("splash", std::string("Failed to lock scaled splash asset surface: ") + SDL_GetError()); SDL_UnlockSurface(sourceSurface); SDL_FreeSurface(sourceSurface); SDL_FreeSurface(scaledSurface); @@ -173,7 +176,7 @@ namespace { SDL_FreeSurface(sourceSurface); if (SDL_SetSurfaceBlendMode(scaledSurface, SDL_BLENDMODE_BLEND) < 0) { - debugPrint("Failed to enable alpha blending for scaled splash asset: %s\n", SDL_GetError()); + logging::error("splash", std::string("Failed to enable alpha blending for scaled splash asset: ") + SDL_GetError()); SDL_FreeSurface(scaledSurface); return nullptr; } @@ -188,7 +191,10 @@ namespace { SDL_Surface *normalizedSurface = SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_ARGB8888, 0); if (normalizedSurface == nullptr) { - debugPrint("Failed to normalize splash asset surface format %s: %s\n", SDL_GetPixelFormatName(surface->format->format), SDL_GetError()); + logging::error( + "splash", + std::string("Failed to normalize splash asset surface format ") + SDL_GetPixelFormatName(surface->format->format) + ": " + SDL_GetError() + ); SDL_FreeSurface(surface); return nullptr; } @@ -196,100 +202,72 @@ namespace { SDL_FreeSurface(surface); if (SDL_SetSurfaceBlendMode(normalizedSurface, SDL_BLENDMODE_BLEND) < 0) { - debugPrint("Failed to enable alpha blending for splash asset: %s\n", SDL_GetError()); + logging::error("splash", std::string("Failed to enable alpha blending for splash asset: ") + SDL_GetError()); SDL_FreeSurface(normalizedSurface); return nullptr; } - debugPrint("Normalized splash asset to format: %s\n", SDL_GetPixelFormatName(normalizedSurface->format->format)); return normalizedSurface; } SDL_Surface *loadSplashLogoSurface() { - const std::array assetNames = { - "moonlight-logo.svg", - "moonlight-logo.ppm", - }; - - for (const char *assetName : assetNames) { - const std::string assetPath = buildAssetPath(assetName); - if (SDL_Surface *loadedSurface = IMG_Load(assetPath.c_str()); loadedSurface != nullptr) { - if (SDL_Surface *normalizedSurface = normalizeSplashLogoSurface(loadedSurface); normalizedSurface != nullptr) { - return normalizedSurface; - } - - debugPrint("Failed to prepare splash asset %s for rendering.\n", assetPath.c_str()); + const std::string assetPath = buildAssetPath("moonlight-logo-wedges.svg"); + if (SDL_Surface *loadedSurface = IMG_Load(assetPath.c_str()); loadedSurface != nullptr) { + if (SDL_Surface *normalizedSurface = normalizeSplashLogoSurface(loadedSurface); normalizedSurface != nullptr) { + return normalizedSurface; } - debugPrint("Failed to load splash asset %s: %s\n", assetPath.c_str(), IMG_GetError()); + logging::error("splash", "Failed to prepare splash asset " + assetPath + " for rendering."); } + logging::error("splash", "Failed to load splash asset " + assetPath + ": " + IMG_GetError()); + return nullptr; } - void cleanupSplashScreen(SDL_Window *window, SDL_Surface *imageSurface) { + void cleanupSplashScreen(SDL_Surface *imageSurface) { if (imageSurface != nullptr) { SDL_FreeSurface(imageSurface); } - if (window != nullptr) { - SDL_DestroyWindow(window); - } - IMG_Quit(); - SDL_VideoQuit(); } -} // namespace - -namespace splash { - - void show_splash_screen(const VIDEO_MODE &videoMode) { + void runSplashScreen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing) { // NOSONAR(cpp:S5213) splash callback API is cold-path and already shared by callers int done = 0; const int imageInitFlags = IMG_INIT_JPG | IMG_INIT_PNG; const int initializedImageFlags = IMG_Init(imageInitFlags); - SDL_Window *window = nullptr; SDL_Event event; SDL_Surface *screenSurface = nullptr; SDL_Surface *imageSurface = nullptr; SDL_LogSetPriority(SDL_LOG_CATEGORY_APPLICATION, SDL_LOG_PRIORITY_INFO); - if (SDL_VideoInit(nullptr) < 0) { - SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Couldn't initialize SDL video.\n"); - printSDLErrorAndReboot(); - return; - } - - window = SDL_CreateWindow("splash", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED, videoMode.width, videoMode.height, SDL_WINDOW_SHOWN); if (window == nullptr) { - debugPrint("Window could not be created!\n"); - SDL_VideoQuit(); + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Splash screen requires a valid SDL window.\n"); printSDLErrorAndReboot(); return; } - if ((initializedImageFlags & imageInitFlags) != imageInitFlags) { - debugPrint("SDL_image initialized without all raster fallback decoders. Flags: %d\n", initializedImageFlags); - } + (void) initializedImageFlags; screenSurface = SDL_GetWindowSurface(window); if (!screenSurface) { - cleanupSplashScreen(window, nullptr); + cleanupSplashScreen(nullptr); printSDLErrorAndReboot(); return; } imageSurface = loadSplashLogoSurface(); if (!imageSurface) { - cleanupSplashScreen(window, nullptr); + cleanupSplashScreen(nullptr); printIMGErrorAndReboot(); return; } imageSurface = createScaledSplashLogoSurface(screenSurface, imageSurface, videoMode); if (!imageSurface) { - cleanupSplashScreen(window, nullptr); + cleanupSplashScreen(nullptr); printSDLErrorAndReboot(); return; } @@ -304,27 +282,46 @@ namespace splash { } if (const Uint32 backgroundColor = SDL_MapRGB(screenSurface->format, SPLASH_BACKGROUND_RED, SPLASH_BACKGROUND_GREEN, SPLASH_BACKGROUND_BLUE); SDL_FillRect(screenSurface, nullptr, backgroundColor) < 0) { - cleanupSplashScreen(window, imageSurface); + cleanupSplashScreen(imageSurface); printSDLErrorAndReboot(); return; } if (SDL_BlitSurface(imageSurface, nullptr, screenSurface, &logoDestination) < 0) { - cleanupSplashScreen(window, imageSurface); + cleanupSplashScreen(imageSurface); printSDLErrorAndReboot(); return; } if (SDL_UpdateWindowSurface(window) < 0) { - cleanupSplashScreen(window, imageSurface); + cleanupSplashScreen(imageSurface); printSDLErrorAndReboot(); return; } - Sleep(1000); + if (!keepShowing()) { + done = 1; + } + + SDL_Delay(16); } - cleanupSplashScreen(window, imageSurface); + cleanupSplashScreen(imageSurface); + } + +} // namespace + +namespace splash { + + void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, unsigned int durationMilliseconds) { + const Uint32 startTicks = SDL_GetTicks(); + runSplashScreen(window, videoMode, [startTicks, durationMilliseconds]() { + return SDL_GetTicks() - startTicks < durationMilliseconds; + }); + } + + void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing) { + runSplashScreen(window, videoMode, keepShowing); } } // namespace splash diff --git a/src/splash/splash_screen.h b/src/splash/splash_screen.h index 524318a..99c5adc 100644 --- a/src/splash/splash_screen.h +++ b/src/splash/splash_screen.h @@ -1,10 +1,34 @@ +/** + * @file src/splash/splash_screen.h + * @brief Declares the splash screen workflow. + */ #pragma once +// standard includes +#include + // nxdk includes #include +#include namespace splash { - void show_splash_screen(const VIDEO_MODE &videoMode); + /** + * @brief Show the splash screen for a fixed duration. + * + * @param window SDL window used for splash rendering. + * @param videoMode Active video mode used to size the splash layout. + * @param durationMilliseconds Splash duration in milliseconds. + */ + void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, unsigned int durationMilliseconds = 1500U); + + /** + * @brief Show the splash screen until the supplied callback reports completion. + * + * @param window SDL window used for splash rendering. + * @param videoMode Active video mode used to size the splash layout. + * @param keepShowing Callback that returns true while the splash screen should remain visible. + */ + void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing); -} +} // namespace splash diff --git a/src/startup/client_identity_storage.cpp b/src/startup/client_identity_storage.cpp new file mode 100644 index 0000000..f52f5b2 --- /dev/null +++ b/src/startup/client_identity_storage.cpp @@ -0,0 +1,180 @@ +/** + * @file src/startup/client_identity_storage.cpp + * @brief Implements client identity persistence. + */ +// class header include +#include "src/startup/client_identity_storage.h" + +// standard includes +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/platform/error_utils.h" +#include "src/platform/filesystem_utils.h" +#include "src/startup/storage_paths.h" + +namespace { + + using platform::append_error; + using platform::join_path; + + constexpr const char *UNIQUE_ID_FILE_NAME = "uniqueid.dat"; + constexpr const char *CERTIFICATE_FILE_NAME = "client.pem"; + constexpr const char *PRIVATE_KEY_FILE_NAME = "key.pem"; + + struct ReadFileTextResult { + std::string content; + int errorCode; + }; + + ReadFileTextResult read_file_text(const std::string &filePath, std::string *errorMessage) { + FILE *file = std::fopen(filePath.c_str(), "rb"); + if (file == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::strerror(errno); + } + return {{}, errno}; + } + + std::vector buffer(4096); + std::string content; + while (true) { + const std::size_t bytesRead = std::fread(buffer.data(), 1, buffer.size(), file); + if (bytesRead > 0) { + content.append(buffer.data(), bytesRead); + } + if (bytesRead < buffer.size()) { + break; + } + } + + if (std::fclose(file) != 0 && errorMessage != nullptr) { + *errorMessage = std::strerror(errno); + } + return {std::move(content), 0}; + } + + bool write_file_text(const std::string &filePath, std::string_view content, std::string *errorMessage) { + FILE *file = std::fopen(filePath.c_str(), "wb"); + if (file == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::strerror(errno); + } + return false; + } + + if (const std::size_t bytesWritten = std::fwrite(content.data(), 1, content.size(), file); bytesWritten != content.size()) { + if (errorMessage != nullptr) { + *errorMessage = std::strerror(errno); + } + std::fclose(file); + return false; + } + + if (std::fclose(file) != 0) { + if (errorMessage != nullptr) { + *errorMessage = std::strerror(errno); + } + return false; + } + + return true; + } + + bool delete_file_if_present(const std::string &filePath, std::string *errorMessage) { + if (std::remove(filePath.c_str()) == 0 || errno == ENOENT) { + return true; + } + + return append_error(errorMessage, "Failed to delete pairing file '" + filePath + "': " + std::strerror(errno)); + } + +} // namespace + +namespace startup { + + std::string default_client_identity_directory() { + return default_storage_path("pairing"); + } + + LoadClientIdentityResult load_client_identity(const std::string &directoryPath) { + LoadClientIdentityResult result {{}, {}, false}; + + const std::string uniqueIdPath = join_path(directoryPath, UNIQUE_ID_FILE_NAME); + const std::string certificatePath = join_path(directoryPath, CERTIFICATE_FILE_NAME); + const std::string privateKeyPath = join_path(directoryPath, PRIVATE_KEY_FILE_NAME); + + std::string uniqueIdError; + const ReadFileTextResult uniqueIdRead = read_file_text(uniqueIdPath, &uniqueIdError); + std::string uniqueId = uniqueIdRead.content; + if (uniqueId.empty()) { + if (uniqueIdRead.errorCode != ENOENT) { + result.warnings.push_back("Failed to load pairing unique ID from '" + uniqueIdPath + "': " + uniqueIdError); + } + return result; + } + + std::string certificateError; + const ReadFileTextResult certificateRead = read_file_text(certificatePath, &certificateError); + std::string certificatePem = certificateRead.content; + if (certificatePem.empty()) { + result.warnings.push_back("Failed to load pairing certificate from '" + certificatePath + "': " + certificateError); + return result; + } + + std::string privateKeyError; + const ReadFileTextResult privateKeyRead = read_file_text(privateKeyPath, &privateKeyError); + std::string privateKeyPem = privateKeyRead.content; + if (privateKeyPem.empty()) { + result.warnings.push_back("Failed to load pairing private key from '" + privateKeyPath + "': " + privateKeyError); + return result; + } + + while (!uniqueId.empty() && (uniqueId.back() == '\r' || uniqueId.back() == '\n')) { + uniqueId.pop_back(); + } + + result.identity = {std::move(uniqueId), std::move(certificatePem), std::move(privateKeyPem)}; + result.fileFound = true; + return result; + } + + bool delete_client_identity(std::string *errorMessage, const std::string &directoryPath) { + const std::string uniqueIdPath = join_path(directoryPath, UNIQUE_ID_FILE_NAME); + const std::string certificatePath = join_path(directoryPath, CERTIFICATE_FILE_NAME); + const std::string privateKeyPath = join_path(directoryPath, PRIVATE_KEY_FILE_NAME); + + if (std::string deleteError; !delete_file_if_present(uniqueIdPath, &deleteError) || !delete_file_if_present(certificatePath, &deleteError) || !delete_file_if_present(privateKeyPath, &deleteError)) { + return append_error(errorMessage, deleteError); + } + + return true; + } + + SaveClientIdentityResult save_client_identity(const network::PairingIdentity &identity, const std::string &directoryPath) { + std::string errorMessage; + if (!platform::ensure_directory_exists(directoryPath, &errorMessage)) { + return {false, errorMessage}; + } + + if (const std::string uniqueIdPath = join_path(directoryPath, UNIQUE_ID_FILE_NAME); !write_file_text(uniqueIdPath, identity.uniqueId, &errorMessage)) { + return {false, "Failed to save pairing unique ID to '" + uniqueIdPath + "': " + errorMessage}; + } + + if (const std::string certificatePath = join_path(directoryPath, CERTIFICATE_FILE_NAME); !write_file_text(certificatePath, identity.certificatePem, &errorMessage)) { + return {false, "Failed to save pairing certificate to '" + certificatePath + "': " + errorMessage}; + } + + if (const std::string privateKeyPath = join_path(directoryPath, PRIVATE_KEY_FILE_NAME); !write_file_text(privateKeyPath, identity.privateKeyPem, &errorMessage)) { + return {false, "Failed to save pairing private key to '" + privateKeyPath + "': " + errorMessage}; + } + + return {true, {}}; + } + +} // namespace startup diff --git a/src/startup/client_identity_storage.h b/src/startup/client_identity_storage.h new file mode 100644 index 0000000..cdf62da --- /dev/null +++ b/src/startup/client_identity_storage.h @@ -0,0 +1,72 @@ +/** + * @file src/startup/client_identity_storage.h + * @brief Declares client identity persistence. + */ +#pragma once + +// standard includes +#include +#include + +// local includes +#include "src/network/host_pairing.h" + +namespace startup { + + /** + * @brief Result of loading persisted client pairing identity material. + */ + struct LoadClientIdentityResult { + network::PairingIdentity identity; ///< Loaded client identity, or an empty identity when unavailable. + std::vector warnings; ///< Non-fatal warnings encountered while loading. + bool fileFound; ///< True when persisted identity files were present. + }; + + /** + * @brief Result of saving client pairing identity material. + */ + struct SaveClientIdentityResult { + bool success; ///< True when the identity was saved successfully. + std::string errorMessage; ///< Error detail when saving failed. + }; + + /** + * @brief Return the default directory used to store pairing identity files. + * + * @return Default client identity directory path. + */ + std::string default_client_identity_directory(); + + /** + * @brief Load persisted client pairing identity material from disk. + * + * @param directoryPath Directory containing the identity files. + * @return Loaded identity plus any non-fatal warnings. + */ + LoadClientIdentityResult load_client_identity(const std::string &directoryPath = default_client_identity_directory()); + + /** + * @brief Delete persisted client identity material from disk. + * + * @param errorMessage Optional output for deletion failures. + * @param directoryPath Directory containing the identity files. + * @return true when the files were removed or were already absent. + */ + bool delete_client_identity( + std::string *errorMessage = nullptr, + const std::string &directoryPath = default_client_identity_directory() + ); + + /** + * @brief Save client pairing identity material to disk. + * + * @param identity Client identity to persist. + * @param directoryPath Directory where the identity files should be written. + * @return Save result including success state and error detail. + */ + SaveClientIdentityResult save_client_identity( + const network::PairingIdentity &identity, + const std::string &directoryPath = default_client_identity_directory() + ); + +} // namespace startup diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp new file mode 100644 index 0000000..2816566 --- /dev/null +++ b/src/startup/cover_art_cache.cpp @@ -0,0 +1,149 @@ +/** + * @file src/startup/cover_art_cache.cpp + * @brief Implements cover art cache persistence. + */ +// class header include +#include "src/startup/cover_art_cache.h" + +// standard includes +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/platform/error_utils.h" +#include "src/platform/filesystem_utils.h" +#include "src/startup/storage_paths.h" + +namespace { + + using platform::append_error; + + std::string cover_art_cache_path(std::string_view cacheKey, const std::string &cacheRoot) { + return platform::join_path(cacheRoot, std::string(cacheKey) + ".bin"); + } + + uint64_t fnv1a_64(std::string_view text) { + uint64_t hash = 1469598103934665603ULL; + for (char character : text) { + hash ^= static_cast(character); + hash *= 1099511628211ULL; + } + return hash; + } + + std::string hex64(uint64_t value) { + static constexpr char HEX_DIGITS[] = "0123456789abcdef"; + + std::string text(16, '0'); + for (int index = 15; index >= 0; --index) { + text[static_cast(index)] = HEX_DIGITS[value & 0x0FULL]; + value >>= 4U; + } + return text; + } + + std::vector read_all_bytes(FILE *file) { + std::vector bytes; + std::vector buffer(4096); + + while (true) { + const std::size_t bytesRead = std::fread(buffer.data(), 1, buffer.size(), file); + if (bytesRead > 0) { + bytes.insert(bytes.end(), buffer.begin(), buffer.begin() + static_cast(bytesRead)); + } + + if (bytesRead < buffer.size()) { + break; + } + } + + return bytes; + } + +} // namespace + +namespace startup { + + std::string default_cover_art_cache_root() { + return default_storage_path("cover-art-cache"); + } + + std::string build_cover_art_cache_key(std::string_view hostUuid, std::string_view hostAddress, int appId) { + const std::string_view hostIdentity = hostUuid.empty() ? hostAddress : hostUuid; + return hex64(fnv1a_64(std::string(hostIdentity) + "|" + std::to_string(appId))) + "-" + std::to_string(appId); + } + + bool cover_art_exists(std::string_view cacheKey, const std::string &cacheRoot) { + FILE *file = std::fopen(cover_art_cache_path(cacheKey, cacheRoot).c_str(), "rb"); + if (file == nullptr) { + return false; + } + + std::fclose(file); + return true; + } + + bool delete_cover_art(std::string_view cacheKey, std::string *errorMessage, const std::string &cacheRoot) { + if (cacheKey.empty()) { + return true; + } + + const std::string path = cover_art_cache_path(cacheKey, cacheRoot); + if (std::remove(path.c_str()) == 0 || errno == ENOENT) { + return true; + } + + return append_error(errorMessage, "Failed to delete cached cover art '" + path + "': " + std::strerror(errno)); + } + + LoadCoverArtResult load_cover_art(std::string_view cacheKey, const std::string &cacheRoot) { + LoadCoverArtResult result {}; + FILE *file = std::fopen(cover_art_cache_path(cacheKey, cacheRoot).c_str(), "rb"); + if (file == nullptr) { + if (errno != ENOENT) { + result.errorMessage = std::strerror(errno); + } + return result; + } + + result.fileFound = true; + result.bytes = read_all_bytes(file); + if (std::ferror(file) != 0) { + result.errorMessage = std::strerror(errno); + } + std::fclose(file); + return result; + } + + SaveCoverArtResult save_cover_art(std::string_view cacheKey, const std::vector &bytes, const std::string &cacheRoot) { + std::string errorMessage; + if (!platform::ensure_directory_exists(cacheRoot, &errorMessage)) { + return {false, errorMessage}; + } + if (!platform::ensure_directory_exists(platform::parent_directory(cover_art_cache_path(cacheKey, cacheRoot)), &errorMessage)) { + return {false, errorMessage}; + } + + FILE *file = std::fopen(cover_art_cache_path(cacheKey, cacheRoot).c_str(), "wb"); + if (file == nullptr) { + return {false, "Failed to open cover-art cache entry for writing: " + std::string(std::strerror(errno))}; + } + + if (const std::size_t bytesWritten = std::fwrite(bytes.data(), 1, bytes.size(), file); bytesWritten != bytes.size()) { + const std::string writeError = std::strerror(errno); + std::fclose(file); + return {false, "Failed to write the cover-art cache entry: " + writeError}; + } + + if (std::fclose(file) != 0) { + return {false, "Failed to finalize the cover-art cache entry: " + std::string(std::strerror(errno))}; + } + + return {true, {}}; + } + +} // namespace startup diff --git a/src/startup/cover_art_cache.h b/src/startup/cover_art_cache.h new file mode 100644 index 0000000..66bf1ab --- /dev/null +++ b/src/startup/cover_art_cache.h @@ -0,0 +1,90 @@ +/** + * @file src/startup/cover_art_cache.h + * @brief Declares cover art cache persistence. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace startup { + + /** + * @brief Result of loading cached cover art bytes from disk. + */ + struct LoadCoverArtResult { + std::vector bytes; ///< Loaded cover art bytes. + bool fileFound = false; ///< True when a cached file existed on disk. + std::string errorMessage; ///< Error detail when loading failed. + }; + + /** + * @brief Result of saving cached cover art bytes to disk. + */ + struct SaveCoverArtResult { + bool success = false; ///< True when the cover art was saved successfully. + std::string errorMessage; ///< Error detail when saving failed. + }; + + /** + * @brief Return the default root directory for cached cover art. + * + * @return Default cover-art cache root path. + */ + std::string default_cover_art_cache_root(); + + /** + * @brief Build a stable cache key for one host app's cover art. + * + * @param hostUuid Host UUID when available. + * @param hostAddress Host address used as a fallback discriminator. + * @param appId Host application identifier. + * @return Stable cache key suitable for file-system storage. + */ + std::string build_cover_art_cache_key(std::string_view hostUuid, std::string_view hostAddress, int appId); + + /** + * @brief Return whether cached cover art exists for a given key. + * + * @param cacheKey Stable cover-art cache key. + * @param cacheRoot Cache root directory to inspect. + * @return true when cached art exists on disk. + */ + bool cover_art_exists(std::string_view cacheKey, const std::string &cacheRoot = default_cover_art_cache_root()); + + /** + * @brief Delete cached cover art for a given key. + * + * @param cacheKey Stable cover-art cache key. + * @param errorMessage Optional output for deletion failures. + * @param cacheRoot Cache root directory containing the artifact. + * @return true when the artifact was deleted or was already absent. + */ + bool delete_cover_art( + std::string_view cacheKey, + std::string *errorMessage = nullptr, + const std::string &cacheRoot = default_cover_art_cache_root() + ); + + /** + * @brief Load cached cover art bytes for a given key. + * + * @param cacheKey Stable cover-art cache key. + * @param cacheRoot Cache root directory containing the artifact. + * @return Loaded cover-art bytes plus file-existence and error details. + */ + LoadCoverArtResult load_cover_art(std::string_view cacheKey, const std::string &cacheRoot = default_cover_art_cache_root()); + + /** + * @brief Save cover art bytes for a given cache key. + * + * @param cacheKey Stable cover-art cache key. + * @param bytes Cover art bytes to persist. + * @param cacheRoot Cache root directory where the artifact should be written. + * @return Save result including success state and error detail. + */ + SaveCoverArtResult save_cover_art(std::string_view cacheKey, const std::vector &bytes, const std::string &cacheRoot = default_cover_art_cache_root()); + +} // namespace startup diff --git a/src/startup/host_storage.cpp b/src/startup/host_storage.cpp new file mode 100644 index 0000000..bbedefa --- /dev/null +++ b/src/startup/host_storage.cpp @@ -0,0 +1,95 @@ +/** + * @file src/startup/host_storage.cpp + * @brief Implements saved host persistence. + */ +// class header include +#include "src/startup/host_storage.h" + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "src/platform/filesystem_utils.h" +#include "src/startup/storage_paths.h" + +namespace { + + 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 > 0) { + content.append(buffer.data(), bytesRead); + } + + if (bytesRead < buffer.size()) { + break; + } + } + + return content; + } + +} // namespace + +namespace startup { + + std::string default_host_storage_path() { + return default_storage_path("moonlight-hosts.tsv"); + } + + LoadSavedHostsResult load_saved_hosts(const std::string &filePath) { + LoadSavedHostsResult result {{}, {}, false}; + + FILE *file = std::fopen(filePath.c_str(), "rb"); + if (file == nullptr) { + if (errno != ENOENT) { + result.warnings.push_back("Failed to open saved hosts file '" + filePath + "': " + std::strerror(errno)); + } + return result; + } + + result.fileFound = true; + const std::string serializedHosts = read_all_text(file); + if (std::ferror(file) != 0) { + result.warnings.push_back("Failed while reading saved hosts file '" + filePath + "': " + std::strerror(errno)); + } + std::fclose(file); + + const app::ParseHostRecordsResult parsedHosts = app::parse_host_records(serializedHosts); + result.hosts = parsedHosts.records; + result.warnings = parsedHosts.errors; + return result; + } + + SaveSavedHostsResult save_saved_hosts(const std::vector &hosts, const std::string &filePath) { + if (std::string errorMessage; !platform::ensure_directory_exists(platform::parent_directory(filePath), &errorMessage)) { + return {false, errorMessage}; + } + + FILE *file = std::fopen(filePath.c_str(), "wb"); + if (file == nullptr) { + return {false, "Failed to open saved hosts file '" + filePath + "' for writing: " + std::strerror(errno)}; + } + + const std::string serializedHosts = app::serialize_host_records(hosts); + if (const std::size_t bytesWritten = std::fwrite(serializedHosts.data(), 1, serializedHosts.size(), file); bytesWritten != serializedHosts.size()) { + const std::string writeErrorMessage = "Failed to write saved hosts file '" + filePath + "': " + std::strerror(errno); + std::fclose(file); + return {false, writeErrorMessage}; + } + + if (std::fclose(file) != 0) { + return {false, "Failed to finalize saved hosts file '" + filePath + "': " + std::strerror(errno)}; + } + + return {true, {}}; + } + +} // namespace startup diff --git a/src/startup/host_storage.h b/src/startup/host_storage.h new file mode 100644 index 0000000..7ead631 --- /dev/null +++ b/src/startup/host_storage.h @@ -0,0 +1,60 @@ +/** + * @file src/startup/host_storage.h + * @brief Declares saved host persistence. + */ +#pragma once + +// standard includes +#include +#include + +// local includes +#include "src/app/host_records.h" + +namespace startup { + + /** + * @brief Result of loading persisted saved hosts from disk. + */ + struct LoadSavedHostsResult { + std::vector hosts; ///< Loaded saved host records. + std::vector warnings; ///< Non-fatal warnings encountered while loading. + bool fileFound; ///< True when the host storage file existed. + }; + + /** + * @brief Result of saving the current saved-host list to disk. + */ + struct SaveSavedHostsResult { + bool success; ///< True when the host list was saved successfully. + std::string errorMessage; ///< Error detail when saving failed. + }; + + /** + * @brief Return the default path used for saved host storage. + * + * @return Default saved-host storage path. + */ + std::string default_host_storage_path(); + + /** + * @brief Load saved host records from disk. + * + * @param filePath Storage file to load. + * @return Loaded hosts plus any non-fatal warnings. + */ + LoadSavedHostsResult load_saved_hosts(const std::string &filePath = default_host_storage_path()); + + /** + * @brief Save the current saved host records to disk. + * + * @param hosts Saved host records to persist. + * @param filePath Storage file to write. + * @return Save result including success state and error detail. + */ + SaveSavedHostsResult save_saved_hosts( + const std::vector &hosts, + const std::string &filePath = default_host_storage_path() + ); + +} // namespace startup diff --git a/src/startup/memory_stats.cpp b/src/startup/memory_stats.cpp index 7d7390a..8ff15ff 100644 --- a/src/startup/memory_stats.cpp +++ b/src/startup/memory_stats.cpp @@ -1,8 +1,14 @@ +/** + * @file src/startup/memory_stats.cpp + * @brief Implements memory statistics helpers. + */ // class header include #include "src/startup/memory_stats.h" +// local includes +#include "src/logging/logger.h" + // nxdk includes -#include #include namespace startup { @@ -12,23 +18,32 @@ namespace startup { } // namespace - void log_memory_statistics() { + std::vector format_memory_statistics_lines() { MM_STATISTICS memoryStatistics {}; memoryStatistics.Length = sizeof(memoryStatistics); if (const NTSTATUS status = MmQueryStatistics(&memoryStatistics); !NT_SUCCESS(status)) { - debugPrint("Failed to query memory statistics. NTSTATUS: 0x%08lx\n", static_cast(status)); - return; + return { + "Failed to query memory statistics. NTSTATUS: 0x" + std::to_string(static_cast(status)), + }; } const unsigned long long totalPhysicalBytes = static_cast(memoryStatistics.TotalPhysicalPages) * PAGE_SIZE; const unsigned long long availableBytes = static_cast(memoryStatistics.AvailablePages) * PAGE_SIZE; const auto committedBytes = static_cast(memoryStatistics.VirtualMemoryBytesCommitted); - debugPrint("Memory statistics:\n"); - debugPrint("Total physical memory: %llu MiB (%lu pages)\n", totalPhysicalBytes / BYTES_PER_MEBIBYTE, memoryStatistics.TotalPhysicalPages); - debugPrint("Available memory: %llu MiB (%lu pages)\n", availableBytes / BYTES_PER_MEBIBYTE, memoryStatistics.AvailablePages); - debugPrint("Committed virtual memory: %llu MiB\n", committedBytes / BYTES_PER_MEBIBYTE); + return { + "Memory statistics:", + "Total physical memory: " + std::to_string(totalPhysicalBytes / BYTES_PER_MEBIBYTE) + " MiB (" + std::to_string(memoryStatistics.TotalPhysicalPages) + " pages)", + "Available memory: " + std::to_string(availableBytes / BYTES_PER_MEBIBYTE) + " MiB (" + std::to_string(memoryStatistics.AvailablePages) + " pages)", + "Committed virtual memory: " + std::to_string(committedBytes / BYTES_PER_MEBIBYTE) + " MiB", + }; + } + + void log_memory_statistics() { + for (const std::string &line : format_memory_statistics_lines()) { + logging::info("memory", line); + } } } // namespace startup diff --git a/src/startup/memory_stats.h b/src/startup/memory_stats.h index 10f3f13..cb86456 100644 --- a/src/startup/memory_stats.h +++ b/src/startup/memory_stats.h @@ -1,7 +1,25 @@ +/** + * @file src/startup/memory_stats.h + * @brief Declares memory statistics helpers. + */ #pragma once +// standard includes +#include +#include + namespace startup { + /** + * @brief Return formatted memory-statistics lines for startup diagnostics. + * + * @return Human-readable memory-statistics lines. + */ + std::vector format_memory_statistics_lines(); + + /** + * @brief Emit the current memory-statistics lines through the shared logger. + */ void log_memory_statistics(); } // namespace startup diff --git a/src/startup/saved_files.cpp b/src/startup/saved_files.cpp new file mode 100644 index 0000000..7b9e30e --- /dev/null +++ b/src/startup/saved_files.cpp @@ -0,0 +1,273 @@ +/** + * @file src/startup/saved_files.cpp + * @brief Implements saved file loading and cleanup helpers. + */ +// class header include +#include "src/startup/saved_files.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#if defined(_WIN32) || defined(NXDK) + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names +#else + #include + #include + #include +#endif + +// local includes +#include "src/app/settings_storage.h" +#include "src/logging/log_file.h" +#include "src/platform/error_utils.h" +#include "src/platform/filesystem_utils.h" +#include "src/startup/client_identity_storage.h" +#include "src/startup/cover_art_cache.h" +#include "src/startup/host_storage.h" + +namespace { + + constexpr const char *PAIRING_UNIQUE_ID_FILE_NAME = "uniqueid.dat"; + constexpr const char *PAIRING_CERTIFICATE_FILE_NAME = "client.pem"; + constexpr const char *PAIRING_PRIVATE_KEY_FILE_NAME = "key.pem"; + + struct ResolvedSavedFileCatalogConfig { + std::string hostStoragePath; + std::string settingsFilePath; + std::string logFilePath; + std::string pairingDirectory; + std::string coverArtCacheRoot; + }; + + using platform::append_error; + using platform::file_name_from_path; + using platform::join_path; + using platform::path_has_prefix; + using platform::try_get_file_size; + + std::string relative_path_from_root(const std::string &rootPath, const std::string &path) { + if (!path_has_prefix(path, rootPath)) { + return file_name_from_path(path); + } + + std::size_t offset = rootPath.size(); + while (offset < path.size() && (path[offset] == '\\' || path[offset] == '/')) { + ++offset; + } + return offset >= path.size() ? file_name_from_path(path) : path.substr(offset); + } + + ResolvedSavedFileCatalogConfig resolve_config(const startup::SavedFileCatalogConfig &config) { + return { + config.hostStoragePath.empty() ? startup::default_host_storage_path() : config.hostStoragePath, + config.settingsFilePath.empty() ? app::default_settings_path() : config.settingsFilePath, + config.logFilePath.empty() ? logging::default_log_file_path() : config.logFilePath, + config.pairingDirectory.empty() ? startup::default_client_identity_directory() : config.pairingDirectory, + config.coverArtCacheRoot.empty() ? startup::default_cover_art_cache_root() : config.coverArtCacheRoot, + }; + } + + void add_file_if_present( + std::vector *files, + std::unordered_map *seenPaths, + const std::string &path, + const std::string &displayName + ) { + if (files == nullptr || seenPaths == nullptr || path.empty()) { + return; + } + + if (seenPaths->find(path) != seenPaths->end()) { + return; + } + + std::uint64_t sizeBytes = 0; + if (!try_get_file_size(path, &sizeBytes)) { + return; + } + + seenPaths->emplace(path, true); + files->push_back({path, displayName, sizeBytes}); + } + + void append_directory_files( + std::vector *files, + std::unordered_map *seenPaths, + std::vector *warnings, + const std::string &rootPath, + const std::string &displayPrefix + ) { + if (files == nullptr || seenPaths == nullptr || rootPath.empty()) { + return; + } + +#if defined(_WIN32) || defined(NXDK) + WIN32_FIND_DATAA findData {}; + const std::string searchPattern = join_path(rootPath, "*"); + HANDLE handle = FindFirstFileA(searchPattern.c_str(), &findData); + if (handle == INVALID_HANDLE_VALUE) { + if (const DWORD errorCode = GetLastError(); errorCode != ERROR_FILE_NOT_FOUND && errorCode != ERROR_PATH_NOT_FOUND && warnings != nullptr) { + warnings->push_back("Failed to enumerate saved files in '" + rootPath + "': error " + std::to_string(errorCode)); + } + return; + } + + do { + const std::string entryName = findData.cFileName; + if (entryName == "." || entryName == "..") { + continue; + } + + const std::string entryPath = join_path(rootPath, entryName); + if ((findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0U) { + append_directory_files(files, seenPaths, warnings, entryPath, displayPrefix); + continue; + } + + const std::string relativePath = relative_path_from_root(rootPath, entryPath); + const std::string displayName = displayPrefix.empty() ? relativePath : join_path(displayPrefix, relativePath); + add_file_if_present(files, seenPaths, entryPath, displayName); + } while (FindNextFileA(handle, &findData) != 0); + + const DWORD lastError = GetLastError(); + FindClose(handle); + if (lastError != ERROR_NO_MORE_FILES && warnings != nullptr) { + warnings->push_back("Stopped enumerating saved files in '" + rootPath + "' early: error " + std::to_string(lastError)); + } +#else + DIR *directory = opendir(rootPath.c_str()); + if (directory == nullptr) { + if (errno != ENOENT && errno != ENOTDIR && warnings != nullptr) { + warnings->push_back("Failed to enumerate saved files in '" + rootPath + "': " + std::strerror(errno)); + } + return; + } + + errno = 0; + while (const dirent *entry = readdir(directory)) { + const std::string entryName = entry->d_name; + if (entryName == "." || entryName == "..") { + continue; + } + + const std::string entryPath = join_path(rootPath, entryName); + struct stat status {}; + if (stat(entryPath.c_str(), &status) != 0) { + if (warnings != nullptr) { + warnings->push_back("Failed to inspect saved file entry '" + entryPath + "': " + std::strerror(errno)); + } + continue; + } + + if (S_ISDIR(status.st_mode)) { + append_directory_files(files, seenPaths, warnings, entryPath, displayPrefix); + continue; + } + if (!S_ISREG(status.st_mode)) { + continue; + } + + const std::string relativePath = relative_path_from_root(rootPath, entryPath); + const std::string displayName = displayPrefix.empty() ? relativePath : join_path(displayPrefix, relativePath); + add_file_if_present(files, seenPaths, entryPath, displayName); + } + + const int readError = errno; + closedir(directory); + if (readError != 0 && warnings != nullptr) { + warnings->push_back("Stopped enumerating saved files in '" + rootPath + "' early: " + std::strerror(readError)); + } +#endif + } + + bool path_is_managed_saved_file(const std::string &path, const ResolvedSavedFileCatalogConfig &config) { + if (path == config.hostStoragePath || path == config.settingsFilePath || path == config.logFilePath) { + return true; + } + + if (const std::vector pairingFiles = { + join_path(config.pairingDirectory, PAIRING_UNIQUE_ID_FILE_NAME), + join_path(config.pairingDirectory, PAIRING_CERTIFICATE_FILE_NAME), + join_path(config.pairingDirectory, PAIRING_PRIVATE_KEY_FILE_NAME), + }; + std::find(pairingFiles.begin(), pairingFiles.end(), path) != pairingFiles.end()) { + return true; + } + + if (!config.coverArtCacheRoot.empty() && path_has_prefix(path, config.coverArtCacheRoot)) { + const std::size_t rootLength = config.coverArtCacheRoot.size(); + if (path.size() == rootLength) { + return false; + } + return path[rootLength] == '\\' || path[rootLength] == '/'; + } + + return false; + } + +} // namespace + +namespace startup { + + ListSavedFilesResult list_saved_files(const SavedFileCatalogConfig &config) { + const ResolvedSavedFileCatalogConfig resolvedConfig = resolve_config(config); + ListSavedFilesResult result {}; + std::unordered_map seenPaths; + + add_file_if_present(&result.files, &seenPaths, resolvedConfig.hostStoragePath, file_name_from_path(resolvedConfig.hostStoragePath)); + add_file_if_present(&result.files, &seenPaths, resolvedConfig.settingsFilePath, file_name_from_path(resolvedConfig.settingsFilePath)); + add_file_if_present(&result.files, &seenPaths, resolvedConfig.logFilePath, file_name_from_path(resolvedConfig.logFilePath)); + add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_UNIQUE_ID_FILE_NAME), join_path("pairing", PAIRING_UNIQUE_ID_FILE_NAME)); + add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_CERTIFICATE_FILE_NAME), join_path("pairing", PAIRING_CERTIFICATE_FILE_NAME)); + add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_PRIVATE_KEY_FILE_NAME), join_path("pairing", PAIRING_PRIVATE_KEY_FILE_NAME)); + append_directory_files(&result.files, &seenPaths, &result.warnings, resolvedConfig.coverArtCacheRoot, "cover-art-cache"); + + std::sort(result.files.begin(), result.files.end(), [](const SavedFileEntry &left, const SavedFileEntry &right) { + return left.displayName < right.displayName; + }); + return result; + } + + bool delete_saved_file(const std::string &path, std::string *errorMessage, const SavedFileCatalogConfig &config) { + const ResolvedSavedFileCatalogConfig resolvedConfig = resolve_config(config); + if (path.empty()) { + return append_error(errorMessage, "Saved file deletion requires a valid path"); + } + if (!path_is_managed_saved_file(path, resolvedConfig)) { + return append_error(errorMessage, "Refused to delete a file outside the Moonlight-managed storage set"); + } + + if (std::remove(path.c_str()) == 0) { + return true; + } + if (errno == ENOENT) { + return true; + } + return append_error(errorMessage, "Failed to delete saved file '" + path + "': " + std::strerror(errno)); + } + + bool delete_all_saved_files(std::string *errorMessage, const SavedFileCatalogConfig &config) { + const ListSavedFilesResult savedFiles = list_saved_files(config); + if (!savedFiles.warnings.empty()) { + return append_error(errorMessage, savedFiles.warnings.front()); + } + + for (const SavedFileEntry &savedFile : savedFiles.files) { + std::string deleteError; + if (!delete_saved_file(savedFile.path, &deleteError, config)) { + return append_error(errorMessage, deleteError); + } + } + + return true; + } + +} // namespace startup diff --git a/src/startup/saved_files.h b/src/startup/saved_files.h new file mode 100644 index 0000000..633c580 --- /dev/null +++ b/src/startup/saved_files.h @@ -0,0 +1,76 @@ +/** + * @file src/startup/saved_files.h + * @brief Declares saved file loading and cleanup helpers. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace startup { + + /** + * @brief Describes one Moonlight-managed file that exists on disk. + */ + struct SavedFileEntry { + std::string path; ///< Absolute or managed-relative path to the file on disk. + std::string displayName; ///< User-facing label shown in the settings UI. + std::uint64_t sizeBytes = 0; ///< File size in bytes. + }; + + /** + * @brief Optional path overrides used to inspect Moonlight-managed files. + */ + struct SavedFileCatalogConfig { + std::string hostStoragePath; ///< Path to the saved-host storage file. + std::string settingsFilePath; ///< Path to the persisted TOML settings file. + std::string logFilePath; ///< Path to the persisted log file. + std::string pairingDirectory; ///< Directory containing persisted pairing credentials. + std::string coverArtCacheRoot; ///< Root directory containing cached cover art artifacts. + }; + + /** + * @brief Result of enumerating Moonlight-managed files on disk. + */ + struct ListSavedFilesResult { + std::vector files; ///< Managed files currently found on disk. + std::vector warnings; ///< Non-fatal warnings produced during enumeration. + }; + + /** + * @brief Enumerate Moonlight-managed files that currently exist on disk. + * + * @param config Optional path overrides for tests or custom storage roots. + * @return Existing files plus any non-fatal enumeration warnings. + */ + ListSavedFilesResult list_saved_files(const SavedFileCatalogConfig &config = {}); + + /** + * @brief Delete one Moonlight-managed file. + * + * @param path Absolute or relative path returned by list_saved_files(). + * @param errorMessage Optional output for deletion failures. + * @param config Optional path overrides for tests or custom storage roots. + * @return true when the file was deleted or was already absent. + */ + bool delete_saved_file( + const std::string &path, + std::string *errorMessage = nullptr, + const SavedFileCatalogConfig &config = {} + ); + + /** + * @brief Delete every Moonlight-managed saved file currently present on disk. + * + * @param errorMessage Optional output for factory-reset failures. + * @param config Optional path overrides for tests or custom storage roots. + * @return true when all managed files were removed successfully. + */ + bool delete_all_saved_files( + std::string *errorMessage = nullptr, + const SavedFileCatalogConfig &config = {} + ); + +} // namespace startup diff --git a/src/startup/storage_paths.cpp b/src/startup/storage_paths.cpp new file mode 100644 index 0000000..aa12c7d --- /dev/null +++ b/src/startup/storage_paths.cpp @@ -0,0 +1,48 @@ +/** + * @file src/startup/storage_paths.cpp + * @brief Implements persistent storage path helpers. + */ +// class header include +#include "src/startup/storage_paths.h" + +// standard includes +#include + +// nxdk includes +#if defined(__has_include) && __has_include() + #include + #if __has_include() + #include + #endif + #include + #include + #include +#endif + +namespace startup { + + std::string title_scoped_storage_root() { +#if defined(__has_include) && __has_include() + #if __has_include() + if (!nxIsDriveMounted('E') && !nxMountDrive('E', "\\Device\\Harddisk0\\Partition1\\")) { + return {}; + } + #endif + + std::array titleIdBuffer {}; + std::snprintf(titleIdBuffer.data(), titleIdBuffer.size(), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); + return std::string("E:\\UDATA\\") + titleIdBuffer.data() + "\\"; +#else + return {}; +#endif + } + + std::string default_storage_path(std::string_view entryName) { + if (const std::string titleScopedRoot = title_scoped_storage_root(); !titleScopedRoot.empty()) { + return titleScopedRoot + std::string(entryName); + } + + return std::string(entryName); + } + +} // namespace startup diff --git a/src/startup/storage_paths.h b/src/startup/storage_paths.h new file mode 100644 index 0000000..bb3acc6 --- /dev/null +++ b/src/startup/storage_paths.h @@ -0,0 +1,35 @@ +/** + * @file src/startup/storage_paths.h + * @brief Declares persistent storage path helpers. + */ +#pragma once + +// standard includes +#include +#include + +namespace startup { + + /** + * @brief Return the title-scoped storage root used for Xbox save data. + * + * On nxdk builds this resolves the current title ID under `E:\UDATA\` and + * mounts the backing drive when needed. On host-native builds it returns an + * empty string. + * + * @return Title-scoped storage root, or an empty string when unavailable. + */ + std::string title_scoped_storage_root(); + + /** + * @brief Return the default storage path for a file or directory under Moonlight-managed storage. + * + * On nxdk builds this prefixes the supplied entry with the title-scoped save root. + * On host-native builds it returns the entry unchanged so tests can use relative paths. + * + * @param entryName File or directory name stored under the Moonlight data root. + * @return Fully qualified storage path, or the original entry name on host-native builds. + */ + std::string default_storage_path(std::string_view entryName); + +} // namespace startup diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index 78569a2..a5c664b 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -1,12 +1,32 @@ +/** + * @file src/startup/video_mode.cpp + * @brief Implements video mode selection helpers. + */ // class header include #include "src/startup/video_mode.h" -// nxdk includes -#include +// local includes +#include "src/logging/logger.h" namespace startup { + namespace { + + bool is_1080i_mode(const VIDEO_MODE &videoMode) { + return videoMode.width >= 1920 && videoMode.height >= 1080; + } + + } // namespace + bool is_preferred_video_mode(const VIDEO_MODE &candidateVideoMode, const VIDEO_MODE ¤tBestVideoMode) { + if (is_1080i_mode(candidateVideoMode) && !is_1080i_mode(currentBestVideoMode) && currentBestVideoMode.width >= 1280 && currentBestVideoMode.height >= 720) { + return false; + } + + if (!is_1080i_mode(candidateVideoMode) && is_1080i_mode(currentBestVideoMode) && candidateVideoMode.width >= 1280 && candidateVideoMode.height >= 720) { + return true; + } + if (candidateVideoMode.height < currentBestVideoMode.height) { return false; } @@ -52,14 +72,25 @@ namespace startup { return selection; } - void log_video_modes(const VideoModeSelection &selection) { - debugPrint("Available video modes:\n"); + std::vector format_video_mode_lines(const VideoModeSelection &selection) { + std::vector lines; + lines.reserve(selection.availableVideoModes.size() + 2U); + lines.emplace_back("Available video modes:"); for (const VIDEO_MODE &availableVideoMode : selection.availableVideoModes) { - debugPrint("Width: %d, Height: %d, BPP: %d, Refresh: %d\n", availableVideoMode.width, availableVideoMode.height, availableVideoMode.bpp, availableVideoMode.refresh); + lines.push_back( + "Width: " + std::to_string(availableVideoMode.width) + ", Height: " + std::to_string(availableVideoMode.height) + ", BPP: " + std::to_string(availableVideoMode.bpp) + ", Refresh: " + std::to_string(availableVideoMode.refresh) + ); } + lines.push_back( + "Best video mode: Width: " + std::to_string(selection.bestVideoMode.width) + ", Height: " + std::to_string(selection.bestVideoMode.height) + ", BPP: " + std::to_string(selection.bestVideoMode.bpp) + ", Refresh: " + std::to_string(selection.bestVideoMode.refresh) + ); + return lines; + } - debugPrint("Best video mode:\n"); - debugPrint("Width: %d, Height: %d, BPP: %d, Refresh: %d\n", selection.bestVideoMode.width, selection.bestVideoMode.height, selection.bestVideoMode.bpp, selection.bestVideoMode.refresh); + void log_video_modes(const VideoModeSelection &selection) { + for (const std::string &line : format_video_mode_lines(selection)) { + logging::info("video", line); + } } } // namespace startup diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index 772ee5b..0716f7b 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -1,6 +1,11 @@ +/** + * @file src/startup/video_mode.h + * @brief Declares video mode selection helpers. + */ #pragma once // standard includes +#include #include // nxdk includes @@ -10,37 +15,17 @@ namespace startup { /** * @brief Represents the set of available video modes and the chosen best mode. - * - * This structure is used during startup to collect detected video modes supported - * by the hardware and to store the best candidate selected by the detection - * algorithm. */ struct VideoModeSelection { - /** - * @brief List of detected, available video modes. - * - * The vector contains VIDEO_MODE entries returned by the platform video - * subsystem. The detection code fills this list and then chooses a - * preferred mode to populate @c bestVideoMode. - */ - std::vector availableVideoModes; - - /** - * @brief The selected best video mode from @c availableVideoModes. - * - * This is the mode chosen by the selection logic as the most suitable - * for the current configuration (color depth, refresh rate, etc.). If no - * suitable mode could be chosen, this value may be left as the default - * VIDEO_MODE value. - */ - VIDEO_MODE bestVideoMode; + std::vector availableVideoModes; ///< Detected video modes supported by the platform. + VIDEO_MODE bestVideoMode {}; ///< Best candidate selected from the detected modes. }; /** * @brief Return whether a candidate mode should replace the current best mode. * * A candidate must be at least as good as the current best mode in width, - * height, color depth, and refresh rate. + * height, color depth, and refresh rate, while preferring 720p over 1080i. * * @param candidateVideoMode The mode being evaluated. * @param currentBestVideoMode The currently selected best mode. @@ -52,26 +37,29 @@ namespace startup { * @brief Choose the best video mode from an already collected list. * * @param availableVideoModes The list of candidate modes to evaluate. - * @return The preferred mode, or a default-initialized VIDEO_MODE when the - * input list is empty. + * @return The preferred mode, or a default-initialized VIDEO_MODE when the input list is empty. */ VIDEO_MODE choose_best_video_mode(const std::vector &availableVideoModes); /** * @brief Detect and choose the best available video mode. * - * @param bpp Desired bits-per-pixel (color depth). Default is 32. - * @param refresh Desired refresh rate or REFRESH_DEFAULT to accept default. - * @return A VideoModeSelection containing the list of available modes and the - * selected best mode. + * @param bpp Desired bits-per-pixel color depth. Default is 32. + * @param refresh Desired refresh rate or REFRESH_DEFAULT to accept the default. + * @return A VideoModeSelection containing the detected modes and the selected best mode. */ VideoModeSelection select_best_video_mode(int bpp = 32, int refresh = REFRESH_DEFAULT); /** - * @brief Log information about available and selected video modes. + * @brief Return human-readable lines describing the detected and selected video modes. * - * This function emits diagnostic information (e.g., to console or platform - * log) about the contents of the provided VideoModeSelection. + * @param selection Video-mode selection to describe. + * @return Formatted diagnostic lines for startup logging or UI display. + */ + std::vector format_video_mode_lines(const VideoModeSelection &selection); + + /** + * @brief Log information about available and selected video modes. * * @param selection The selection object to log. */ diff --git a/src/streaming/stats_overlay.cpp b/src/streaming/stats_overlay.cpp new file mode 100644 index 0000000..c016bfa --- /dev/null +++ b/src/streaming/stats_overlay.cpp @@ -0,0 +1,88 @@ +/** + * @file src/streaming/stats_overlay.cpp + * @brief Implements the streaming statistics overlay. + */ +// class header include +#include "src/streaming/stats_overlay.h" + +// standard includes +#include +#include +#include + +namespace { + + bool has_metric(int value) { + return value >= 0; + } + + std::string join_segments(const std::vector &segments) { + std::ostringstream stream; + + for (std::size_t index = 0; index < segments.size(); ++index) { + if (index > 0) { + stream << " | "; + } + stream << segments[index]; + } + + return stream.str(); + } + +} // namespace + +namespace streaming { + + std::vector build_stats_overlay_lines(const StreamStatisticsSnapshot &snapshot) { + std::vector lines; + + { + std::ostringstream streamLine; + streamLine << "Stream: " << snapshot.width << "x" << snapshot.height << " @ " << snapshot.fps << " FPS"; + lines.push_back(streamLine.str()); + } + + std::vector latencySegments; + if (has_metric(snapshot.roundTripTimeMs)) { + latencySegments.push_back("RTT " + std::to_string(snapshot.roundTripTimeMs) + " ms"); + } + if (has_metric(snapshot.hostLatencyMs)) { + latencySegments.push_back("Host " + std::to_string(snapshot.hostLatencyMs) + " ms"); + } + if (has_metric(snapshot.decoderLatencyMs)) { + latencySegments.push_back("Decode " + std::to_string(snapshot.decoderLatencyMs) + " ms"); + } + if (!latencySegments.empty()) { + lines.push_back("Latency: " + join_segments(latencySegments)); + } + + std::vector queueSegments; + if (has_metric(snapshot.videoQueueDepth)) { + queueSegments.push_back("Video " + std::to_string(snapshot.videoQueueDepth) + " frames"); + } + if (has_metric(snapshot.audioQueueDurationMs)) { + queueSegments.push_back("Audio " + std::to_string(snapshot.audioQueueDurationMs) + " ms"); + } + if (!queueSegments.empty()) { + lines.push_back("Queues: " + join_segments(queueSegments)); + } + + std::vector packetSegments; + if (has_metric(snapshot.videoPacketsReceived)) { + packetSegments.push_back(std::to_string(snapshot.videoPacketsReceived) + " rx"); + } + if (has_metric(snapshot.videoPacketsRecovered)) { + packetSegments.push_back(std::to_string(snapshot.videoPacketsRecovered) + " recovered"); + } + if (has_metric(snapshot.videoPacketsLost)) { + packetSegments.push_back(std::to_string(snapshot.videoPacketsLost) + " lost"); + } + if (!packetSegments.empty()) { + lines.push_back("Video packets: " + join_segments(packetSegments)); + } + + lines.push_back(std::string("Connection: ") + (snapshot.poorConnection ? "Poor" : "Okay")); + return lines; + } + +} // namespace streaming diff --git a/src/streaming/stats_overlay.h b/src/streaming/stats_overlay.h new file mode 100644 index 0000000..cc2dcd8 --- /dev/null +++ b/src/streaming/stats_overlay.h @@ -0,0 +1,42 @@ +/** + * @file src/streaming/stats_overlay.h + * @brief Declares the streaming statistics overlay. + */ +#pragma once + +// standard includes +#include +#include + +namespace streaming { + + /** + * @brief Snapshot of stream telemetry shown in the on-screen stats overlay. + */ + struct StreamStatisticsSnapshot { + int width; ///< Stream width in pixels. + int height; ///< Stream height in pixels. + int fps; ///< Current stream frame rate. + int roundTripTimeMs; ///< End-to-end measured round-trip latency in milliseconds. + int hostLatencyMs; ///< Host-side processing latency in milliseconds. + int decoderLatencyMs; ///< Video decoder latency in milliseconds. + int videoQueueDepth; ///< Number of video frames buffered locally. + int audioQueueDurationMs; ///< Estimated buffered audio duration in milliseconds. + int videoPacketsReceived; ///< Total video packets received. + int videoPacketsRecovered; ///< Video packets recovered through FEC or retransmission. + int videoPacketsLost; ///< Video packets permanently lost. + bool poorConnection; ///< True when the host flags the connection as poor. + }; + + /** + * @brief Build text rows for the streaming statistics overlay. + * + * Negative metric values are treated as unavailable and omitted from the + * corresponding row. + * + * @param snapshot Telemetry values to display. + * @return Text rows ready for rendering. + */ + std::vector build_stats_overlay_lines(const StreamStatisticsSnapshot &snapshot); + +} // namespace streaming diff --git a/src/ui/host_probe_result_queue.cpp b/src/ui/host_probe_result_queue.cpp new file mode 100644 index 0000000..9cee8db --- /dev/null +++ b/src/ui/host_probe_result_queue.cpp @@ -0,0 +1,72 @@ +/** + * @file src/ui/host_probe_result_queue.cpp + * @brief Implements queued host probe results. + */ +// class header include +#include "src/ui/host_probe_result_queue.h" + +// standard includes +#include + +namespace ui { + + void reset_host_probe_result_queue(HostProbeResultQueue *queue) { + if (queue == nullptr) { + return; + } + + const std::scoped_lock lock(queue->mutex); + queue->targetCount = 0U; + queue->publishedCount = 0U; + queue->pendingResults.clear(); + } + + void begin_host_probe_result_round(HostProbeResultQueue *queue, std::size_t targetCount) { + if (queue == nullptr) { + return; + } + + const std::scoped_lock lock(queue->mutex); + queue->targetCount = targetCount; + queue->publishedCount = 0U; + queue->pendingResults.clear(); + } + + void publish_host_probe_result(HostProbeResultQueue *queue, HostProbeResult result) { + if (queue == nullptr) { + return; + } + + const std::scoped_lock lock(queue->mutex); + queue->pendingResults.push_back(std::move(result)); + ++queue->publishedCount; + } + + void skip_host_probe_result_target(HostProbeResultQueue *queue) { + if (queue == nullptr) { + return; + } + + const std::scoped_lock lock(queue->mutex); + if (queue->targetCount > 0U) { + --queue->targetCount; + } + } + + std::vector drain_host_probe_results(HostProbeResultQueue *queue) { + if (queue == nullptr) { + return {}; + } + + const std::scoped_lock lock(queue->mutex); + std::vector results; + results.swap(queue->pendingResults); + return results; + } + + bool host_probe_result_round_complete(const HostProbeResultQueue &queue) { + const std::scoped_lock lock(queue.mutex); + return queue.targetCount != 0U && queue.publishedCount >= queue.targetCount; + } + +} // namespace ui diff --git a/src/ui/host_probe_result_queue.h b/src/ui/host_probe_result_queue.h new file mode 100644 index 0000000..be344b7 --- /dev/null +++ b/src/ui/host_probe_result_queue.h @@ -0,0 +1,88 @@ +/** + * @file src/ui/host_probe_result_queue.h + * @brief Declares queued host probe results. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "src/network/host_pairing.h" + +namespace ui { + + /** + * @brief One completed saved-host reachability probe. + */ + struct HostProbeResult { + std::string address; ///< Saved host address that was probed. + uint16_t port = 0; ///< Effective host port used for the probe. + bool success = false; ///< True when the host responded to the probe. + network::HostPairingServerInfo serverInfo; ///< Parsed host status returned by the probe when available. + }; + + /** + * @brief Thread-safe queue used to publish per-host probe results back to the shell loop. + */ + struct HostProbeResultQueue { + mutable std::mutex mutex; ///< Guards the current round counters and pending result queue. + std::size_t targetCount = 0U; ///< Number of results expected for the active probe round. + std::size_t publishedCount = 0U; ///< Number of results published so far for the active round. + std::vector pendingResults; ///< Probe results waiting to be drained by the main thread. + }; + + /** + * @brief Reset the queued probe results and counters for a queue. + * + * @param queue Queue to clear. + */ + void reset_host_probe_result_queue(HostProbeResultQueue *queue); + + /** + * @brief Start a fresh probe round with a known number of expected results. + * + * @param queue Queue that will receive probe results. + * @param targetCount Number of probe results expected for the new round. + */ + void begin_host_probe_result_round(HostProbeResultQueue *queue, std::size_t targetCount); + + /** + * @brief Publish one completed host probe into the queue. + * + * @param queue Queue that receives the probe result. + * @param result Completed host probe to append. + */ + void publish_host_probe_result(HostProbeResultQueue *queue, HostProbeResult result); + + /** + * @brief Remove one expected result from the active probe round. + * + * Use this when a planned probe worker could not be launched and therefore will + * never publish a result. + * + * @param queue Queue whose expected result count should be reduced. + */ + void skip_host_probe_result_target(HostProbeResultQueue *queue); + + /** + * @brief Drain every probe result currently waiting in the queue. + * + * @param queue Queue whose pending results should be removed. + * @return Completed probe results published since the previous drain. + */ + std::vector drain_host_probe_results(HostProbeResultQueue *queue); + + /** + * @brief Return whether the active probe round has published every expected result. + * + * @param queue Queue to inspect. + * @return True when the current round has received every expected result. + */ + bool host_probe_result_round_complete(const HostProbeResultQueue &queue); + +} // namespace ui diff --git a/src/ui/menu_model.cpp b/src/ui/menu_model.cpp new file mode 100644 index 0000000..2f37670 --- /dev/null +++ b/src/ui/menu_model.cpp @@ -0,0 +1,123 @@ +/** + * @file src/ui/menu_model.cpp + * @brief Implements menu model structures and helpers. + */ +// class header include +#include "src/ui/menu_model.h" + +// standard includes +#include + +namespace ui { + + MenuModel::MenuModel(std::vector items) { + set_items(std::move(items)); + } + + void MenuModel::set_items(std::vector items) { + items_ = std::move(items); + selectedIndex_ = find_first_enabled_index(); + } + + const std::vector &MenuModel::items() const { + return items_; + } + + std::size_t MenuModel::selected_index() const { + return selectedIndex_; + } + + const MenuItem *MenuModel::selected_item() const { + if (selectedIndex_ == npos || selectedIndex_ >= items_.size()) { + return nullptr; + } + + return &items_[selectedIndex_]; + } + + bool MenuModel::select_item_by_id(std::string_view itemId) { + for (std::size_t index = 0; index < items_.size(); ++index) { + if (items_[index].enabled && items_[index].id == itemId) { + const bool changed = index != selectedIndex_; + selectedIndex_ = index; + return changed; + } + } + + return false; + } + + MenuUpdate MenuModel::handle_command(input::UiCommand command) { + MenuUpdate update {}; + + switch (command) { + case input::UiCommand::move_up: + update.selectionChanged = move_selection(-1); + break; + case input::UiCommand::move_down: + update.selectionChanged = move_selection(1); + break; + case input::UiCommand::activate: + case input::UiCommand::confirm: + if (const MenuItem *item = selected_item(); item != nullptr && item->enabled) { + update.activationRequested = true; + update.activatedItemId = item->id; + } + break; + case input::UiCommand::back: + update.backRequested = true; + break; + case input::UiCommand::previous_page: + case input::UiCommand::fast_previous_page: + update.previousPageRequested = true; + break; + case input::UiCommand::next_page: + case input::UiCommand::fast_next_page: + update.nextPageRequested = true; + break; + case input::UiCommand::toggle_overlay: + update.overlayToggleRequested = true; + break; + case input::UiCommand::none: + case input::UiCommand::open_context_menu: + case input::UiCommand::delete_character: + case input::UiCommand::move_left: + case input::UiCommand::move_right: + break; + } + + return update; + } + + bool MenuModel::move_selection(int direction) { + if (items_.empty() || selectedIndex_ == npos) { + return false; + } + + const std::size_t itemCount = items_.size(); + std::size_t candidateIndex = selectedIndex_; + + for (std::size_t visited = 0; visited < itemCount; ++visited) { + candidateIndex = direction < 0 ? (candidateIndex + itemCount - 1) % itemCount : (candidateIndex + 1) % itemCount; + + if (items_[candidateIndex].enabled) { + const bool changed = candidateIndex != selectedIndex_; + selectedIndex_ = candidateIndex; + return changed; + } + } + + return false; + } + + std::size_t MenuModel::find_first_enabled_index() const { + for (std::size_t index = 0; index < items_.size(); ++index) { + if (items_[index].enabled) { + return index; + } + } + + return npos; + } + +} // namespace ui diff --git a/src/ui/menu_model.h b/src/ui/menu_model.h new file mode 100644 index 0000000..3544bc3 --- /dev/null +++ b/src/ui/menu_model.h @@ -0,0 +1,111 @@ +/** + * @file src/ui/menu_model.h + * @brief Declares menu model structures and helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include +#include + +// local includes +#include "src/input/navigation_input.h" + +namespace ui { + + /** + * @brief Item shown in a focus-driven menu. + */ + struct MenuItem { + std::string id; ///< Stable identifier used by reducers and view builders. + std::string label; ///< User-facing label shown in the menu. + std::string description; ///< Helper copy explaining what the item changes or activates. + bool enabled; ///< True when the item can be selected and activated. + }; + + /** + * @brief Result of applying a UI command to a menu. + */ + struct MenuUpdate { + bool selectionChanged; ///< True when the focused item changed. + bool activationRequested; ///< True when the selected item should be activated. + bool backRequested; ///< True when the caller should navigate back. + bool previousPageRequested; ///< True when the caller should move to the previous page. + bool nextPageRequested; ///< True when the caller should move to the next page. + bool overlayToggleRequested; ///< True when the caller should toggle the diagnostics overlay. + std::string activatedItemId; ///< Stable identifier for the activated item, when any. + }; + + /** + * @brief Menu state that supports controller and keyboard navigation. + */ + class MenuModel { + public: + /** + * @brief Sentinel index used when no menu item is selectable. + */ + static constexpr std::size_t npos = std::numeric_limits::max(); + + /** + * @brief Construct a menu from a list of items. + * + * @param items Menu items in display order. + */ + explicit MenuModel(std::vector items = {}); + + /** + * @brief Replace the menu items and select the first enabled entry. + * + * @param items Menu items in display order. + */ + void set_items(std::vector items); + + /** + * @brief Return the configured items. + * + * @return Immutable view of the menu items in display order. + */ + [[nodiscard]] const std::vector &items() const; + + /** + * @brief Return the selected item index or npos when none is selectable. + * + * @return Selected item index or npos. + */ + [[nodiscard]] std::size_t selected_index() const; + + /** + * @brief Return the selected item or nullptr when none is selectable. + * + * @return Pointer to the selected item, or nullptr when unavailable. + */ + [[nodiscard]] const MenuItem *selected_item() const; + + /** + * @brief Select a specific enabled item by its stable identifier. + * + * @param itemId Identifier to select. + * @return true when the selection changed. + */ + bool select_item_by_id(std::string_view itemId); + + /** + * @brief Apply a UI command to the menu. + * + * @param command Command from controller or keyboard input. + * @return Details about selection changes and passthrough actions. + */ + MenuUpdate handle_command(input::UiCommand command); + + private: + bool move_selection(int direction); + [[nodiscard]] std::size_t find_first_enabled_index() const; + + std::vector items_; + std::size_t selectedIndex_ = npos; + }; + +} // namespace ui diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp new file mode 100644 index 0000000..e45fcf6 --- /dev/null +++ b/src/ui/shell_screen.cpp @@ -0,0 +1,4581 @@ +/** + * @file src/ui/shell_screen.cpp + * @brief Implements the shell screen controller. + */ +// class header include +#include "src/ui/shell_screen.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// nxdk includes +#include +#include +#include +#include +#include +#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names + +// local includes +#include "src/app/settings_storage.h" +#include "src/input/navigation_input.h" +#include "src/logging/log_file.h" +#include "src/logging/logger.h" +#include "src/network/host_pairing.h" +#include "src/network/runtime_network.h" +#include "src/os.h" +#include "src/platform/error_utils.h" +#include "src/platform/filesystem_utils.h" +#include "src/splash/splash_layout.h" +#include "src/startup/client_identity_storage.h" +#include "src/startup/cover_art_cache.h" +#include "src/startup/host_storage.h" +#include "src/startup/saved_files.h" +#include "src/ui/host_probe_result_queue.h" +#include "src/ui/shell_view.h" + +namespace { + + constexpr std::size_t PAIRING_THREAD_STACK_SIZE = 1024U * 1024U; + constexpr Uint32 HOST_PROBE_REFRESH_INTERVAL_MILLISECONDS = 10000U; + constexpr Uint32 APP_LIST_REFRESH_INTERVAL_MILLISECONDS = 30000U; + + constexpr Uint8 BACKGROUND_RED = 0x10; + constexpr Uint8 BACKGROUND_GREEN = 0x12; + constexpr Uint8 BACKGROUND_BLUE = 0x16; + constexpr Uint8 PANEL_RED = 0x24; + constexpr Uint8 PANEL_GREEN = 0x25; + constexpr Uint8 PANEL_BLUE = 0x27; + constexpr Uint8 PANEL_ALT_RED = 0x1C; + constexpr Uint8 PANEL_ALT_GREEN = 0x1D; + constexpr Uint8 PANEL_ALT_BLUE = 0x20; + constexpr Uint8 ACCENT_RED = 0x00; + constexpr Uint8 ACCENT_GREEN = 0xF3; + constexpr Uint8 ACCENT_BLUE = 0xD4; + constexpr Uint8 TEXT_RED = 0xF2; + constexpr Uint8 TEXT_GREEN = 0xF5; + constexpr Uint8 TEXT_BLUE = 0xF8; + constexpr Uint8 MUTED_RED = 0xA3; + constexpr Uint8 MUTED_GREEN = 0xAB; + constexpr Uint8 MUTED_BLUE = 0xB5; + constexpr Sint16 TRIGGER_PAGE_SCROLL_THRESHOLD = 16000; + constexpr Sint16 LEFT_STICK_NAVIGATION_THRESHOLD = 16000; + constexpr Sint16 LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD = 12000; + constexpr Uint32 EXIT_COMBO_HOLD_MILLISECONDS = 900U; + constexpr Uint32 CONTROLLER_NAVIGATION_INITIAL_REPEAT_MILLISECONDS = 150U; + constexpr Uint32 CONTROLLER_NAVIGATION_REPEAT_MILLISECONDS = 45U; + constexpr Uint32 LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS = 110U; + constexpr Uint32 LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS = 45U; + constexpr int SHELL_EVENT_WAIT_TIMEOUT_MILLISECONDS = 2; + constexpr std::size_t LOG_VIEWER_MAX_LOADED_LINES = 512U; + constexpr std::size_t LOG_VIEWER_MAX_RENDER_CHARACTERS = 320U; + + std::string build_asset_path(const char *relativePath) { + return std::string(DATA_PATH) + "assets" + PATH_SEP + relativePath; + } + + bool asset_path_uses_svg(const char *relativePath) { + if (relativePath == nullptr) { + return false; + } + + const std::string path(relativePath); + return path.size() >= 4U && path.substr(path.size() - 4U) == ".svg"; + } + + bool asset_path_is_svg_icon(const char *relativePath) { + if (!asset_path_uses_svg(relativePath)) { + return false; + } + + const std::string path(relativePath); + return path.rfind("icons\\", 0U) == 0U; + } + + /** + * @brief Build an asset texture cache key, including raster dimensions for size-aware SVG textures. + * + * @param relativePath Asset path relative to `DATA_PATH/assets`. + * @param maxWidth Requested maximum raster width in pixels. + * @param maxHeight Requested maximum raster height in pixels. + * @return Cache key used to store or look up the texture. + */ + std::string build_asset_texture_cache_key(const std::string &relativePath, int maxWidth = 0, int maxHeight = 0) { + if (relativePath.empty() || maxWidth <= 0 || maxHeight <= 0 || !asset_path_uses_svg(relativePath.c_str())) { + return relativePath; + } + + return relativePath + "#" + std::to_string(maxWidth) + "x" + std::to_string(maxHeight); + } + + /** + * @brief Calculate the largest integer size that fits inside a destination rectangle. + * + * @param sourceWidth Source asset width. + * @param sourceHeight Source asset height. + * @param maxWidth Maximum destination width. + * @param maxHeight Maximum destination height. + * @param fittedWidth Receives the fitted width. + * @param fittedHeight Receives the fitted height. + */ + void calculate_fitted_dimensions(double sourceWidth, double sourceHeight, int maxWidth, int maxHeight, int *fittedWidth, int *fittedHeight) { + if (fittedWidth != nullptr) { + *fittedWidth = 0; + } + if (fittedHeight != nullptr) { + *fittedHeight = 0; + } + if (sourceWidth <= 0.0 || sourceHeight <= 0.0 || maxWidth <= 0 || maxHeight <= 0) { + return; + } + + int targetWidth = maxWidth; + int targetHeight = maxHeight; + if ((sourceWidth * static_cast(maxHeight)) > (sourceHeight * static_cast(maxWidth))) { + targetHeight = std::max(1, static_cast((sourceHeight * static_cast(maxWidth)) / sourceWidth)); + } else { + targetWidth = std::max(1, static_cast((sourceWidth * static_cast(maxHeight)) / sourceHeight)); + } + + if (fittedWidth != nullptr) { + *fittedWidth = targetWidth; + } + if (fittedHeight != nullptr) { + *fittedHeight = targetHeight; + } + } + + SDL_Surface *normalize_asset_surface(SDL_Surface *surface) { + if (surface == nullptr) { + return nullptr; + } + + SDL_Surface *normalizedSurface = SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_ARGB8888, 0); + if (normalizedSurface == nullptr) { + SDL_FreeSurface(surface); + return nullptr; + } + + SDL_FreeSurface(surface); + if (SDL_SetSurfaceBlendMode(normalizedSurface, SDL_BLENDMODE_BLEND) != 0) { + SDL_FreeSurface(normalizedSurface); + return nullptr; + } + + return normalizedSurface; + } + + SDL_Surface *prepare_asset_surface(SDL_Surface *surface) { + return normalize_asset_surface(surface); + } + + SDL_Texture *load_texture_from_asset(SDL_Renderer *renderer, const char *relativePath, int maxWidth, int maxHeight); + + /** + * @brief Load and rasterize an SVG asset so it matches the requested destination bounds. + * + * @param relativePath Asset path relative to `DATA_PATH/assets`. + * @param maxWidth Maximum raster width in pixels. + * @param maxHeight Maximum raster height in pixels. + * @return Rasterized 32-bit RGBA surface sized to fit within the destination bounds. + */ + SDL_Surface *load_svg_surface_from_asset(const char *relativePath, int maxWidth, int maxHeight) { + if (!asset_path_uses_svg(relativePath) || maxWidth <= 0 || maxHeight <= 0) { + return nullptr; + } + + const std::string assetPath = build_asset_path(relativePath); + SDL_RWops *rw = SDL_RWFromFile(assetPath.c_str(), "rb"); + if (rw == nullptr) { + return nullptr; + } + + size_t dataSize = 0U; + auto *data = static_cast(SDL_LoadFile_RW(rw, &dataSize, 1)); + if (data == nullptr) { + return nullptr; + } + + NSVGimage *image = nsvgParse(data, "px", 96.0f); + SDL_free(data); + if (image == nullptr) { + return nullptr; + } + + int targetWidth = 0; + int targetHeight = 0; + calculate_fitted_dimensions(image->width, image->height, maxWidth, maxHeight, &targetWidth, &targetHeight); + if (targetWidth <= 0 || targetHeight <= 0) { + nsvgDelete(image); + return nullptr; + } + + NSVGrasterizer *rasterizer = nsvgCreateRasterizer(); + if (rasterizer == nullptr) { + nsvgDelete(image); + return nullptr; + } + + SDL_Surface *surface = SDL_CreateRGBSurface( + SDL_SWSURFACE, + targetWidth, + targetHeight, + 32, + 0x000000FF, + 0x0000FF00, + 0x00FF0000, + 0xFF000000 + ); + if (surface == nullptr) { + nsvgDeleteRasterizer(rasterizer); + nsvgDelete(image); + return nullptr; + } + + const bool mustLockSurface = SDL_MUSTLOCK(surface) != 0; + if (mustLockSurface && SDL_LockSurface(surface) != 0) { + SDL_FreeSurface(surface); + nsvgDeleteRasterizer(rasterizer); + nsvgDelete(image); + return nullptr; + } + + SDL_memset(surface->pixels, 0, static_cast(surface->pitch) * static_cast(surface->h)); + const float rasterScale = std::min(static_cast(targetWidth) / image->width, static_cast(targetHeight) / image->height); + nsvgRasterize(rasterizer, image, 0.0f, 0.0f, rasterScale, static_cast(surface->pixels), surface->w, surface->h, surface->pitch); + + if (mustLockSurface) { + SDL_UnlockSurface(surface); + } + + nsvgDeleteRasterizer(rasterizer); + nsvgDelete(image); + + if (SDL_SetSurfaceBlendMode(surface, SDL_BLENDMODE_BLEND) != 0) { + SDL_FreeSurface(surface); + return nullptr; + } + + return surface; + } + + SDL_Texture *create_texture_from_surface_with_scale_quality(SDL_Renderer *renderer, SDL_Surface *surface, const char *scaleQualityHint) { + if (renderer == nullptr || surface == nullptr || scaleQualityHint == nullptr) { + return nullptr; + } + + const char *previousHint = SDL_GetHint(SDL_HINT_RENDER_SCALE_QUALITY); + const std::string previousHintValue = previousHint == nullptr ? std::string {} : std::string(previousHint); + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, scaleQualityHint); + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, previousHintValue.empty() ? "0" : previousHintValue.c_str()); + return texture; + } + + int report_shell_failure(const char *category, const std::string &message) { + logging::error(category, message); + logging::warn(category, "Holding the failure screen for 5 seconds before exit."); + Sleep(5000); + return 1; + } + + void destroy_texture(SDL_Texture *texture) { + if (texture != nullptr) { + SDL_DestroyTexture(texture); + } + } + + bool render_surface_line(SDL_Renderer *renderer, SDL_Surface *surface, int x, int y, int *drawnHeight) { + if (renderer == nullptr || surface == nullptr) { + return false; + } + + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + if (texture == nullptr) { + SDL_FreeSurface(surface); + return false; + } + + SDL_Rect destination {x, y, surface->w, surface->h}; + SDL_FreeSurface(surface); + const int renderResult = SDL_RenderCopy(renderer, texture, nullptr, &destination); + destroy_texture(texture); + + if (drawnHeight != nullptr) { + *drawnHeight = destination.h; + } + + return renderResult == 0; + } + + /** + * @brief Caches cover-art textures keyed by the app art cache key. + */ + struct CoverArtTextureCache { + std::unordered_map textures; ///< Loaded cover-art textures by cache key. + std::unordered_map failedKeys; ///< Cache keys that already failed to load. + }; + + /** + * @brief Caches UI asset textures keyed by asset path. + */ + struct AssetTextureCache { + std::unordered_map textures; ///< Loaded asset textures by path. + std::unordered_map failedKeys; ///< Asset paths that already failed to load. + }; + + /** + * @brief Stores a reusable rendered text texture and the inputs that produced it. + */ + struct CachedTextTexture { + SDL_Texture *texture = nullptr; ///< Cached SDL texture for the rendered text. + TTF_Font *font = nullptr; ///< Font used to build the cached texture. + std::string sourceText; ///< Original uncropped text source. + std::string renderedText; ///< Sanitized or cropped text actually rendered into the texture. + int maxWidth = 0; ///< Maximum width used when generating the cached texture. + int width = 0; ///< Cached texture width in pixels. + int height = 0; ///< Cached texture height in pixels. + SDL_Color color {0x00, 0x00, 0x00, 0x00}; ///< Text color used when rendering the cached texture. + bool wrapped = false; ///< True when the texture was generated with wrapped rendering. + }; + + /** + * @brief Stores reusable layout and text textures for the add-host keypad modal. + */ + struct KeypadModalLayoutCache { + int modalInnerWidth = 0; ///< Last modal content width used to build the cached line layout. + int modalTextHeight = 0; ///< Cached combined height of the modal body text. + std::vector lines; ///< Last body lines used to populate the cache. + CachedTextTexture titleTexture; ///< Cached rendered title texture. + std::vector lineTextures; ///< Cached rendered body line textures. + std::vector buttonLabelTextures; ///< Cached rendered keypad button labels. + }; + + bool render_footer_actions( + SDL_Renderer *renderer, + TTF_Font *font, + AssetTextureCache *assetCache, + const std::vector &actions, + const SDL_Rect &footerRect + ); + + void clear_cover_art_texture_cache(CoverArtTextureCache *cache) { + if (cache == nullptr) { + return; + } + + for (const auto &[cacheKey, texture] : cache->textures) { + (void) cacheKey; + destroy_texture(texture); + } + cache->textures.clear(); + cache->failedKeys.clear(); + } + + void clear_cover_art_texture(CoverArtTextureCache *cache, const std::string &cacheKey) { + if (cache == nullptr || cacheKey.empty()) { + return; + } + + if (const auto textureIterator = cache->textures.find(cacheKey); textureIterator != cache->textures.end()) { + destroy_texture(textureIterator->second); + cache->textures.erase(textureIterator); + } + cache->failedKeys.erase(cacheKey); + } + + void clear_asset_texture_cache(AssetTextureCache *cache) { + if (cache == nullptr) { + return; + } + + for (const auto &[assetPath, texture] : cache->textures) { + (void) assetPath; + destroy_texture(texture); + } + cache->textures.clear(); + cache->failedKeys.clear(); + } + + /** + * @brief Returns whether two SDL colors are identical. + * + * @param left First color to compare. + * @param right Second color to compare. + * @return True when all RGBA components match. + */ + bool colors_match(SDL_Color left, SDL_Color right) { + return left.r == right.r && left.g == right.g && left.b == right.b && left.a == right.a; + } + + /** + * @brief Releases a cached text texture and resets its metadata. + * + * @param cache Cached text entry to clear. + */ + void clear_cached_text_texture(CachedTextTexture *cache) { + if (cache == nullptr) { + return; + } + + destroy_texture(cache->texture); + *cache = {}; + } + + /** + * @brief Releases every cached text texture stored for the keypad modal. + * + * @param cache Keypad modal cache to clear. + */ + void clear_keypad_modal_layout_cache(KeypadModalLayoutCache *cache) { + if (cache == nullptr) { + return; + } + + clear_cached_text_texture(&cache->titleTexture); + for (CachedTextTexture &lineTexture : cache->lineTextures) { + clear_cached_text_texture(&lineTexture); + } + for (CachedTextTexture &buttonLabelTexture : cache->buttonLabelTextures) { + clear_cached_text_texture(&buttonLabelTexture); + } + cache->lineTextures.clear(); + cache->buttonLabelTextures.clear(); + cache->modalInnerWidth = 0; + cache->modalTextHeight = 0; + cache->lines.clear(); + } + + Uint32 color_seed(std::string_view text) { + Uint32 value = 2166136261U; + for (char character : text) { + value ^= static_cast(character); + value *= 16777619U; + } + return value; + } + + SDL_Color placeholder_color(std::string_view seedText) { + const Uint32 seed = color_seed(seedText); + return { + static_cast(0x40 + (seed & 0x3F)), + static_cast(0x50 + ((seed >> 8U) & 0x4F)), + static_cast(0x70 + ((seed >> 16U) & 0x5F)), + 0xFF, + }; + } + + std::string sanitize_text_for_render(std::string_view text) { + std::string sanitized; + sanitized.reserve(text.size()); + for (std::size_t index = 0; index < text.size(); ++index) { // NOSONAR(cpp:S886) UTF-8 validation needs explicit cursor control + const auto character = static_cast(text[index]); + if (character == '\r' || character == '\n') { + continue; + } + if (character == '\t') { + sanitized.append(" "); + continue; + } + if (character < 0x80) { + if (character < 0x20) { + sanitized.push_back('?'); + continue; + } + sanitized.push_back(static_cast(character)); + continue; + } + + std::size_t sequenceLength = 0U; + if ((character & 0xE0U) == 0xC0U) { // NOSONAR(cpp:S6022) UTF-8 parsing is byte-oriented by design + sequenceLength = 2U; + } else if ((character & 0xF0U) == 0xE0U) { // NOSONAR(cpp:S6022) UTF-8 parsing is byte-oriented by design + sequenceLength = 3U; + } else if ((character & 0xF8U) == 0xF0U) { // NOSONAR(cpp:S6022) UTF-8 parsing is byte-oriented by design + sequenceLength = 4U; + } + + const bool sequenceAvailable = sequenceLength > 0U && index + sequenceLength <= text.size(); + bool sequenceValid = sequenceAvailable; + for (std::size_t continuationIndex = 1U; sequenceValid && continuationIndex < sequenceLength; ++continuationIndex) { // NOSONAR(cpp:S886) UTF-8 validation needs explicit cursor control + const auto continuation = static_cast(text[index + continuationIndex]); + sequenceValid = (continuation & 0xC0U) == 0x80U; // NOSONAR(cpp:S6022) UTF-8 parsing is byte-oriented by design + } + + if (sequenceValid) { + sanitized.append(text.substr(index, sequenceLength)); + index += sequenceLength - 1U; + continue; + } + + sanitized.push_back('?'); + } + return sanitized; + } + + std::string truncate_text_for_render(std::string_view text, std::size_t maxCharacters) { + if (maxCharacters == 0U || text.size() <= maxCharacters) { + return std::string(text); + } + + if (maxCharacters <= 3U) { + return std::string(maxCharacters, '.'); + } + + return std::string(text.substr(0, maxCharacters - 3U)) + "..."; + } + + std::string sanitize_ascii_text_for_render(std::string_view text) { + std::string sanitized; + sanitized.reserve(text.size()); + for (const unsigned char character : text) { + if (character == '\r' || character == '\n') { + continue; + } + if (character == '\t') { + sanitized.append(" "); + continue; + } + if (character >= 0x20 && character <= 0x7E) { + sanitized.push_back(static_cast(character)); + continue; + } + sanitized.push_back('?'); + } + return sanitized; + } + + std::string app_monogram(const ui::ShellAppTile &tile) { + for (char character : tile.name) { + if ((character >= 'A' && character <= 'Z') || (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9')) { + return std::string(1, static_cast(std::toupper(static_cast(character)))); + } + } + + return "?"; + } + + bool render_default_app_cover( + SDL_Renderer *renderer, + TTF_Font *labelFont, + const ui::ShellAppTile &tile, + const SDL_Rect &rect, + const AssetTextureCache *assetCache + ); + + SDL_Texture *load_cover_art_texture(SDL_Renderer *renderer, CoverArtTextureCache *cache, const std::string &cacheKey) { + if (renderer == nullptr || cache == nullptr || cacheKey.empty()) { + return nullptr; + } + + if (const auto existingTexture = cache->textures.find(cacheKey); existingTexture != cache->textures.end()) { + return existingTexture->second; + } + if (cache->failedKeys.find(cacheKey) != cache->failedKeys.end()) { + return nullptr; + } + + const startup::LoadCoverArtResult loadResult = startup::load_cover_art(cacheKey); + if (!loadResult.fileFound || loadResult.bytes.empty()) { + cache->failedKeys[cacheKey] = true; + return nullptr; + } + + SDL_RWops *rw = SDL_RWFromConstMem(loadResult.bytes.data(), static_cast(loadResult.bytes.size())); + if (rw == nullptr) { + cache->failedKeys[cacheKey] = true; + return nullptr; + } + + SDL_Surface *surface = IMG_Load_RW(rw, 1); + if (surface == nullptr) { + cache->failedKeys[cacheKey] = true; + return nullptr; + } + + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (texture == nullptr) { + cache->failedKeys[cacheKey] = true; + return nullptr; + } + + cache->textures.try_emplace(cacheKey, texture); + return texture; + } + + /** + * @brief Load an asset texture, optionally rasterizing SVG assets to requested bounds. + * + * @param renderer SDL renderer that will own the texture. + * @param relativePath Asset path relative to `DATA_PATH/assets`. + * @param maxWidth Optional maximum raster width used for SVG assets. + * @param maxHeight Optional maximum raster height used for SVG assets. + * @return Loaded texture, or null when the asset could not be prepared. + */ + SDL_Texture *load_texture_from_asset(SDL_Renderer *renderer, const char *relativePath, int maxWidth = 0, int maxHeight = 0) { + if (renderer == nullptr || relativePath == nullptr) { + return nullptr; + } + + SDL_Surface *surface = (maxWidth > 0 && maxHeight > 0 && asset_path_uses_svg(relativePath)) ? load_svg_surface_from_asset(relativePath, maxWidth, maxHeight) : nullptr; + if (surface == nullptr) { + const std::string assetPath = build_asset_path(relativePath); + surface = IMG_Load(assetPath.c_str()); + } + if (surface == nullptr) { + return nullptr; + } + + surface = prepare_asset_surface(surface); + if (surface == nullptr) { + return nullptr; + } + + SDL_Texture *texture = create_texture_from_surface_with_scale_quality(renderer, surface, asset_path_is_svg_icon(relativePath) ? "0" : "1"); + SDL_FreeSurface(surface); + return texture; + } + + SDL_Texture *load_cached_asset_texture(SDL_Renderer *renderer, AssetTextureCache *cache, const std::string &relativePath, int maxWidth = 0, int maxHeight = 0) { + if (renderer == nullptr || cache == nullptr || relativePath.empty()) { + return nullptr; + } + + const std::string cacheKey = build_asset_texture_cache_key(relativePath, maxWidth, maxHeight); + + if (const auto existingTexture = cache->textures.find(cacheKey); existingTexture != cache->textures.end()) { + return existingTexture->second; + } + if (cache->failedKeys.find(cacheKey) != cache->failedKeys.end()) { + return nullptr; + } + + SDL_Texture *texture = load_texture_from_asset(renderer, relativePath.c_str(), maxWidth, maxHeight); + if (texture == nullptr) { + cache->failedKeys[cacheKey] = true; + return nullptr; + } + + cache->textures.try_emplace(cacheKey, texture); + return texture; + } + + void fill_rect(SDL_Renderer *renderer, const SDL_Rect &rect, Uint8 red, Uint8 green, Uint8 blue, Uint8 alpha = 0xFF) { + SDL_SetRenderDrawColor(renderer, red, green, blue, alpha); + SDL_RenderFillRect(renderer, &rect); + } + + void draw_rect(SDL_Renderer *renderer, const SDL_Rect &rect, Uint8 red, Uint8 green, Uint8 blue, Uint8 alpha = 0xFF) { + SDL_SetRenderDrawColor(renderer, red, green, blue, alpha); + SDL_RenderDrawRect(renderer, &rect); + } + + bool render_text_line( // NOSONAR(cpp:S107) helper keeps the SDL text rendering callsite contract explicit + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + int x, + int y, + int maxWidth, + int *drawnHeight = nullptr + ) { + if (font == nullptr || maxWidth <= 0) { + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + const std::string renderText = sanitize_text_for_render(text); + if (renderText.empty()) { + if (drawnHeight != nullptr) { + *drawnHeight = TTF_FontLineSkip(font); + } + return true; + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, renderText.c_str(), color, static_cast(maxWidth)); + if (surface == nullptr) { + return false; + } + + return render_surface_line(renderer, surface, x, y, drawnHeight); + } + + std::string fit_single_line_text(TTF_Font *font, const std::string &text, int maxWidth) { + if (font == nullptr || maxWidth <= 0) { + return {}; + } + + const std::string sanitized = sanitize_ascii_text_for_render(text); + if (sanitized.empty()) { + return {}; + } + + int textWidth = 0; + int textHeight = 0; + if (TTF_SizeText(font, sanitized.c_str(), &textWidth, &textHeight) == 0 && textWidth <= maxWidth) { + return sanitized; + } + + const std::string ellipsis = "..."; + for (std::size_t length = sanitized.size(); length > 0U; --length) { + const std::string candidate = sanitize_ascii_text_for_render(std::string_view(sanitized).substr(0, length)) + ellipsis; + if (TTF_SizeText(font, candidate.c_str(), &textWidth, &textHeight) == 0 && textWidth <= maxWidth) { + return candidate; + } + } + + return ellipsis; + } + + bool render_text_line_simple( // NOSONAR(cpp:S107) helper keeps the SDL text rendering callsite contract explicit + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + int x, + int y, + int maxWidth, + int *drawnHeight = nullptr + ) { + if (font == nullptr || maxWidth <= 0) { + if (drawnHeight != nullptr) { + *drawnHeight = 0; + } + return false; + } + + const std::string renderText = fit_single_line_text(font, text, maxWidth); + if (renderText.empty()) { + if (drawnHeight != nullptr) { + *drawnHeight = TTF_FontLineSkip(font); + } + return true; + } + + SDL_Surface *surface = TTF_RenderText_Blended(font, renderText.c_str(), color); + if (surface == nullptr) { + return false; + } + + return render_surface_line(renderer, surface, x, y, drawnHeight); + } + + /** + * @brief Builds or reuses a cached wrapped text texture for keypad modal content. + * + * @param renderer SDL renderer used to create textures. + * @param font Font used to render the text. + * @param text Source text to cache. + * @param color Text color used for rendering. + * @param maxWidth Maximum wrapped width in pixels. + * @param cache Cache entry to populate or reuse. + * @return True when the wrapped texture is ready for use. + */ + bool ensure_wrapped_text_texture( + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + int maxWidth, + CachedTextTexture *cache + ) { + if (font == nullptr || maxWidth <= 0 || cache == nullptr) { + return false; + } + + const std::string renderText = sanitize_text_for_render(text); + if ( + cache->texture != nullptr && cache->wrapped && cache->font == font && cache->sourceText == text && cache->renderedText == renderText && cache->maxWidth == maxWidth && + colors_match(cache->color, color) + ) { + return true; + } + + clear_cached_text_texture(cache); + cache->font = font; + cache->sourceText = text; + cache->renderedText = renderText; + cache->maxWidth = maxWidth; + cache->color = color; + cache->wrapped = true; + cache->height = renderText.empty() ? TTF_FontLineSkip(font) : 0; + + if (renderText.empty()) { + return true; + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, renderText.c_str(), color, static_cast(maxWidth)); + if (surface == nullptr) { + cache->height = TTF_FontLineSkip(font); + return false; + } + + cache->width = surface->w; + cache->height = surface->h; + cache->texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (cache->texture == nullptr) { + cache->width = 0; + cache->height = TTF_FontLineSkip(font); + return false; + } + + return true; + } + + /** + * @brief Builds or reuses a cached single-line text texture for keypad button labels. + * + * @param renderer SDL renderer used to create textures. + * @param font Font used to render the text. + * @param text Source text to cache. + * @param color Text color used for rendering. + * @param maxWidth Maximum label width in pixels. + * @param cache Cache entry to populate or reuse. + * @return True when the single-line texture is ready for use. + */ + bool ensure_single_line_text_texture( + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + int maxWidth, + CachedTextTexture *cache + ) { + if (font == nullptr || maxWidth <= 0 || cache == nullptr) { + return false; + } + + const std::string renderText = fit_single_line_text(font, text, maxWidth); + if ( + cache->texture != nullptr && !cache->wrapped && cache->font == font && cache->sourceText == text && cache->renderedText == renderText && cache->maxWidth == maxWidth && + colors_match(cache->color, color) + ) { + return true; + } + + clear_cached_text_texture(cache); + cache->font = font; + cache->sourceText = text; + cache->renderedText = renderText; + cache->maxWidth = maxWidth; + cache->color = color; + cache->wrapped = false; + cache->height = renderText.empty() ? TTF_FontLineSkip(font) : 0; + + if (renderText.empty()) { + return true; + } + + SDL_Surface *surface = TTF_RenderText_Blended(font, renderText.c_str(), color); + if (surface == nullptr) { + cache->height = TTF_FontLineSkip(font); + return false; + } + + cache->width = surface->w; + cache->height = surface->h; + cache->texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + if (cache->texture == nullptr) { + cache->width = 0; + cache->height = TTF_FontLineSkip(font); + return false; + } + + return true; + } + + /** + * @brief Draws a cached text texture at a fixed point, using stored height for blank lines. + * + * @param renderer SDL renderer used for drawing. + * @param cache Cached text texture to render. + * @param x Left destination coordinate. + * @param y Top destination coordinate. + * @param drawnHeight Receives the rendered or reserved text height. + * @return True when drawing succeeded or no texture was required. + */ + bool render_cached_text_texture(SDL_Renderer *renderer, const CachedTextTexture &cache, int x, int y, int *drawnHeight = nullptr) { + if (cache.texture == nullptr) { + if (drawnHeight != nullptr) { + *drawnHeight = cache.height; + } + return true; + } + + const SDL_Rect destination {x, y, cache.width, cache.height}; + const bool rendered = SDL_RenderCopy(renderer, cache.texture, nullptr, &destination) == 0; + if (drawnHeight != nullptr) { + *drawnHeight = cache.height; + } + return rendered; + } + + /** + * @brief Draws a cached single-line text texture centered within a button rect. + * + * @param renderer SDL renderer used for drawing. + * @param cache Cached text texture to render. + * @param rect Button rectangle that should contain the rendered text. + * @param drawnHeight Receives the rendered or reserved text height. + * @return True when drawing succeeded or no texture was required. + */ + bool render_cached_centered_text_texture(SDL_Renderer *renderer, const CachedTextTexture &cache, const SDL_Rect &rect, int *drawnHeight = nullptr) { + if (cache.texture == nullptr) { + if (drawnHeight != nullptr) { + *drawnHeight = cache.height; + } + return true; + } + + const SDL_Rect destination { + rect.x + std::max(0, (rect.w - cache.width) / 2), + rect.y + std::max(0, (rect.h - cache.height) / 2), + cache.width, + cache.height, + }; + const bool rendered = SDL_RenderCopy(renderer, cache.texture, nullptr, &destination) == 0; + if (drawnHeight != nullptr) { + *drawnHeight = cache.height; + } + return rendered; + } + + bool render_text_centered_simple( + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + const SDL_Rect &rect, + int *drawnHeight = nullptr + ) { + const std::string renderText = fit_single_line_text(font, text, rect.w); + if (renderText.empty()) { + if (drawnHeight != nullptr) { + *drawnHeight = TTF_FontLineSkip(font); + } + return true; + } + + int textWidth = 0; + int textHeight = 0; + if (TTF_SizeText(font, renderText.c_str(), &textWidth, &textHeight) != 0) { + return render_text_line_simple(renderer, font, renderText, color, rect.x + 8, rect.y + 8, std::max(1, rect.w - 16), drawnHeight); + } + + const int x = rect.x + std::max(0, (rect.w - textWidth) / 2); + const int y = rect.y + std::max(0, (rect.h - textHeight) / 2); + return render_text_line_simple(renderer, font, renderText, color, x, y, rect.w, drawnHeight); + } + + int measure_wrapped_text_height(TTF_Font *font, const std::string &text, int maxWidth) { + if (font == nullptr || maxWidth <= 0) { + return 0; + } + + const std::string renderText = sanitize_text_for_render(text); + if (renderText.empty()) { + return TTF_FontLineSkip(font); + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, renderText.c_str(), {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, static_cast(maxWidth)); + if (surface == nullptr) { + return TTF_FontLineSkip(font); + } + + const int height = surface->h; + SDL_FreeSurface(surface); + return height; + } + + /** + * @brief Reuses keypad modal text measurements until the content or width changes. + * + * @param renderer SDL renderer used when cached text textures must be rebuilt. + * @param font Font used to measure and render the keypad body text. + * @param viewModel Shell view model containing the keypad modal content. + * @param modalInnerWidth Available wrapped-text width inside the modal. + * @param cache Cache entry that stores the last measured keypad modal layout. + * @return Total pixel height required for the keypad modal body text. + */ + int keypad_modal_text_height( + SDL_Renderer *renderer, + TTF_Font *font, + const ui::ShellViewModel &viewModel, + int modalInnerWidth, + KeypadModalLayoutCache *cache + ) { + if (cache != nullptr && cache->modalInnerWidth == modalInnerWidth && cache->lines == viewModel.keypad.lines) { + return cache->modalTextHeight; + } + + if (cache != nullptr) { + if (cache->lineTextures.size() > viewModel.keypad.lines.size()) { + for (std::size_t index = viewModel.keypad.lines.size(); index < cache->lineTextures.size(); ++index) { + clear_cached_text_texture(&cache->lineTextures[index]); + } + } + cache->lineTextures.resize(viewModel.keypad.lines.size()); + } + + int modalTextHeight = 0; + for (std::size_t index = 0; index < viewModel.keypad.lines.size(); ++index) { + const std::string &line = viewModel.keypad.lines[index]; + if (cache != nullptr) { + if (!ensure_wrapped_text_texture(renderer, font, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, modalInnerWidth, &cache->lineTextures[index])) { + modalTextHeight += measure_wrapped_text_height(font, line, modalInnerWidth) + 6; + continue; + } + modalTextHeight += cache->lineTextures[index].height + 6; + continue; + } + + modalTextHeight += measure_wrapped_text_height(font, line, modalInnerWidth) + 6; + } + + if (cache != nullptr) { + cache->modalInnerWidth = modalInnerWidth; + cache->modalTextHeight = modalTextHeight; + cache->lines = viewModel.keypad.lines; + } + + return modalTextHeight; + } + + bool render_text_centered( + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + const SDL_Rect &rect, + int *drawnHeight = nullptr + ) { + int textWidth = 0; + int textHeight = 0; + if (TTF_SizeUTF8(font, text.c_str(), &textWidth, &textHeight) != 0) { + return render_text_line(renderer, font, text, color, rect.x + 8, rect.y + 8, rect.w - 16, drawnHeight); + } + + const int x = rect.x + std::max(0, (rect.w - textWidth) / 2); + const int y = rect.y + std::max(0, (rect.h - textHeight) / 2); + return render_text_line(renderer, font, text, color, x, y, rect.w, drawnHeight); + } + + bool render_texture_fit(SDL_Renderer *renderer, SDL_Texture *texture, const SDL_Rect &rect) { + if (renderer == nullptr || texture == nullptr || rect.w <= 0 || rect.h <= 0) { + return false; + } + + int textureWidth = 0; + int textureHeight = 0; + if (SDL_QueryTexture(texture, nullptr, nullptr, &textureWidth, &textureHeight) != 0 || textureWidth <= 0 || textureHeight <= 0) { + return false; + } + + SDL_Rect destination = rect; + if ((textureWidth * rect.h) > (textureHeight * rect.w)) { + destination.h = std::max(1, (textureHeight * rect.w) / textureWidth); + destination.y = rect.y + std::max(0, (rect.h - destination.h) / 2); + } else { + destination.w = std::max(1, (textureWidth * rect.h) / textureHeight); + destination.x = rect.x + std::max(0, (rect.w - destination.w) / 2); + } + + return SDL_RenderCopy(renderer, texture, nullptr, &destination) == 0; + } + + bool render_texture_fill(SDL_Renderer *renderer, SDL_Texture *texture, const SDL_Rect &rect) { + if (renderer == nullptr || texture == nullptr || rect.w <= 0 || rect.h <= 0) { + return false; + } + + int textureWidth = 0; + int textureHeight = 0; + if (SDL_QueryTexture(texture, nullptr, nullptr, &textureWidth, &textureHeight) != 0 || textureWidth <= 0 || textureHeight <= 0) { + return false; + } + + SDL_Rect source {0, 0, textureWidth, textureHeight}; + if ((textureWidth * rect.h) > (textureHeight * rect.w)) { + source.w = std::max(1, (textureHeight * rect.w) / rect.h); + source.x = std::max(0, (textureWidth - source.w) / 2); + } else { + source.h = std::max(1, (textureWidth * rect.h) / rect.w); + source.y = std::max(0, (textureHeight - source.h) / 2); + } + + return SDL_RenderCopy(renderer, texture, &source, &rect) == 0; + } + + bool render_asset_icon(SDL_Renderer *renderer, AssetTextureCache *cache, const std::string &relativePath, const SDL_Rect &rect) { + SDL_Texture *texture = load_cached_asset_texture(renderer, cache, relativePath, rect.w, rect.h); + if (texture == nullptr) { + return false; + } + + return render_texture_fit(renderer, texture, rect); + } + + bool render_default_app_cover( + SDL_Renderer *renderer, + TTF_Font *labelFont, + const ui::ShellAppTile &tile, + const SDL_Rect &rect, + const AssetTextureCache *assetCache + ) { + (void) assetCache; + const SDL_Color seedColor = placeholder_color(tile.name); + fill_rect(renderer, rect, seedColor.r / 2, seedColor.g / 2, seedColor.b / 2, 0xFF); + fill_rect(renderer, {rect.x + std::max(4, rect.w / 18), rect.y, std::max(6, rect.w / 14), rect.h}, seedColor.r, seedColor.g, seedColor.b, 0xFF); + + const SDL_Rect innerRect { + rect.x + std::max(10, rect.w / 10), + rect.y + std::max(10, rect.h / 12), + std::max(1, rect.w - std::max(20, rect.w / 5)), + std::max(1, rect.h - std::max(20, rect.h / 6)), + }; + fill_rect(renderer, innerRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xD8); + + const int logoSize = std::max(28, std::min(innerRect.w - 18, (innerRect.h * 2) / 5)); + const SDL_Rect logoRect { + innerRect.x + std::max(0, (innerRect.w - logoSize) / 2), + innerRect.y + std::max(8, innerRect.h / 9), + logoSize, + logoSize, + }; + render_text_centered_simple(renderer, labelFont, app_monogram(tile), {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, logoRect); + + render_text_centered_simple( + renderer, + labelFont, + "Moonlight", + {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xD8}, + { + innerRect.x + 8, + innerRect.y + innerRect.h - std::max(40, innerRect.h / 5), + std::max(1, innerRect.w - 16), + std::max(20, innerRect.h / 8), + } + ); + return true; + } + + void draw_line(SDL_Renderer *renderer, int x1, int y1, int x2, int y2, Uint8 red, Uint8 green, Uint8 blue, Uint8 alpha = 0xFF) { // NOSONAR(cpp:S107) low-level draw helper intentionally mirrors SDL primitive arguments + SDL_SetRenderDrawColor(renderer, red, green, blue, alpha); + SDL_RenderDrawLine(renderer, x1, y1, x2, y2); + } + + bool render_app_cover( + SDL_Renderer *renderer, + TTF_Font *labelFont, + const ui::ShellAppTile &tile, + const SDL_Rect &rect, + CoverArtTextureCache *textureCache, + const AssetTextureCache *assetCache + ) { + fill_rect(renderer, rect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xFF); + if (SDL_Texture *texture = tile.boxArtCached ? load_cover_art_texture(renderer, textureCache, tile.boxArtCacheKey) : nullptr; texture != nullptr) { + if (!render_texture_fill(renderer, texture, rect)) { + return false; + } + } else { + if (!render_default_app_cover(renderer, labelFont, tile, rect, assetCache)) { + return false; + } + } + + const int overlayTextWidth = std::max(1, rect.w - 16); + const int overlayHeight = std::max(28, (TTF_FontLineSkip(labelFont) * 2) + 8); + const SDL_Rect overlayRect {rect.x, rect.y + rect.h - overlayHeight, rect.w, overlayHeight}; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, overlayRect, 0x00, 0x00, 0x00, 0x96); + render_text_line_simple( + renderer, + labelFont, + truncate_text_for_render(tile.name, 72U), + {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, + overlayRect.x + 8, + overlayRect.y + std::max(4, (overlayHeight - TTF_FontLineSkip(labelFont)) / 2), + overlayTextWidth + ); + + if (tile.selected) { + draw_rect(renderer, rect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + } else { + draw_rect(renderer, rect, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x28); + } + return true; + } + + SDL_Rect log_viewer_rect(const ui::ShellViewModel &viewModel, int screenWidth, int screenHeight, int outerMargin) { + const int fullWidth = screenWidth - (outerMargin * 2); + const int dockedWidth = std::max(420, (screenWidth - (outerMargin * 3)) / 2); + const int height = screenHeight - (outerMargin * 2); + + switch (viewModel.logViewer.placement) { + case app::LogViewerPlacement::left: + return {outerMargin, outerMargin, dockedWidth, height}; + case app::LogViewerPlacement::right: + return {screenWidth - outerMargin - dockedWidth, outerMargin, dockedWidth, height}; + case app::LogViewerPlacement::full: + return {outerMargin, outerMargin, fullWidth, height}; + } + + return {outerMargin, outerMargin, fullWidth, height}; + } + + struct LogViewerLayout { + std::vector visibleLines; + std::size_t firstVisibleIndex = 0U; + }; + + LogViewerLayout build_log_viewer_layout(const ui::ShellViewModel &viewModel, TTF_Font *font, int availableWidth, int availableHeight, std::size_t clampedOffset) { + LogViewerLayout layout {}; + if (viewModel.logViewer.lines.empty()) { + layout.visibleLines.push_back(nullptr); + return layout; + } + + int usedHeight = 0; + std::size_t endIndex = viewModel.logViewer.lines.size() > clampedOffset ? viewModel.logViewer.lines.size() - clampedOffset : 0U; + layout.firstVisibleIndex = endIndex; + while (endIndex > 0U) { + const std::string renderedLine = truncate_text_for_render(viewModel.logViewer.lines[endIndex - 1U], LOG_VIEWER_MAX_RENDER_CHARACTERS); + const int lineHeight = measure_wrapped_text_height(font, renderedLine, std::max(1, availableWidth - 12)) + 4; + if (!layout.visibleLines.empty() && usedHeight + lineHeight > availableHeight - 8) { + break; + } + layout.visibleLines.push_back(&viewModel.logViewer.lines[endIndex - 1U]); + usedHeight += lineHeight; + --endIndex; + } + layout.firstVisibleIndex = endIndex; + std::reverse(layout.visibleLines.begin(), layout.visibleLines.end()); + return layout; + } + + bool render_log_viewer_lines(SDL_Renderer *renderer, TTF_Font *smallFont, const ui::ShellViewModel &viewModel, const SDL_Rect &textRect, const LogViewerLayout &layout) { + int contentCursorY = textRect.y + 6; + if (layout.firstVisibleIndex > 0U) { + if (!render_text_line_simple(renderer, smallFont, "Earlier lines above", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, textRect.x + 6, contentCursorY, std::max(1, textRect.w - 12))) { + return false; + } + contentCursorY += TTF_FontLineSkip(smallFont) + 4; + } + + if (layout.visibleLines.size() == 1U && layout.visibleLines.front() == nullptr) { + if (!render_text_line_simple(renderer, smallFont, "The log file is empty.", {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, textRect.x + 6, contentCursorY, std::max(1, textRect.w - 12))) { + return false; + } + } else { + for (const std::string *line : layout.visibleLines) { + int drawnHeight = 0; + if (!render_text_line_simple(renderer, smallFont, truncate_text_for_render(*line, LOG_VIEWER_MAX_RENDER_CHARACTERS), {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, textRect.x + 6, contentCursorY, std::max(1, textRect.w - 12), &drawnHeight)) { + return false; + } + contentCursorY += drawnHeight + 4; + } + } + + if (viewModel.logViewer.scrollOffset > 0U) { + return render_text_line_simple(renderer, smallFont, "Newer lines below", {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, textRect.x + 6, std::max(textRect.y + 6, textRect.y + textRect.h - TTF_FontLineSkip(smallFont) - 6), std::max(1, textRect.w - 12)); + } + return true; + } + + void render_vertical_scrollbar(SDL_Renderer *renderer, const SDL_Rect &trackRect, int totalItemCount, int visibleItemCount, int startItemIndex) { + if ( + renderer == nullptr || trackRect.w <= 0 || trackRect.h <= 0 || totalItemCount <= visibleItemCount || visibleItemCount <= 0 + ) { + return; + } + + fill_rect(renderer, trackRect, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xA0); + draw_rect(renderer, trackRect, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x30); + + const int thumbHeight = std::max(24, (trackRect.h * visibleItemCount) / totalItemCount); + const int maxThumbTravel = std::max(0, trackRect.h - thumbHeight); + const int maxStartItem = std::max(1, totalItemCount - visibleItemCount); + const int clampedStartItem = std::clamp(startItemIndex, 0, std::max(0, totalItemCount - visibleItemCount)); + const int thumbY = trackRect.y + ((maxThumbTravel * clampedStartItem) / maxStartItem); + const SDL_Rect thumbRect {trackRect.x + 1, thumbY, std::max(1, trackRect.w - 2), thumbHeight}; + fill_rect(renderer, thumbRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD0); + } + + bool render_log_viewer_modal( // NOSONAR(cpp:S107) modal rendering keeps the layout inputs explicit + SDL_Renderer *renderer, + TTF_Font *bodyFont, + TTF_Font *smallFont, + AssetTextureCache *assetCache, + const ui::ShellViewModel &viewModel, + int screenWidth, + int screenHeight, + int outerMargin + ) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, {0, 0, screenWidth, screenHeight}, 0x00, 0x00, 0x00, 0xA6); + + const SDL_Rect modalRect = log_viewer_rect(viewModel, screenWidth, screenHeight, outerMargin); + fill_rect(renderer, modalRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE, 0xF4); + draw_rect(renderer, modalRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + + if (!render_text_line_simple(renderer, bodyFont, viewModel.modal.title, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 18, modalRect.y + 16, modalRect.w - 36)) { + return false; + } + + int pathHeight = 0; + if (!render_text_line_simple(renderer, smallFont, "Path: " + viewModel.logViewer.path, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, modalRect.x + 18, modalRect.y + 56, modalRect.w - 36, &pathHeight)) { + return false; + } + + const int hintHeight = std::max(30, TTF_FontLineSkip(smallFont) + 10); + const SDL_Rect hintRect {modalRect.x + 18, modalRect.y + 56 + pathHeight + 8, modalRect.w - 36, hintHeight}; + if (const std::vector logViewerActions = { + {"change-view", "Change View", "icons\\button-x.svg", "icons\\button-y.svg", false}, + {"scroll", "Scroll", "icons\\button-lb.svg", "icons\\button-rb.svg", false}, + {"fast-scroll", "Fast Scroll", "icons\\button-lt.svg", "icons\\button-rt.svg", false}, + {"close", "Close", "icons\\button-a.svg", "icons\\button-b.svg", false}, + }; + !render_footer_actions(renderer, smallFont, assetCache, logViewerActions, hintRect)) { + return false; + } + + const int contentBottom = modalRect.y + modalRect.h - 18; + const int requestedContentY = modalRect.y + 56 + pathHeight + hintHeight + 18; + const int contentAreaY = std::min(requestedContentY, std::max(modalRect.y + 72, contentBottom - 1)); + const SDL_Rect contentRect { + modalRect.x + 18, + contentAreaY, + std::max(1, modalRect.w - 36), + std::max(1, contentBottom - contentAreaY), + }; + fill_rect(renderer, contentRect, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0x70); + + const std::size_t maxOffset = viewModel.logViewer.lines.size() > 1U ? viewModel.logViewer.lines.size() - 1U : 0U; + const std::size_t clampedOffset = std::min(viewModel.logViewer.scrollOffset, maxOffset); + + constexpr int logViewerScrollbarWidth = 10; + constexpr int logViewerScrollbarGap = 12; + LogViewerLayout logViewerLayout = build_log_viewer_layout(viewModel, smallFont, contentRect.w, contentRect.h, clampedOffset); + const bool overflow = !viewModel.logViewer.lines.empty() && viewModel.logViewer.lines.size() > logViewerLayout.visibleLines.size(); + if (overflow) { + logViewerLayout = build_log_viewer_layout(viewModel, smallFont, std::max(1, contentRect.w - logViewerScrollbarWidth - logViewerScrollbarGap), contentRect.h, clampedOffset); + } + + if (const SDL_Rect textRect { + contentRect.x, + contentRect.y, + std::max(1, contentRect.w - (overflow ? logViewerScrollbarWidth + logViewerScrollbarGap : 0)), + contentRect.h, + }; + !render_log_viewer_lines(renderer, smallFont, viewModel, textRect, logViewerLayout)) { + return false; + } + + if (overflow) { + render_vertical_scrollbar( + renderer, + {contentRect.x + contentRect.w - logViewerScrollbarWidth, contentRect.y, logViewerScrollbarWidth, contentRect.h}, + static_cast(viewModel.logViewer.lines.size()), + static_cast(std::max(1U, logViewerLayout.visibleLines.size())), + static_cast(logViewerLayout.firstVisibleIndex) + ); + } + + return true; + } + + bool render_action_rows( + SDL_Renderer *renderer, + TTF_Font *font, + const std::vector &rows, + const SDL_Rect &rect, + int rowHeight + ) { + if (rows.empty()) { + return true; + } + + const int rowSpacing = 6; + const int rowStep = rowHeight + rowSpacing; + const auto visibleRowCount = static_cast(std::max(1, (rect.h + rowSpacing) / std::max(1, rowStep))); + std::size_t selectedIndex = 0U; + bool selectedIndexFound = false; + for (std::size_t index = 0; index < rows.size(); ++index) { + if (rows[index].selected) { + selectedIndex = index; + selectedIndexFound = true; + break; + } + } + + std::size_t startIndex = 0U; + if (rows.size() > visibleRowCount && selectedIndexFound) { + const std::size_t centeredOffset = visibleRowCount / 2U; + startIndex = selectedIndex > centeredOffset ? selectedIndex - centeredOffset : 0U; + const std::size_t maxStartIndex = rows.size() - visibleRowCount; + if (startIndex > maxStartIndex) { + startIndex = maxStartIndex; + } + } + const std::size_t endIndex = std::min(rows.size(), startIndex + visibleRowCount); + const bool overflow = rows.size() > visibleRowCount; + const int scrollBarWidth = overflow ? 8 : 0; + const int contentWidth = std::max(1, rect.w - (overflow ? scrollBarWidth + 10 : 0)); + + int y = rect.y; + for (std::size_t index = startIndex; index < endIndex; ++index) { + const ui::ShellActionRow &row = rows[index]; + const SDL_Rect rowRect {rect.x, y, contentWidth, rowHeight}; + if (row.selected) { + fill_rect(renderer, rowRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0x55); + draw_rect(renderer, rowRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + } + + const std::string label = row.checked ? "[x] " + row.label : row.label; + if (const SDL_Color color = row.enabled ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; !render_text_line_simple(renderer, font, label, color, rowRect.x + 12, rowRect.y + 8, rowRect.w - 24)) { + return false; + } + + y += rowStep; + } + + if (overflow) { + render_vertical_scrollbar( + renderer, + {rect.x + rect.w - scrollBarWidth, rect.y, scrollBarWidth, rect.h}, + static_cast(rows.size()), + static_cast(visibleRowCount), + static_cast(startIndex) + ); + } + + return true; + } + + bool render_toolbar_button( + SDL_Renderer *renderer, + TTF_Font *font, + TTF_Font *smallFont, + AssetTextureCache *assetCache, + const ui::ShellToolbarButton &button, + const SDL_Rect &buttonRect + ) { + if (button.selected) { + fill_rect(renderer, buttonRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0x55); + draw_rect(renderer, buttonRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + } else { + fill_rect(renderer, buttonRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xC8); + draw_rect(renderer, buttonRect, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x50); + } + + const int iconSize = std::max(18, buttonRect.h - 16); + const SDL_Rect iconRect {buttonRect.x + 10, buttonRect.y + (buttonRect.h - iconSize) / 2, iconSize, iconSize}; + if (const bool renderedIcon = !button.iconAssetPath.empty() && render_asset_icon(renderer, assetCache, button.iconAssetPath, iconRect); !renderedIcon && !render_text_centered(renderer, font, button.glyph, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, iconRect)) { + return false; + } + + return render_text_line(renderer, smallFont, button.label, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, iconRect.x + iconRect.w + 8, buttonRect.y + std::max(6, (buttonRect.h - TTF_FontLineSkip(smallFont)) / 2), buttonRect.w - (iconRect.w + 26)); + } + + struct FooterActionChipLayout { + int labelWidth = 0; + int iconSize = 0; + int iconBlockWidth = 0; + int chipWidth = 0; + }; + + int measure_footer_action_label_width(TTF_Font *font, const std::string &label) { + int labelWidth = 0; + if (int labelHeight = 0; TTF_SizeUTF8(font, label.c_str(), &labelWidth, &labelHeight) != 0) { + return static_cast(label.size()) * 8; + } + return labelWidth; + } + + FooterActionChipLayout measure_footer_action_chip(TTF_Font *font, const ui::ShellFooterAction &action, int chipHeight) { + const int iconCount = (action.iconAssetPath.empty() ? 0 : 1) + (action.secondaryIconAssetPath.empty() ? 0 : 1); + const int iconSize = (action.iconAssetPath.empty() && action.secondaryIconAssetPath.empty()) ? 0 : std::max(18, chipHeight - 14); + const int iconBlockWidth = iconCount == 0 ? 0 : (iconSize * iconCount) + ((iconCount - 1) * 4); + return { + measure_footer_action_label_width(font, action.label), + iconSize, + iconBlockWidth, + 18 + iconBlockWidth + (iconBlockWidth > 0 ? 8 : 0) + measure_footer_action_label_width(font, action.label) + 18, + }; + } + + int render_footer_action_icons(SDL_Renderer *renderer, AssetTextureCache *assetCache, const ui::ShellFooterAction &action, const SDL_Rect &chipRect, int iconSize) { + int contentX = chipRect.x + 10; + if (iconSize <= 0) { + return contentX; + } + + const auto render_icon = [&](const std::string &assetPath) { + if (assetPath.empty()) { + return; + } + const SDL_Rect iconRect {contentX, chipRect.y + (chipRect.h - iconSize) / 2, iconSize, iconSize}; + render_asset_icon(renderer, assetCache, assetPath, iconRect); + contentX += iconSize + 4; + }; + + render_icon(action.iconAssetPath); + render_icon(action.secondaryIconAssetPath); + return contentX + 4; + } + + bool render_footer_action_chip( + SDL_Renderer *renderer, + TTF_Font *font, + AssetTextureCache *assetCache, + const ui::ShellFooterAction &action, + const FooterActionChipLayout &layout, + const SDL_Rect &chipRect + ) { + const int contentX = render_footer_action_icons(renderer, assetCache, action, chipRect, layout.iconSize); + return render_text_line(renderer, font, action.label, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentX, chipRect.y + std::max(6, (chipRect.h - TTF_FontLineSkip(font)) / 2), chipRect.w - (contentX - chipRect.x) - 10); + } + + bool render_footer_actions( + SDL_Renderer *renderer, + TTF_Font *font, + AssetTextureCache *assetCache, + const std::vector &actions, + const SDL_Rect &footerRect + ) { + int cursorX = footerRect.x + 16; + const int availableRight = footerRect.x + footerRect.w - 16; + const int chipHeight = std::max(30, footerRect.h - 18); + const int chipY = footerRect.y + (footerRect.h - chipHeight) / 2; + + for (const ui::ShellFooterAction &action : actions) { + const FooterActionChipLayout layout = measure_footer_action_chip(font, action, chipHeight); + if (cursorX + layout.chipWidth > availableRight) { + break; + } + + if (const SDL_Rect chipRect {cursorX, chipY, layout.chipWidth, chipHeight}; !render_footer_action_chip(renderer, font, assetCache, action, layout, chipRect)) { + return false; + } + + cursorX += layout.chipWidth + 12; + } + + return true; + } + + bool render_notification( // NOSONAR(cpp:S107) notification layout helper keeps the render inputs explicit + SDL_Renderer *renderer, + TTF_Font *titleFont, + TTF_Font *bodyFont, + AssetTextureCache *assetCache, + const ui::ShellNotification ¬ification, + int screenWidth, + int footerTop, + int outerMargin + ) { + if (notification.message.empty()) { + return true; + } + + const int notificationWidth = std::min(420, std::max(300, screenWidth / 3)); + const int innerWidth = notificationWidth - 28; + const int titleHeight = notification.title.empty() ? 0 : TTF_FontLineSkip(titleFont); + const int messageHeight = measure_wrapped_text_height(bodyFont, notification.message, innerWidth); + const int actionHeight = notification.actions.empty() ? 0 : std::max(30, TTF_FontLineSkip(bodyFont) + 10); + const int notificationHeight = 14 + titleHeight + (titleHeight > 0 ? 8 : 0) + messageHeight + (actionHeight > 0 ? 14 + actionHeight : 0) + 14; + const SDL_Rect notificationRect { + screenWidth - outerMargin - notificationWidth, + std::max(outerMargin, footerTop - notificationHeight - 14), + notificationWidth, + notificationHeight, + }; + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, notificationRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE, 0xF0); + draw_rect(renderer, notificationRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + + int cursorY = notificationRect.y + 12; + if (!notification.title.empty()) { + if (!render_text_line_simple(renderer, titleFont, notification.title, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, notificationRect.x + 14, cursorY, innerWidth)) { + return false; + } + cursorY += titleHeight + 8; + } + + int messageDrawnHeight = 0; + if (!render_text_line(renderer, bodyFont, notification.message, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, notificationRect.x + 14, cursorY, innerWidth, &messageDrawnHeight)) { + return false; + } + cursorY += messageDrawnHeight; + + if (!notification.actions.empty()) { + cursorY += 10; + if (!render_footer_actions(renderer, bodyFont, assetCache, notification.actions, {notificationRect.x + 10, cursorY, notificationRect.w - 20, actionHeight})) { + return false; + } + } + + return true; + } + + struct GridViewport { + int totalRowCount = 0; + int visibleRowCount = 0; + int startRow = 0; + int scrollbarWidth = 0; + }; + + GridViewport calculate_grid_viewport(std::size_t itemCount, std::size_t columnCount, std::size_t selectedIndex, int availableHeight, int preferredRowHeight, int tileGap) { + GridViewport viewport {}; + if (itemCount == 0U || columnCount == 0U || availableHeight <= 0) { + return viewport; + } + + viewport.totalRowCount = static_cast((itemCount + columnCount - 1U) / columnCount); + viewport.visibleRowCount = std::max(1, (availableHeight + tileGap) / std::max(1, preferredRowHeight + tileGap)); + viewport.visibleRowCount = std::min(viewport.visibleRowCount, viewport.totalRowCount); + const auto selectedRow = static_cast(std::min(selectedIndex, itemCount - 1U) / columnCount); + viewport.startRow = std::clamp(selectedRow + 1 - viewport.visibleRowCount, 0, viewport.totalRowCount - viewport.visibleRowCount); + viewport.scrollbarWidth = viewport.totalRowCount > viewport.visibleRowCount ? 10 : 0; + return viewport; + } + + void render_grid_scrollbar(SDL_Renderer *renderer, const SDL_Rect &trackRect, const GridViewport &viewport) { + render_vertical_scrollbar(renderer, trackRect, viewport.totalRowCount, viewport.visibleRowCount, viewport.startRow); + } + + std::size_t selected_host_tile_index(const std::vector &tiles) { + for (std::size_t index = 0; index < tiles.size(); ++index) { + if (tiles[index].selected) { + return index; + } + } + + return 0U; + } + + std::size_t selected_app_tile_index(const std::vector &tiles) { + for (std::size_t index = 0; index < tiles.size(); ++index) { + if (tiles[index].selected) { + return index; + } + } + + return 0U; + } + + input::UiCommand translate_controller_button(Uint8 button) { + switch (button) { + case SDL_CONTROLLER_BUTTON_DPAD_UP: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_up); + case SDL_CONTROLLER_BUTTON_DPAD_DOWN: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_down); + case SDL_CONTROLLER_BUTTON_DPAD_LEFT: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_left); + case SDL_CONTROLLER_BUTTON_DPAD_RIGHT: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_right); + case SDL_CONTROLLER_BUTTON_A: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::a); + case SDL_CONTROLLER_BUTTON_B: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::b); + case SDL_CONTROLLER_BUTTON_X: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::x); + case SDL_CONTROLLER_BUTTON_Y: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::y); + case SDL_CONTROLLER_BUTTON_LEFTSHOULDER: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::left_shoulder); + case SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::right_shoulder); + case SDL_CONTROLLER_BUTTON_START: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::start); + case SDL_CONTROLLER_BUTTON_BACK: + return input::map_gamepad_button_to_ui_command(input::GamepadButton::back); + default: + return input::UiCommand::none; + } + } + + input::UiCommand translate_keyboard_key(SDL_Keycode key, Uint16 modifiers) { + const bool shiftPressed = (modifiers & KMOD_SHIFT) != 0; + + switch (key) { + case SDLK_UP: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::up, shiftPressed); + case SDLK_DOWN: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::down, shiftPressed); + case SDLK_LEFT: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::left, shiftPressed); + case SDLK_RIGHT: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::right, shiftPressed); + case SDLK_RETURN: + case SDLK_KP_ENTER: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::enter, shiftPressed); + case SDLK_ESCAPE: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::escape, shiftPressed); + case SDLK_BACKSPACE: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::backspace, shiftPressed); + case SDLK_DELETE: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::delete_key, shiftPressed); + case SDLK_SPACE: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::space, shiftPressed); + case SDLK_TAB: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::tab, shiftPressed); + case SDLK_PAGEUP: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::page_up, shiftPressed); + case SDLK_PAGEDOWN: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::page_down, shiftPressed); + case SDLK_i: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::i, shiftPressed); + case SDLK_m: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::m, shiftPressed); + case SDLK_F3: + return input::map_keyboard_key_to_ui_command(input::KeyboardKey::f3, shiftPressed); + default: + return input::UiCommand::none; + } + } + + input::UiCommand translate_trigger_axis(const SDL_ControllerAxisEvent &event, bool *leftTriggerPressed, bool *rightTriggerPressed) { + if (leftTriggerPressed == nullptr || rightTriggerPressed == nullptr) { + return input::UiCommand::none; + } + + const bool thresholdCrossed = event.value >= TRIGGER_PAGE_SCROLL_THRESHOLD; + + switch (event.axis) { + case SDL_CONTROLLER_AXIS_TRIGGERLEFT: + if (thresholdCrossed && !*leftTriggerPressed) { + *leftTriggerPressed = true; + return input::UiCommand::fast_previous_page; + } + if (!thresholdCrossed) { + *leftTriggerPressed = false; + } + break; + case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: + if (thresholdCrossed && !*rightTriggerPressed) { + *rightTriggerPressed = true; + return input::UiCommand::fast_next_page; + } + if (!thresholdCrossed) { + *rightTriggerPressed = false; + } + break; + default: + break; + } + + return input::UiCommand::none; + } + + /** + * @brief Tracks the repeat timing for one controller navigation direction. + */ + struct ControllerNavigationHoldState { + bool active = false; ///< True while the direction remains held past activation. + Uint32 activatedTick = 0U; ///< Tick count when the direction first became active. + Uint32 lastRepeatTick = 0U; ///< Tick count when the last repeat command fired. + }; + + /** + * @brief Clears the held-repeat bookkeeping for every controller navigation direction. + * + * @param upState Repeat state for up navigation. + * @param downState Repeat state for down navigation. + * @param leftState Repeat state for left navigation. + * @param rightState Repeat state for right navigation. + */ + void reset_controller_navigation_hold_states( + ControllerNavigationHoldState *upState, + ControllerNavigationHoldState *downState, + ControllerNavigationHoldState *leftState, + ControllerNavigationHoldState *rightState + ) { + if (upState != nullptr) { + *upState = {}; + } + if (downState != nullptr) { + *downState = {}; + } + if (leftState != nullptr) { + *leftState = {}; + } + if (rightState != nullptr) { + *rightState = {}; + } + } + + /** + * @brief Returns whether a UI command is part of directional navigation and should keep repeat state alive. + * + * @param command UI command to classify. + * @return True when the command represents directional navigation. + */ + bool is_navigation_command(input::UiCommand command) { + switch (command) { + case input::UiCommand::move_up: + case input::UiCommand::move_down: + case input::UiCommand::move_left: + case input::UiCommand::move_right: + return true; + 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::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 false; + } + + return false; + } + + /** + * @brief Returns whether a negative stick direction remains active, using hysteresis to avoid jitter near center. + * + * @param value Current stick axis value. + * @param state Existing hold state for the direction. + * @return True when the negative navigation direction should be treated as active. + */ + bool axis_value_is_negative_navigation_active(Sint16 value, const ControllerNavigationHoldState *state) { + const Sint16 threshold = state != nullptr && state->active ? LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD : LEFT_STICK_NAVIGATION_THRESHOLD; + return value <= -threshold; + } + + /** + * @brief Returns whether a positive stick direction remains active, using hysteresis to avoid jitter near center. + * + * @param value Current stick axis value. + * @param state Existing hold state for the direction. + * @return True when the positive navigation direction should be treated as active. + */ + bool axis_value_is_positive_navigation_active(Sint16 value, const ControllerNavigationHoldState *state) { + const Sint16 threshold = state != nullptr && state->active ? LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD : LEFT_STICK_NAVIGATION_THRESHOLD; + return value >= threshold; + } + + /** + * @brief Returns the held-repeat state associated with a directional UI command. + * + * @param command UI command whose hold state should be returned. + * @param upState Repeat state for up navigation. + * @param downState Repeat state for down navigation. + * @param leftState Repeat state for left navigation. + * @param rightState Repeat state for right navigation. + * @return Pointer to the matching hold state, or null when the command is not directional. + */ + ControllerNavigationHoldState *controller_navigation_hold_state_for_command( + input::UiCommand command, + ControllerNavigationHoldState *upState, + ControllerNavigationHoldState *downState, + ControllerNavigationHoldState *leftState, + ControllerNavigationHoldState *rightState + ) { + switch (command) { + case input::UiCommand::move_up: + return upState; + case input::UiCommand::move_down: + return downState; + case input::UiCommand::move_left: + return leftState; + case input::UiCommand::move_right: + return rightState; + 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::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 nullptr; + } + + return nullptr; + } + + /** + * @brief Arms held-repeat timing for a freshly pressed navigation direction. + * + * @param now Current SDL tick count. + * @param command Directional command that was just pressed. + * @param upState Repeat state for up navigation. + * @param downState Repeat state for down navigation. + * @param leftState Repeat state for left navigation. + * @param rightState Repeat state for right navigation. + */ + void seed_controller_navigation_hold_state( + Uint32 now, + input::UiCommand command, + ControllerNavigationHoldState *upState, + ControllerNavigationHoldState *downState, + ControllerNavigationHoldState *leftState, + ControllerNavigationHoldState *rightState + ) { + if (command == input::UiCommand::move_up && downState != nullptr) { + *downState = {}; + } else if (command == input::UiCommand::move_down && upState != nullptr) { + *upState = {}; + } else if (command == input::UiCommand::move_left && rightState != nullptr) { + *rightState = {}; + } else if (command == input::UiCommand::move_right && leftState != nullptr) { + *leftState = {}; + } + + if (ControllerNavigationHoldState *state = controller_navigation_hold_state_for_command(command, upState, downState, leftState, rightState); state != nullptr) { + state->active = true; + state->activatedTick = now; + state->lastRepeatTick = now; + } + } + + /** + * @brief Clears held-repeat timing for a released navigation direction. + * + * @param command Directional command that was just released. + * @param upState Repeat state for up navigation. + * @param downState Repeat state for down navigation. + * @param leftState Repeat state for left navigation. + * @param rightState Repeat state for right navigation. + */ + void release_controller_navigation_hold_state( + input::UiCommand command, + ControllerNavigationHoldState *upState, + ControllerNavigationHoldState *downState, + ControllerNavigationHoldState *leftState, + ControllerNavigationHoldState *rightState + ) { + if (ControllerNavigationHoldState *state = controller_navigation_hold_state_for_command(command, upState, downState, leftState, rightState); state != nullptr) { + *state = {}; + } + } + + /** + * @brief Returns whether the controller is still holding any D-pad or left-stick navigation input. + * + * @param controller SDL game controller to inspect. + * @return True when any navigation direction is still held. + */ + bool is_controller_navigation_active(SDL_GameController *controller) { + if (controller == nullptr) { + return false; + } + + const Sint16 leftStickX = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX); + const Sint16 leftStickY = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY); + return SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_UP) != 0 || SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_DOWN) != 0 || + SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_LEFT) != 0 || SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) != 0 || + leftStickY <= -LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD || leftStickY >= LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD || leftStickX <= -LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD || + leftStickX >= LEFT_STICK_NAVIGATION_RELEASE_THRESHOLD; + } + + input::UiCommand update_controller_navigation_hold_state(bool active, Uint32 now, input::UiCommand command, ControllerNavigationHoldState *state) { + if (state == nullptr || command == input::UiCommand::none) { + return input::UiCommand::none; + } + + if (!active) { + *state = {}; + return input::UiCommand::none; + } + + if (!state->active) { + state->active = true; + state->activatedTick = now; + state->lastRepeatTick = now; + return command; + } + + if (now - state->activatedTick < CONTROLLER_NAVIGATION_INITIAL_REPEAT_MILLISECONDS || now - state->lastRepeatTick < CONTROLLER_NAVIGATION_REPEAT_MILLISECONDS) { + return input::UiCommand::none; + } + + state->lastRepeatTick = now; + return command; + } + + input::UiCommand poll_controller_navigation( + SDL_GameController *controller, + Uint32 now, + ControllerNavigationHoldState *upState, + ControllerNavigationHoldState *downState, + ControllerNavigationHoldState *leftState, + ControllerNavigationHoldState *rightState + ) { + if (controller == nullptr) { + reset_controller_navigation_hold_states(upState, downState, leftState, rightState); + return input::UiCommand::none; + } + + const Sint16 leftStickX = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTX); + const Sint16 leftStickY = SDL_GameControllerGetAxis(controller, SDL_CONTROLLER_AXIS_LEFTY); + const bool moveUpActive = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_UP) != 0 || axis_value_is_negative_navigation_active(leftStickY, upState); + const bool moveDownActive = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_DOWN) != 0 || axis_value_is_positive_navigation_active(leftStickY, downState); + const bool moveLeftActive = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_LEFT) != 0 || axis_value_is_negative_navigation_active(leftStickX, leftState); + const bool moveRightActive = SDL_GameControllerGetButton(controller, SDL_CONTROLLER_BUTTON_DPAD_RIGHT) != 0 || axis_value_is_positive_navigation_active(leftStickX, rightState); + + if (const input::UiCommand command = update_controller_navigation_hold_state(moveUpActive, now, input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_up), upState); command != input::UiCommand::none) { + return command; + } + if (const input::UiCommand command = update_controller_navigation_hold_state(moveDownActive, now, input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_down), downState); command != input::UiCommand::none) { + return command; + } + if (const input::UiCommand command = update_controller_navigation_hold_state(moveLeftActive, now, input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_left), leftState); command != input::UiCommand::none) { + return command; + } + return update_controller_navigation_hold_state(moveRightActive, now, input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_right), rightState); + } + + void log_app_update(const app::ClientState &state, const app::AppUpdate &update) { + if (!update.navigation.activatedItemId.empty()) { + logging::info("ui", "Activated menu item: " + update.navigation.activatedItemId); + } + if (update.navigation.screenChanged) { + logging::info("ui", std::string("Switched screen to ") + app::to_string(state.shell.activeScreen)); + } + if (update.navigation.overlayVisibilityChanged) { + logging::info("overlay", state.shell.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + } + if (update.navigation.exitRequested) { + logging::info("app", "Exit requested from shell"); + } + } + + app::HostRecord *find_persisted_host_record(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); + } + + void merge_host_for_persistence(app::HostRecord *targetHost, const app::HostRecord &sourceHost) { + if (targetHost == nullptr) { + return; + } + + targetHost->displayName = sourceHost.displayName; + targetHost->address = sourceHost.address; + targetHost->port = sourceHost.port; + targetHost->pairingState = sourceHost.pairingState; + targetHost->reachability = sourceHost.reachability; + targetHost->activeAddress = sourceHost.activeAddress; + targetHost->uuid = sourceHost.uuid; + targetHost->localAddress = sourceHost.localAddress; + targetHost->remoteAddress = sourceHost.remoteAddress; + targetHost->ipv6Address = sourceHost.ipv6Address; + targetHost->manualAddress = sourceHost.manualAddress; + targetHost->macAddress = sourceHost.macAddress; + targetHost->httpsPort = sourceHost.httpsPort; + targetHost->runningGameId = sourceHost.runningGameId; + targetHost->apps = sourceHost.apps; + targetHost->appListState = sourceHost.appListState; + targetHost->appListStatusMessage = sourceHost.appListStatusMessage; + targetHost->resolvedHttpPort = sourceHost.resolvedHttpPort; + targetHost->appListContentHash = sourceHost.appListContentHash; + targetHost->lastAppListRefreshTick = sourceHost.lastAppListRefreshTick; + } + + bool ensure_hosts_loaded_for_active_screen(app::ClientState &state) { + if (state.shell.activeScreen != app::ScreenId::hosts || state.hosts.loaded) { + return true; + } + + const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); + for (const std::string &warning : loadedHosts.warnings) { + logging::warn("hosts", warning); + } + app::replace_hosts(state, loadedHosts.hosts, state.shell.statusMessage); + return true; + } + + bool persist_hosts(app::ClientState &state) { + std::vector hostsToSave; + if (state.hosts.loaded) { + hostsToSave = state.hosts.items; + } else if (state.hosts.activeLoaded) { + const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); + for (const std::string &warning : loadedHosts.warnings) { + logging::warn("hosts", warning); + } + hostsToSave = loadedHosts.hosts; + if (app::HostRecord *host = find_persisted_host_record(hostsToSave, state.hosts.active.address, state.hosts.active.port); host != nullptr) { + merge_host_for_persistence(host, state.hosts.active); + } else { + hostsToSave.push_back(state.hosts.active); + } + } else { + hostsToSave = state.hosts.items; + } + + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hostsToSave); + if (saveResult.success) { + state.hosts.dirty = false; + logging::info("hosts", "Saved host records"); + return true; + } + + logging::error("hosts", saveResult.errorMessage); + return false; + } + + void persist_hosts_if_needed(app::ClientState &state, const app::AppUpdate &update) { + if (!update.persistence.hostsChanged) { + return; + } + + persist_hosts(state); + } + + app::AppSettings persistent_settings_from_state(const app::ClientState &state) { + return { + state.settings.loggingLevel, + state.settings.xemuConsoleLoggingLevel, + state.settings.logViewerPlacement, + }; + } + + void persist_settings_if_needed(app::ClientState &state, const app::AppUpdate &update) { + if (!update.persistence.settingsChanged || !state.settings.dirty) { + return; + } + + const app::SaveAppSettingsResult saveResult = app::save_app_settings(persistent_settings_from_state(state)); + if (saveResult.success) { + state.settings.dirty = false; + logging::info("settings", "Saved Moonlight settings"); + return; + } + + logging::error("settings", saveResult.errorMessage); + } + + bool update_host_metadata_from_server_info(app::HostRecord *host, const std::string &address, const network::HostPairingServerInfo &serverInfo) { + if (host == nullptr) { + return false; + } + + bool persistedMetadataChanged = false; + if (!serverInfo.hostName.empty()) { + persistedMetadataChanged = persistedMetadataChanged || host->displayName != serverInfo.hostName; + host->displayName = serverInfo.hostName; + } + host->reachability = app::HostReachability::online; + host->activeAddress = network::resolve_reachable_address(address, serverInfo); + host->uuid = serverInfo.uuid; + host->localAddress = serverInfo.localAddress; + host->remoteAddress = serverInfo.remoteAddress; + host->ipv6Address = serverInfo.ipv6Address; + host->manualAddress = address; + host->macAddress = serverInfo.macAddress; + host->resolvedHttpPort = serverInfo.httpPort; + host->httpsPort = serverInfo.httpsPort; + host->runningGameId = serverInfo.runningGameId; + return persistedMetadataChanged; + } + + bool update_host_pairing_from_server_info( + app::ClientState &state, + app::HostRecord *host, + const std::string &address, + uint16_t port, + const network::HostPairingServerInfo &serverInfo + ) { + if (host == nullptr || !serverInfo.pairingStatusCurrentClientKnown) { + return false; + } + + const bool hostRequiresManualPairing = app::host_requires_manual_pairing(state, address, port); + const bool clientIsEffectivelyPaired = serverInfo.pairingStatusCurrentClient && !hostRequiresManualPairing; + const app::PairingState resolvedPairingState = clientIsEffectivelyPaired ? app::PairingState::paired : app::PairingState::not_paired; + const bool pairingChanged = host->pairingState != resolvedPairingState; + host->pairingState = resolvedPairingState; + if (clientIsEffectivelyPaired) { + return pairingChanged; + } + + host->apps.clear(); + host->appListState = hostRequiresManualPairing ? app::HostAppListState::idle : app::HostAppListState::failed; + host->appListStatusMessage = hostRequiresManualPairing ? "This host was removed locally. Pair it again to restore apps and authorization." : "The host reports that this client is no longer paired. Pair the host again."; + host->appListContentHash = 0; + host->lastAppListRefreshTick = 0; + state.apps.selectedAppIndex = 0U; + if (state.shell.activeScreen == app::ScreenId::apps && state.hosts.activeLoaded && host == &state.hosts.active) { + state.shell.statusMessage = host->appListStatusMessage; + } + return true; + } + + void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { + auto apply_to_host = [&](app::HostRecord &host) { + bool persistedMetadataChanged = update_host_metadata_from_server_info(&host, address, serverInfo); + persistedMetadataChanged = update_host_pairing_from_server_info(state, &host, address, port, serverInfo) || persistedMetadataChanged; + state.hosts.dirty = state.hosts.dirty || persistedMetadataChanged; + }; + + for (app::HostRecord &host : state.hosts.items) { + if (!app::host_matches_endpoint(host, address, port)) { + continue; + } + apply_to_host(host); + return; + } + + if (state.hosts.activeLoaded && app::host_matches_endpoint(state.hosts.active, address, port)) { + apply_to_host(state.hosts.active); + } + } + + std::string display_name_for_saved_file(const app::ClientState &state, const std::string &path) { + for (const startup::SavedFileEntry &savedFile : state.settings.savedFiles) { + if (savedFile.path == path) { + return savedFile.displayName; + } + } + return path; + } + + std::string cover_art_cache_key_from_path(const std::string &path) { + if (const std::string coverArtRoot = startup::default_cover_art_cache_root(); coverArtRoot.empty() || path.size() <= coverArtRoot.size() || path.rfind(coverArtRoot, 0U) != 0U) { + return {}; + } + + const std::string fileName = platform::file_name_from_path(path); + if (fileName.empty()) { + return {}; + } + + if (fileName.size() <= 4U || fileName.substr(fileName.size() - 4U) != ".bin") { + return {}; + } + return fileName.substr(0, fileName.size() - 4U); + } + + void clear_deleted_cover_art_flag(app::ClientState &state, std::string_view cacheKey) { + if (cacheKey.empty()) { + return; + } + + for (app::HostRecord &host : state.hosts.items) { + for (app::HostAppRecord &appRecord : host.apps) { + if (appRecord.boxArtCacheKey == cacheKey) { + appRecord.boxArtCached = false; + } + } + } + } + + void refresh_saved_files_if_needed(app::ClientState &state) { + if (state.shell.activeScreen != app::ScreenId::settings || !state.settings.savedFilesDirty) { + return; + } + + const startup::ListSavedFilesResult savedFiles = startup::list_saved_files(); + for (const std::string &warning : savedFiles.warnings) { + logging::warn("storage", warning); + } + app::replace_saved_files(state, savedFiles.files); + } + + void release_page_resources_for_screen(app::ScreenId previousScreen, app::ScreenId nextScreen, CoverArtTextureCache *coverArtTextureCache, KeypadModalLayoutCache *keypadModalLayoutCache) { + if (previousScreen != nextScreen && previousScreen == app::ScreenId::apps) { + clear_cover_art_texture_cache(coverArtTextureCache); + } + if (previousScreen != nextScreen && previousScreen == app::ScreenId::add_host) { + clear_keypad_modal_layout_cache(keypadModalLayoutCache); + } + } + + void delete_saved_file_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + if (!update.persistence.savedFileDeleteRequested) { + return; + } + + if (std::string errorMessage; !startup::delete_saved_file(update.persistence.savedFileDeletePath, &errorMessage)) { + state.shell.statusMessage = errorMessage; + logging::warn("storage", errorMessage); + return; + } + + const std::string deletedDisplayName = display_name_for_saved_file(state, update.persistence.savedFileDeletePath); + const std::string deletedCoverArtCacheKey = cover_art_cache_key_from_path(update.persistence.savedFileDeletePath); + clear_deleted_cover_art_flag(state, deletedCoverArtCacheKey); + clear_cover_art_texture(coverArtTextureCache, deletedCoverArtCacheKey); + state.settings.savedFilesDirty = true; + state.shell.statusMessage = "Deleted saved file " + deletedDisplayName; + logging::info("storage", state.shell.statusMessage); + } + + void delete_host_data_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + if (!update.persistence.hostDeleteCleanupRequested) { + return; + } + + std::size_t deletedCoverArtCount = 0U; + for (const std::string &cacheKey : update.persistence.deletedHostCoverArtCacheKeys) { + if (std::string errorMessage; !startup::delete_cover_art(cacheKey, &errorMessage)) { + logging::warn("storage", errorMessage); + } else { + ++deletedCoverArtCount; + } + clear_cover_art_texture(coverArtTextureCache, cacheKey); + } + + bool deletedClientIdentity = false; + if (update.persistence.deletedHostWasPaired) { + const bool pairedHostsRemain = std::any_of(state.hosts.begin(), state.hosts.end(), [](const app::HostRecord &host) { + return host.pairingState == app::PairingState::paired; + }); + if (!pairedHostsRemain) { + std::string errorMessage; + if (!startup::delete_client_identity(&errorMessage)) { + logging::warn("storage", errorMessage); + } else { + deletedClientIdentity = true; + } + } else { + logging::info("storage", "Retained the shared pairing identity because other paired hosts still exist"); + } + } + + state.shell.statusMessage = "Deleted saved host"; + if (deletedCoverArtCount > 0U) { + state.shell.statusMessage += " and cleared " + std::to_string(deletedCoverArtCount) + " cached asset" + (deletedCoverArtCount == 1U ? std::string {} : "s"); + } + if (deletedClientIdentity) { + state.shell.statusMessage += " and reset local pairing identity"; + } + logging::info("storage", state.shell.statusMessage); + } + + void factory_reset_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + if (!update.persistence.factoryResetRequested) { + return; + } + + if (std::string errorMessage; !startup::delete_all_saved_files(&errorMessage)) { + state.shell.statusMessage = errorMessage; + logging::warn("storage", errorMessage); + return; + } + + state.hosts.clear(); + state = app::create_initial_state(); + state.settings.savedFiles.clear(); + state.settings.savedFilesDirty = true; + state.shell.statusMessage = "Factory reset completed"; + clear_cover_art_texture_cache(coverArtTextureCache); + app::set_log_file_path(state, logging::default_log_file_path()); + logging::info("storage", state.shell.statusMessage); + } + + bool try_load_saved_pairing_identity(network::PairingIdentity *identity) { + const startup::LoadClientIdentityResult loadedIdentity = startup::load_client_identity(); + if (!loadedIdentity.fileFound || !network::is_valid_pairing_identity(loadedIdentity.identity)) { + return false; + } + + if (identity != nullptr) { + *identity = loadedIdentity.identity; + } + return true; + } + + bool load_saved_pairing_identity_for_streaming(network::PairingIdentity *identity, std::string *errorMessage) { + const startup::LoadClientIdentityResult loadedIdentity = startup::load_client_identity(); + if (!loadedIdentity.fileFound || !network::is_valid_pairing_identity(loadedIdentity.identity)) { + return platform::append_error(errorMessage, "No valid paired client identity is available. Pair the host again before browsing apps."); + } + + if (identity != nullptr) { + *identity = loadedIdentity.identity; + } + return true; + } + + bool test_tcp_host_connection( + const std::string &address, + uint16_t port, + const network::PairingIdentity *clientIdentity, + std::string *message, + network::HostPairingServerInfo *serverInfoResult = nullptr + ) { + if (!network::runtime_network_ready()) { + if (message != nullptr) { + *message = network::runtime_network_status().summary; + } + return false; + } + + network::HostPairingServerInfo serverInfo {}; + if (std::string errorMessage; !network::query_server_info(address, port, clientIdentity, &serverInfo, &errorMessage)) { + if (message != nullptr) { + *message = std::move(errorMessage); + } + return false; + } + + if (serverInfoResult != nullptr) { + *serverInfoResult = serverInfo; + } + + if (message != nullptr) { + *message = "Received /serverinfo from " + address + ":" + std::to_string(serverInfo.httpPort) + " and discovered HTTPS pairing on port " + std::to_string(serverInfo.httpsPort); + if (serverInfo.pairingStatusCurrentClientKnown) { + *message += serverInfo.pairingStatusCurrentClient ? "; the current client is paired and authorized" : "; the current client is no longer paired or authorized"; + } + } + return true; + } + + struct PairingAttemptState { + SDL_Thread *thread; + std::atomic completed; + std::atomic discardResult; + std::atomic cancelRequested; + network::HostPairingRequest request; + network::HostPairingResult result; + + struct DeferredLogEntry { + logging::LogLevel level; + std::string message; + }; + + std::vector deferredLogs; + }; + + struct PairingTaskState { + std::unique_ptr activeAttempt; + std::vector> retiredAttempts; + }; + + struct AppListTaskState { + SDL_Thread *thread = nullptr; + std::atomic completed = false; + std::string address; + uint16_t port = 0; + bool serverInfoAvailable = false; + bool success = false; + uint64_t appListContentHash = 0; + std::string message; + network::HostPairingServerInfo serverInfo; + std::vector apps; + }; + + struct AppArtTaskState { + SDL_Thread *thread = nullptr; + std::atomic completed = false; + std::string address; + uint16_t port = 0; + std::vector apps; + std::vector cachedAppIds; + std::size_t failureCount = 0; + }; + + struct HostProbeTaskState { + struct ProbeWorkerState { + SDL_Thread *thread = nullptr; + std::atomic completed = false; + std::string address; + uint16_t port = 0; + bool success = false; + network::HostPairingServerInfo serverInfo; + const network::PairingIdentity *clientIdentity = nullptr; + ui::HostProbeResultQueue *resultQueue = nullptr; + }; + + std::vector> workers; + network::PairingIdentity clientIdentity; + bool clientIdentityAvailable = false; + ui::HostProbeResultQueue resultQueue; + std::size_t onlineCount = 0U; + std::size_t offlineCount = 0U; + bool metadataChanged = false; + }; + + void reset_pairing_attempt(PairingAttemptState *attempt) { + if (attempt == nullptr) { + return; + } + + attempt->thread = nullptr; + attempt->completed.store(false); + attempt->discardResult.store(false); + attempt->cancelRequested.store(false); + attempt->request = {}; + attempt->result = {false, false, {}}; + attempt->deferredLogs.clear(); + } + + void reset_pairing_task(PairingTaskState *task) { + if (task == nullptr) { + return; + } + + task->activeAttempt.reset(); + task->retiredAttempts.clear(); + } + + bool pairing_task_is_active(const PairingTaskState &task) { + if (task.activeAttempt == nullptr) { + return false; + } + return task.activeAttempt->thread != nullptr && !task.activeAttempt->completed.load(); + } + + bool pairing_attempt_is_ready(const PairingAttemptState *attempt) { + return attempt != nullptr && attempt->thread != nullptr && attempt->completed.load(); + } + + void finalize_pairing_attempt(app::ClientState *state, std::unique_ptr attempt) { + if (attempt == nullptr || attempt->thread == nullptr) { + return; + } + + int threadResult = 0; + SDL_WaitThread(attempt->thread, &threadResult); + (void) threadResult; + + const network::HostPairingRequest request = attempt->request; + const network::HostPairingResult result = attempt->result; + const bool discardResult = attempt->discardResult.load(); + const std::vector deferredLogs = attempt->deferredLogs; + reset_pairing_attempt(attempt.get()); + + for (const PairingAttemptState::DeferredLogEntry &entry : deferredLogs) { + logging::log(entry.level, "pairing", entry.message); + } + + if (discardResult || state == nullptr) { + logging::info("pairing", "Ignored a completed pairing result after leaving the pairing screen or starting a new attempt"); + return; + } + + const bool hostsChanged = app::apply_pairing_result( + *state, + request.address, + request.httpPort, + result.success || result.alreadyPaired, + result.message + ); + + logging::log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); + if (hostsChanged) { + persist_hosts(*state); + } + } + + void retire_active_pairing_attempt(PairingTaskState *task, bool discardResult) { + if (task == nullptr || task->activeAttempt == nullptr) { + return; + } + + if (discardResult) { + task->activeAttempt->discardResult.store(true); + } + task->activeAttempt->cancelRequested.store(true); + task->retiredAttempts.push_back(std::move(task->activeAttempt)); + } + + void reap_retired_pairing_attempts(PairingTaskState *task) { + if (task == nullptr) { + return; + } + + auto iterator = task->retiredAttempts.begin(); + while (iterator != task->retiredAttempts.end()) { + if (!pairing_attempt_is_ready(iterator->get())) { + ++iterator; + continue; + } + + finalize_pairing_attempt(nullptr, std::move(*iterator)); + iterator = task->retiredAttempts.erase(iterator); + } + } + + void reset_app_list_task(AppListTaskState *task) { + if (task == nullptr) { + return; + } + + task->thread = nullptr; + task->completed.store(false); + task->address.clear(); + task->port = 0; + task->serverInfoAvailable = false; + task->success = false; + task->appListContentHash = 0; + task->message.clear(); + task->serverInfo = {}; + task->apps.clear(); + } + + bool app_list_task_is_active(const AppListTaskState &task) { + return task.thread != nullptr && !task.completed.load(); + } + + void reset_app_art_task(AppArtTaskState *task) { + if (task == nullptr) { + return; + } + + task->thread = nullptr; + task->completed.store(false); + task->address.clear(); + task->port = 0; + task->apps.clear(); + task->cachedAppIds.clear(); + task->failureCount = 0; + } + + bool app_art_task_is_active(const AppArtTaskState &task) { + return task.thread != nullptr && !task.completed.load(); + } + + void reset_host_probe_task(HostProbeTaskState *task) { + if (task == nullptr) { + return; + } + + task->workers.clear(); + task->clientIdentity = {}; + task->clientIdentityAvailable = false; + ui::reset_host_probe_result_queue(&task->resultQueue); + task->onlineCount = 0U; + task->offlineCount = 0U; + task->metadataChanged = false; + } + + bool host_probe_task_is_active(const HostProbeTaskState &task) { + return !task.workers.empty(); + } + + bool mark_host_offline(app::ClientState &state, const std::string &address, uint16_t port) { + for (app::HostRecord &host : state.hosts) { + if (!app::host_matches_endpoint(host, address, port)) { + continue; + } + + host.reachability = app::HostReachability::offline; + host.manualAddress = address; + return true; + } + + return false; + } + + int run_pairing_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature + auto *task = static_cast(context); + if (task == nullptr) { + return -1; + } + + const startup::LoadClientIdentityResult loadedIdentity = startup::load_client_identity(); + for (const std::string &warning : loadedIdentity.warnings) { + task->deferredLogs.push_back({logging::LogLevel::warning, warning}); + } + + network::PairingIdentity identity = loadedIdentity.identity; + if (!loadedIdentity.fileFound || !network::is_valid_pairing_identity(identity)) { + if (loadedIdentity.fileFound) { + task->deferredLogs.push_back({logging::LogLevel::warning, "Stored pairing identity was invalid. Generating a new one."}); + } + + std::string identityError; + identity = network::create_pairing_identity(&identityError); + if (!network::is_valid_pairing_identity(identity)) { + task->result = { + false, + false, + identityError.empty() ? "Failed to generate a valid client pairing identity" : "Failed to generate a valid client pairing identity: " + identityError, + }; + task->completed.store(true); + return 0; + } + + if (const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity); !saveResult.success) { + task->result = {false, false, saveResult.errorMessage}; + task->completed.store(true); + return 0; + } + + task->deferredLogs.push_back({logging::LogLevel::info, "Saved pairing identity"}); + } + + task->request.identity = std::move(identity); + task->result = network::pair_host(task->request, &task->cancelRequested); + task->completed.store(true); + return 0; + } + + void finish_pairing_task_if_ready(app::ClientState &state, PairingTaskState *task) { + if (task == nullptr) { + return; + } + + reap_retired_pairing_attempts(task); + if (!pairing_attempt_is_ready(task->activeAttempt.get())) { + return; + } + + finalize_pairing_attempt(&state, std::move(task->activeAttempt)); + } + + void cancel_pairing_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + if (task == nullptr || !update.requests.pairingCancelledRequested || task->activeAttempt == nullptr || task->activeAttempt->thread == nullptr) { + return; + } + + task->activeAttempt->discardResult.store(true); + task->activeAttempt->cancelRequested.store(true); + retire_active_pairing_attempt(task, true); + state.shell.statusMessage.clear(); + logging::info("pairing", "Cancelled the in-flight pairing attempt after leaving the pairing screen"); + } + + void test_host_connection_if_requested(app::ClientState &state, const app::AppUpdate &update) { + if (!update.requests.connectionTestRequested) { + return; + } + + const std::string address = update.requests.connectionTestAddress; + const uint16_t port = update.requests.connectionTestPort == 0 ? app::DEFAULT_HOST_PORT : update.requests.connectionTestPort; + + if (address.empty()) { + app::apply_connection_test_result(state, false, "Connection test failed because the host address is invalid"); + logging::warn("hosts", state.shell.statusMessage); + return; + } + + network::PairingIdentity clientIdentity {}; + const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; + + std::string resultMessage; + network::HostPairingServerInfo serverInfo {}; + const bool success = test_tcp_host_connection(address, port, clientIdentityPointer, &resultMessage, &serverInfo); + if (success) { + apply_server_info_to_host(state, address, port, serverInfo); + } else { + for (app::HostRecord &host : state.hosts.items) { + if (host.address == address && app::effective_host_port(host.port) == port) { + host.reachability = app::HostReachability::offline; + host.manualAddress = address; + break; + } + } + } + app::apply_connection_test_result(state, success, resultMessage); + logging::log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); + if (state.hosts.dirty) { + persist_hosts(state); + } + } + + void browse_host_apps_if_requested(app::ClientState &state, const app::AppUpdate &update) { + if (!update.requests.appsBrowseRequested) { + return; + } + + const app::HostRecord *host = app::selected_host(state); + if (host == nullptr) { + return; + } + + const std::string address = host->address; + const uint16_t port = host->resolvedHttpPort == 0 ? app::effective_host_port(host->port) : host->resolvedHttpPort; + network::PairingIdentity clientIdentity {}; + const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; + + std::string resultMessage; + network::HostPairingServerInfo serverInfo {}; + if (!test_tcp_host_connection(address, port, clientIdentityPointer, &resultMessage, &serverInfo)) { + for (app::HostRecord &mutableHost : state.hosts.items) { + if (mutableHost.address == address && app::effective_host_port(mutableHost.port) == app::effective_host_port(port)) { + mutableHost.reachability = app::HostReachability::offline; + mutableHost.manualAddress = address; + break; + } + } + state.shell.statusMessage = resultMessage; + logging::warn("apps", resultMessage); + return; + } + + apply_server_info_to_host(state, address, port, serverInfo); + if (state.hosts.dirty) { + persist_hosts(state); + } + + host = app::selected_host(state); + if (host == nullptr || host->pairingState != app::PairingState::paired) { + state.shell.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again before opening apps."; + logging::warn("apps", state.shell.statusMessage); + return; + } + + if (app::begin_selected_host_app_browse(state, update.requests.appsBrowseShowHidden)) { + logging::info("apps", "Authorized host browse for " + host->displayName); + return; + } + + logging::warn("apps", state.shell.statusMessage.empty() ? "Failed to enter the apps screen" : state.shell.statusMessage); + } + + int run_host_probe_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature + auto *worker = static_cast(context); + if (worker == nullptr) { + return -1; + } + + worker->success = test_tcp_host_connection(worker->address, worker->port, worker->clientIdentity, nullptr, &worker->serverInfo); + ui::publish_host_probe_result( + worker->resultQueue, + { + worker->address, + worker->port, + worker->success, + worker->serverInfo, + } + ); + worker->completed.store(true); + return 0; + } + + void apply_published_host_probe_results(app::ClientState &state, HostProbeTaskState *task) { + if (task == nullptr) { + return; + } + + const std::vector results = ui::drain_host_probe_results(&task->resultQueue); + for (const ui::HostProbeResult &result : results) { + if (result.success) { + if (state.shell.activeScreen == app::ScreenId::hosts && state.hosts.loaded) { + apply_server_info_to_host(state, result.address, result.port, result.serverInfo); + task->metadataChanged = task->metadataChanged || state.hosts.dirty; + } + ++task->onlineCount; + continue; + } + + if (state.shell.activeScreen == app::ScreenId::hosts && state.hosts.loaded) { + mark_host_offline(state, result.address, result.port); + } + ++task->offlineCount; + } + } + + void reap_completed_host_probe_workers(HostProbeTaskState *task) { + if (task == nullptr) { + return; + } + + auto iterator = task->workers.begin(); + while (iterator != task->workers.end()) { + if ((*iterator)->thread == nullptr || !(*iterator)->completed.load()) { + ++iterator; + continue; + } + + int threadResult = 0; + SDL_WaitThread((*iterator)->thread, &threadResult); + (void) threadResult; + iterator = task->workers.erase(iterator); + } + } + + void finish_host_probe_task_if_ready(app::ClientState &state, HostProbeTaskState *task) { + if (task == nullptr) { + return; + } + + apply_published_host_probe_results(state, task); + reap_completed_host_probe_workers(task); + apply_published_host_probe_results(state, task); + if (host_probe_task_is_active(*task) || !ui::host_probe_result_round_complete(task->resultQueue)) { + return; + } + + logging::debug( + "hosts", + "Refreshed " + std::to_string(task->onlineCount + task->offlineCount) + " saved host(s): " + std::to_string(task->onlineCount) + " online, " + std::to_string(task->offlineCount) + " offline" + ); + if (task->metadataChanged) { + persist_hosts(state); + } + reset_host_probe_task(task); + } + + void start_host_probe_task_if_needed(const app::ClientState &state, HostProbeTaskState *task, Uint32 now, Uint32 *nextHostProbeTick) { + if (task == nullptr || host_probe_task_is_active(*task) || state.shell.activeScreen != app::ScreenId::hosts || !state.hosts.loaded || !network::runtime_network_ready()) { + return; + } + if (nextHostProbeTick != nullptr && *nextHostProbeTick != 0U && now < *nextHostProbeTick) { + return; + } + + reset_host_probe_task(task); + task->clientIdentityAvailable = try_load_saved_pairing_identity(&task->clientIdentity); + ui::begin_host_probe_result_round(&task->resultQueue, state.hosts.items.size()); + for (const app::HostRecord &host : state.hosts.items) { + auto worker = std::make_unique(); + worker->address = host.address; + worker->port = app::effective_host_port(host.port); + worker->clientIdentity = task->clientIdentityAvailable ? &task->clientIdentity : nullptr; + worker->resultQueue = &task->resultQueue; + worker->thread = SDL_CreateThread(run_host_probe_task, "probe-saved-host", worker.get()); + if (worker->thread == nullptr) { + logging::error("hosts", "Failed to start the saved-host refresh worker for " + host.address + ": " + SDL_GetError()); + ui::skip_host_probe_result_target(&task->resultQueue); + continue; + } + + task->workers.push_back(std::move(worker)); + } + if (task->workers.empty()) { + reset_host_probe_task(task); + return; + } + + if (nextHostProbeTick != nullptr) { + *nextHostProbeTick = now + HOST_PROBE_REFRESH_INTERVAL_MILLISECONDS; + } + } + + void pair_host_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + if (!update.requests.pairingRequested || task == nullptr) { + return; + } + + finish_pairing_task_if_ready(state, task); + + if (pairing_task_is_active(*task)) { + retire_active_pairing_attempt(task, true); + logging::info("pairing", "Discarded the previous background pairing attempt and started a fresh one"); + } + + auto attempt = std::make_unique(); + reset_pairing_attempt(attempt.get()); + attempt->request = { + update.requests.pairingAddress, + app::effective_host_port(update.requests.pairingPort), + update.requests.pairingPin, + "MoonlightXboxOG", + {}, + }; + + attempt->thread = SDL_CreateThreadWithStackSize(run_pairing_task, "pair-host", PAIRING_THREAD_STACK_SIZE, attempt.get()); + if (attempt->thread == nullptr) { + reset_pairing_attempt(attempt.get()); + const std::string createThreadError = std::string("Failed to start the background pairing task: ") + SDL_GetError(); + app::apply_pairing_result(state, update.requests.pairingAddress, update.requests.pairingPort, false, createThreadError); + state.pairingDraft.generatedPin.clear(); + logging::error("pairing", createThreadError); + return; + } + + task->activeAttempt = std::move(attempt); + + state.pairingDraft.stage = app::PairingStage::in_progress; + state.pairingDraft.statusMessage = "Trying to reach the host now. Enter the PIN on the host when it appears and keep this screen open for the result."; + state.shell.statusMessage.clear(); + logging::info("pairing", "Started background pairing with " + update.requests.pairingAddress + ":" + std::to_string(update.requests.pairingPort)); + } + + int run_app_list_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature + auto *task = static_cast(context); + if (task == nullptr) { + return -1; + } + + network::PairingIdentity clientIdentity {}; + std::string errorMessage; + if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &errorMessage)) { + task->success = false; + task->message = errorMessage; + task->completed.store(true); + return 0; + } + + std::vector fetchedApps; + errorMessage.clear(); + task->success = network::query_app_list(task->address, task->port, &clientIdentity, &fetchedApps, &task->serverInfo, &errorMessage); + task->serverInfoAvailable = task->serverInfo.httpPort != 0 || task->serverInfo.httpsPort != 0 || !task->serverInfo.hostName.empty() || !task->serverInfo.uuid.empty(); + if (!task->success) { + task->message = errorMessage.empty() ? "Failed to fetch the host app list" : errorMessage; + task->completed.store(true); + return 0; + } + + task->appListContentHash = network::hash_app_list_entries(fetchedApps); + + task->apps.clear(); + task->apps.reserve(fetchedApps.size()); + const std::string hostIdentity = !task->serverInfo.uuid.empty() ? task->serverInfo.uuid : task->address; + for (const network::HostAppEntry &entry : fetchedApps) { + const std::string cacheKey = startup::build_cover_art_cache_key(hostIdentity, task->address, entry.id); + task->apps.push_back({ + entry.name, + entry.id, + entry.hdrSupported, + entry.hidden, + false, + cacheKey, + startup::cover_art_exists(cacheKey), + false, + }); + } + + task->message = task->apps.empty() ? "Host returned no launchable apps for this host" : "Loaded " + std::to_string(task->apps.size()) + " Host app(s)"; + task->completed.store(true); + return 0; + } + + void finish_app_list_task_if_ready(app::ClientState &state, AppListTaskState *task) { + if (task == nullptr || task->thread == nullptr || !task->completed.load()) { + return; + } + + SDL_Thread *thread = task->thread; + task->thread = nullptr; + int threadResult = 0; + SDL_WaitThread(thread, &threadResult); + (void) threadResult; + + const std::string address = task->address; + const uint16_t port = task->port; + const bool success = task->success; + const uint64_t appListContentHash = task->appListContentHash; + const std::string message = task->message; + const bool serverInfoAvailable = task->serverInfoAvailable; + const network::HostPairingServerInfo serverInfo = task->serverInfo; + std::vector apps = std::move(task->apps); + reset_app_list_task(task); + + if (serverInfoAvailable) { + apply_server_info_to_host(state, address, port, serverInfo); + } + + if (success) { + app::apply_app_list_result(state, address, port, std::move(apps), appListContentHash, true, message); + logging::info("apps", "Fetched app list from " + address + ":" + std::to_string(serverInfo.httpPort)); + if (state.hosts.dirty) { + persist_hosts(state); + } + return; + } + + app::apply_app_list_result(state, address, port, {}, 0, false, message); + logging::warn("apps", message); + } + + void start_app_list_task_if_needed(app::ClientState &state, AppListTaskState *task, Uint32 now) { + if (task == nullptr || app_list_task_is_active(*task) || state.shell.activeScreen != app::ScreenId::apps) { + return; + } + + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr || host->pairingState != app::PairingState::paired || host->reachability == app::HostReachability::offline) { + return; + } + + if (host->appListState != app::HostAppListState::loading) { + if (host->lastAppListRefreshTick != 0U && now - host->lastAppListRefreshTick < APP_LIST_REFRESH_INTERVAL_MILLISECONDS) { + return; + } + + if (state.hosts.activeLoaded) { + app::HostRecord &mutableHost = state.hosts.active; + mutableHost.appListState = app::HostAppListState::loading; + mutableHost.appListStatusMessage = (mutableHost.apps.empty() ? "Loading apps for " : "Refreshing apps for ") + mutableHost.displayName + "..."; + state.shell.statusMessage.clear(); + } + } + + reset_app_list_task(task); + task->address = host->address; + task->port = host->resolvedHttpPort == 0 ? app::effective_host_port(host->port) : host->resolvedHttpPort; + task->thread = SDL_CreateThread(run_app_list_task, "fetch-app-list", task); + if (task->thread == nullptr) { + const std::string errorMessage = std::string("Failed to start the app-list fetch task: ") + SDL_GetError(); + logging::error("apps", errorMessage); + if (state.hosts.activeLoaded) { + state.hosts.active.appListState = app::HostAppListState::failed; + state.hosts.active.appListStatusMessage = errorMessage; + state.shell.statusMessage = errorMessage; + } + reset_app_list_task(task); + return; + } + + if (state.hosts.activeLoaded) { + state.hosts.active.lastAppListRefreshTick = now; + } + } + + int run_app_art_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature + auto *task = static_cast(context); + if (task == nullptr) { + return -1; + } + + network::PairingIdentity clientIdentity {}; + if (std::string identityError; !load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + task->failureCount = task->apps.size(); + task->completed.store(true); + return 0; + } + + for (const app::HostAppRecord &appRecord : task->apps) { + if (appRecord.boxArtCached || appRecord.boxArtCacheKey.empty()) { + continue; + } + + std::vector assetBytes; + if (std::string errorMessage; !network::query_app_asset(task->address, task->port, &clientIdentity, appRecord.id, &assetBytes, &errorMessage)) { + ++task->failureCount; + continue; + } + + if (const startup::SaveCoverArtResult saveResult = startup::save_cover_art(appRecord.boxArtCacheKey, assetBytes); !saveResult.success) { + ++task->failureCount; + continue; + } + + task->cachedAppIds.push_back(appRecord.id); + } + + task->completed.store(true); + return 0; + } + + void finish_app_art_task_if_ready(app::ClientState &state, AppArtTaskState *task, CoverArtTextureCache *textureCache) { + if (task == nullptr || task->thread == nullptr || !task->completed.load()) { + return; + } + + SDL_Thread *thread = task->thread; + task->thread = nullptr; + int threadResult = 0; + SDL_WaitThread(thread, &threadResult); + (void) threadResult; + + const std::string address = task->address; + const uint16_t port = task->port; + const std::vector cachedAppIds = task->cachedAppIds; + const std::size_t failureCount = task->failureCount; + reset_app_art_task(task); + + for (int appId : cachedAppIds) { + app::mark_cover_art_cached(state, address, port, appId); + } + + if (textureCache != nullptr) { + textureCache->failedKeys.clear(); + } + + if (!cachedAppIds.empty()) { + logging::info("apps", "Cached cover art for " + std::to_string(cachedAppIds.size()) + " app(s)"); + } + if (failureCount > 0U) { + logging::warn("apps", std::to_string(failureCount) + " app artwork request(s) fell back to placeholders"); + } + } + + void start_app_art_task_if_needed(const app::ClientState &state, AppArtTaskState *task) { + if (task == nullptr || app_art_task_is_active(*task) || state.shell.activeScreen != app::ScreenId::apps) { + return; + } + + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr || host->appListState != app::HostAppListState::ready || host->apps.empty()) { + return; + } + + if (const bool missingArt = std::any_of(host->apps.begin(), host->apps.end(), [](const app::HostAppRecord &appRecord) { + return !appRecord.boxArtCached && !appRecord.boxArtCacheKey.empty(); + }); + !missingArt) { + return; + } + + reset_app_art_task(task); + task->address = host->address; + task->port = host->httpsPort == 0 ? app::effective_host_port(host->port) : host->httpsPort; + task->apps = host->apps; + task->thread = SDL_CreateThread(run_app_art_task, "fetch-app-art", task); + if (task->thread == nullptr) { + logging::error("apps", std::string("Failed to start the cover-art fetch task: ") + SDL_GetError()); + reset_app_art_task(task); + } + } + + void show_log_file_if_requested(app::ClientState &state, const app::AppUpdate &update) { + if (!update.requests.logViewRequested) { + return; + } + + const std::string filePath = state.settings.logFilePath.empty() ? logging::default_log_file_path() : state.settings.logFilePath; + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, LOG_VIEWER_MAX_LOADED_LINES); + app::set_log_file_path(state, loadedLog.filePath); + if (!loadedLog.errorMessage.empty()) { + app::apply_log_viewer_contents(state, {loadedLog.errorMessage}, loadedLog.errorMessage); + logging::warn("logging", loadedLog.errorMessage); + return; + } + + std::vector lines = loadedLog.lines; + if (!loadedLog.fileFound) { + lines = {"The log file does not exist yet."}; + } else if (lines.empty()) { + lines = {"The log file is empty."}; + } + + const std::string statusMessage = loadedLog.fileFound ? "Loaded recent log file lines" : "No log file has been written yet"; + app::apply_log_viewer_contents(state, std::move(lines), statusMessage); + logging::info("logging", statusMessage + ": " + loadedLog.filePath); + } + + int measure_body_lines_height(TTF_Font *font, const std::vector &lines, int maxWidth, int lineGap) { + int textHeight = 0; + for (std::size_t index = 0; index < lines.size(); ++index) { + textHeight += measure_wrapped_text_height(font, lines[index], maxWidth); + if (index + 1U < lines.size()) { + textHeight += lineGap; + } + } + return textHeight; + } + + struct BodyLinesRenderLayout { + SDL_Color color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}; + int x = 0; + int y = 0; + int maxWidth = 0; + int lineGap = 0; + }; + + bool render_body_lines( + SDL_Renderer *renderer, + TTF_Font *font, + const std::vector &lines, + const BodyLinesRenderLayout &layout + ) { + int cursorY = layout.y; + for (const std::string &line : lines) { + int drawnHeight = 0; + if (!render_text_line(renderer, font, line, layout.color, layout.x, cursorY, layout.maxWidth, &drawnHeight)) { + return false; + } + cursorY += drawnHeight + layout.lineGap; + } + return true; + } + + bool render_settings_detail_panel( + SDL_Renderer *renderer, + TTF_Font *bodyFont, + TTF_Font *smallFont, + const ui::ShellViewModel &viewModel, + const SDL_Rect &bodyPanel, + int panelPadding + ) { + const int optionsHeaderY = bodyPanel.y + panelPadding; + const int optionsTopY = optionsHeaderY + 28; + const int descriptionGap = 16; + const int descriptionHeaderHeight = std::max(26, TTF_FontLineSkip(smallFont)); + const int minimumDescriptionHeight = std::max(96, (TTF_FontLineSkip(smallFont) * 3) + descriptionHeaderHeight + 20); + const int availableOptionsHeight = bodyPanel.h - ((panelPadding * 2) + 28 + descriptionGap + descriptionHeaderHeight + minimumDescriptionHeight); + const int optionsHeight = std::max(std::max(120, bodyPanel.h / 2), availableOptionsHeight); + const SDL_Rect optionsRect {bodyPanel.x + panelPadding, optionsTopY, bodyPanel.w - (panelPadding * 2), std::max(96, optionsHeight)}; + const int descriptionTopY = optionsRect.y + optionsRect.h + descriptionGap; + const SDL_Rect descriptionRect { + bodyPanel.x + panelPadding, + descriptionTopY, + bodyPanel.w - (panelPadding * 2), + std::max(72, bodyPanel.y + bodyPanel.h - panelPadding - descriptionTopY) + }; + + if (!render_text_line_simple(renderer, smallFont, "Options", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, bodyPanel.x + panelPadding, optionsHeaderY, bodyPanel.w - (panelPadding * 2))) { + return false; + } + if (!render_action_rows( + renderer, + bodyFont, + viewModel.content.detailMenuRows, + optionsRect, + std::max(34, TTF_FontLineSkip(bodyFont) + 12) + )) { + return false; + } + + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, descriptionRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0x88); + draw_rect(renderer, descriptionRect, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x40); + + if (!render_text_line_simple(renderer, smallFont, "Description", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, descriptionRect.x + 10, descriptionRect.y + 8, descriptionRect.w - 20)) { + return false; + } + + int descriptionY = descriptionRect.y + descriptionHeaderHeight + 10; + if (!viewModel.content.selectedMenuRowLabel.empty()) { + int drawnHeight = 0; + if (!render_text_line(renderer, bodyFont, viewModel.content.selectedMenuRowLabel, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, descriptionRect.x + 10, descriptionY, descriptionRect.w - 20, &drawnHeight)) { + return false; + } + descriptionY += drawnHeight + 6; + } + + const std::string descriptionText = viewModel.content.selectedMenuRowDescription.empty() ? std::string("No description is available for the selected setting.") : viewModel.content.selectedMenuRowDescription; + return render_text_line(renderer, smallFont, descriptionText, {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, descriptionRect.x + 10, descriptionY, descriptionRect.w - 20); + } + + bool render_app_tiles_grid( + SDL_Renderer *renderer, + TTF_Font *smallFont, + const ui::ShellViewModel &viewModel, + const SDL_Rect &gridRect, + CoverArtTextureCache *textureCache, + const AssetTextureCache *assetCache + ) { + const int columnCount = std::max(1, static_cast(viewModel.content.appColumnCount)); + const int tileGap = 16; + const int gridPadding = 10; + const GridViewport viewport = calculate_grid_viewport(viewModel.content.appTiles.size(), viewModel.content.appColumnCount, selected_app_tile_index(viewModel.content.appTiles), std::max(1, gridRect.h - (gridPadding * 2)), 220, tileGap); + const int scrollbarGap = viewport.scrollbarWidth > 0 ? 12 : 0; + const int gridInnerWidth = std::max(1, gridRect.w - (gridPadding * 2) - viewport.scrollbarWidth - scrollbarGap); + const int cellWidth = std::max(1, (gridInnerWidth - (tileGap * (columnCount - 1))) / columnCount); + const int cellHeight = std::max(1, (gridRect.h - (gridPadding * 2) - (tileGap * std::max(0, viewport.visibleRowCount - 1))) / std::max(1, viewport.visibleRowCount)); + const int tileWidth = std::max(1, std::min(cellWidth, (cellHeight * 2) / 3)); + const int tileHeight = std::max(1, std::min(cellHeight, (tileWidth * 3) / 2)); + const std::size_t startIndex = static_cast(viewport.startRow) * viewModel.content.appColumnCount; + const std::size_t endIndex = std::min(viewModel.content.appTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.content.appColumnCount); + + for (std::size_t index = startIndex; index < endIndex; ++index) { + const int row = static_cast(index / viewModel.content.appColumnCount) - viewport.startRow; + const auto column = static_cast(index % viewModel.content.appColumnCount); + const SDL_Rect tileRect { + gridRect.x + gridPadding + (column * (cellWidth + tileGap)) + std::max(0, (cellWidth - tileWidth) / 2), + gridRect.y + gridPadding + (row * (cellHeight + tileGap)) + std::max(0, (cellHeight - tileHeight) / 2), + tileWidth, + tileHeight, + }; + if (!render_app_cover(renderer, smallFont, viewModel.content.appTiles[index], tileRect, textureCache, assetCache)) { + return false; + } + } + + if (viewport.scrollbarWidth > 0) { + render_grid_scrollbar( + renderer, + {gridRect.x + gridRect.w - viewport.scrollbarWidth, gridRect.y + gridPadding, viewport.scrollbarWidth, std::max(1, gridRect.h - (gridPadding * 2))}, + viewport + ); + } + return true; + } + + bool render_apps_empty_state(SDL_Renderer *renderer, TTF_Font *smallFont, const ui::ShellViewModel &viewModel, const SDL_Rect &gridRect) { + const int lineGap = 8; + const int textHeight = measure_body_lines_height(smallFont, viewModel.content.bodyLines, gridRect.w - 48, lineGap); + return render_body_lines( + renderer, + smallFont, + viewModel.content.bodyLines, + {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, gridRect.x + 24, gridRect.y + std::max(16, (gridRect.h - textHeight) / 2), gridRect.w - 48, lineGap} + ); + } + + bool draw_shell( // NOSONAR(cpp:S107) one-frame shell rendering keeps layout dependencies explicit + SDL_Renderer *renderer, + const VIDEO_MODE &videoMode, + unsigned long encoderSettings, + TTF_Font *titleFont, + TTF_Font *bodyFont, + TTF_Font *smallFont, + const ui::ShellViewModel &viewModel, + CoverArtTextureCache *textureCache, + AssetTextureCache *assetCache, + KeypadModalLayoutCache *keypadModalLayoutCache + ) { + int framebufferWidth = 0; + int framebufferHeight = 0; + if (SDL_GetRendererOutputSize(renderer, &framebufferWidth, &framebufferHeight) != 0 || framebufferWidth <= 0 || framebufferHeight <= 0) { + return false; + } + + const int screenHeight = framebufferHeight; + const int screenWidth = splash::calculate_display_width(screenHeight, videoMode, encoderSettings); + const float horizontalScale = static_cast(framebufferWidth) / static_cast(screenWidth); + + SDL_RenderSetScale(renderer, 1.0f, 1.0f); + + const int outerMargin = std::max(18, screenHeight / 24); + const int panelGap = std::max(14, screenWidth / 48); + const int headerHeight = std::max(76, screenHeight / 8); + const int footerHeight = std::max(46, screenHeight / 11); + const SDL_Rect headerRect {outerMargin, outerMargin, screenWidth - (outerMargin * 2), headerHeight}; + const SDL_Rect contentRect {outerMargin, outerMargin + headerHeight + 10, screenWidth - (outerMargin * 2), screenHeight - ((outerMargin * 2) + headerHeight + footerHeight + 20)}; + const SDL_Rect footerRect {outerMargin, screenHeight - outerMargin - footerHeight, screenWidth - (outerMargin * 2), footerHeight}; + + SDL_SetRenderDrawColor(renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + if (SDL_RenderClear(renderer) != 0) { + return false; + } + + if (SDL_RenderSetScale(renderer, horizontalScale, 1.0f) != 0) { + return false; + } + + fill_rect(renderer, headerRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE); + fill_rect(renderer, contentRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE); + fill_rect(renderer, footerRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE); + draw_line(renderer, headerRect.x, headerRect.y + headerRect.h - 1, headerRect.x + headerRect.w, headerRect.y + headerRect.h - 1, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x90); + draw_line(renderer, footerRect.x, footerRect.y, footerRect.x + footerRect.w, footerRect.y, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x90); + + int titleTextX = headerRect.x + 16; + const int titleTextY = headerRect.y + 12; + int titleTextWidth = headerRect.w - 32; + + if (assetCache != nullptr && titleFont != nullptr) { + const int targetLogoHeight = std::max(32, TTF_FontLineSkip(titleFont)); + const SDL_Rect logoRect { + headerRect.x + 16, + headerRect.y + 10, + targetLogoHeight, + targetLogoHeight, + }; + if (render_asset_icon(renderer, assetCache, "moonlight-logo.svg", logoRect)) { + titleTextX = logoRect.x + logoRect.w + 12; + titleTextWidth = (headerRect.w / 3) - (titleTextX - headerRect.x); + } + } + + if (!render_text_line(renderer, titleFont, viewModel.frame.title, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, titleTextX, titleTextY, titleTextWidth)) { + return false; + } + + const int pageTitleX = headerRect.x + (headerRect.w / 3); + const int pageTitleY = headerRect.y + 18; + if (const bool renderedPageTitle = viewModel.frame.screen == app::ScreenId::apps ? render_text_line_simple(renderer, bodyFont, viewModel.frame.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) : render_text_line(renderer, bodyFont, viewModel.frame.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); !viewModel.frame.pageTitle.empty() && !renderedPageTitle) { + return false; + } + + if (viewModel.frame.screen == app::ScreenId::hosts) { + const int buttonWidth = std::max(132, headerRect.w / 7); + const int buttonHeight = std::max(40, headerRect.h / 2); + int buttonX = headerRect.x + headerRect.w - 16 - ((buttonWidth + 12) * static_cast(viewModel.content.toolbarButtons.size())); + for (const ui::ShellToolbarButton &button : viewModel.content.toolbarButtons) { + if (const SDL_Rect buttonRect {buttonX, headerRect.y + 18, buttonWidth, buttonHeight}; !render_toolbar_button(renderer, bodyFont, smallFont, assetCache, button, buttonRect)) { + return false; + } + buttonX += buttonWidth + 12; + } + } + + int infoY = contentRect.y + 16; + if (viewModel.frame.screen == app::ScreenId::hosts) { + for (const std::string &line : viewModel.content.bodyLines) { + int drawnHeight = 0; + if (!render_text_line(renderer, smallFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentRect.x + 16, infoY, contentRect.w - 32, &drawnHeight)) { + return false; + } + infoY += drawnHeight + 6; + } + } + + if (viewModel.frame.screen == app::ScreenId::hosts) { + const int gridTop = infoY + 8; + const int gridHeight = std::max(1, (contentRect.y + contentRect.h - gridTop) - 12); + const int columnCount = std::max(1, static_cast(viewModel.content.hostColumnCount)); + const int tileGap = 16; + const SDL_Rect gridRect {contentRect.x + 16, gridTop, contentRect.w - 32, gridHeight}; + const GridViewport viewport = calculate_grid_viewport(viewModel.content.hostTiles.size(), viewModel.content.hostColumnCount, selected_host_tile_index(viewModel.content.hostTiles), gridRect.h, 188, tileGap); + const int scrollbarGap = viewport.scrollbarWidth > 0 ? 12 : 0; + const int gridInnerWidth = std::max(1, gridRect.w - viewport.scrollbarWidth - scrollbarGap); + const int tileWidth = std::max(1, (gridInnerWidth - (tileGap * (columnCount - 1))) / columnCount); + const int tileHeight = std::max(1, (gridRect.h - (tileGap * std::max(0, viewport.visibleRowCount - 1))) / std::max(1, viewport.visibleRowCount)); + const std::size_t startIndex = static_cast(viewport.startRow) * viewModel.content.hostColumnCount; + const std::size_t endIndex = std::min(viewModel.content.hostTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.content.hostColumnCount); + for (std::size_t index = startIndex; index < endIndex; ++index) { + const int row = static_cast(index / viewModel.content.hostColumnCount) - viewport.startRow; + const auto column = static_cast(index % viewModel.content.hostColumnCount); + const SDL_Rect tileRect { + gridRect.x + (column * (tileWidth + tileGap)), + gridRect.y + (row * (tileHeight + tileGap)), + tileWidth, + tileHeight, + }; + const ui::ShellHostTile &tile = viewModel.content.hostTiles[index]; + const bool online = tile.reachability == app::HostReachability::online; + fill_rect(renderer, tileRect, online ? PANEL_RED + 8 : PANEL_RED, online ? PANEL_GREEN + 8 : PANEL_GREEN, online ? PANEL_BLUE + 8 : PANEL_BLUE); + draw_rect(renderer, tileRect, tile.selected ? ACCENT_RED : MUTED_RED, tile.selected ? ACCENT_GREEN : MUTED_GREEN, tile.selected ? ACCENT_BLUE : MUTED_BLUE); + const int statusHeight = std::max(20, tileRect.h / 8); + const int nameHeight = std::max(24, tileRect.h / 7); + const int textBlockHeight = statusHeight + nameHeight + 18; + const SDL_Rect hostIconRect { + tileRect.x + 12, + tileRect.y + 8, + tileRect.w - 24, + std::max(18, tileRect.h - textBlockHeight - 16), + }; + if (!tile.iconAssetPath.empty()) { + render_asset_icon(renderer, assetCache, tile.iconAssetPath, hostIconRect); + } + if (const SDL_Rect nameRect { + tileRect.x + 8, + tileRect.y + tileRect.h - statusHeight - nameHeight - 10, + tileRect.w - 16, + nameHeight, + }; + !render_text_centered_simple(renderer, bodyFont, tile.displayName, online ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, nameRect)) { + return false; + } + const SDL_Rect statusRect { + tileRect.x + 8, + tileRect.y + tileRect.h - statusHeight - 8, + tileRect.w - 16, + statusHeight, + }; + if (!render_text_centered_simple(renderer, smallFont, tile.statusLabel, online ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, statusRect)) { + return false; + } + } + + if (viewport.scrollbarWidth > 0) { + render_grid_scrollbar(renderer, {gridRect.x + gridRect.w - viewport.scrollbarWidth, gridRect.y, viewport.scrollbarWidth, gridRect.h}, viewport); + } + } else if (viewModel.frame.screen == app::ScreenId::apps) { + const SDL_Rect gridRect { + contentRect.x + 16, + contentRect.y + 16, + contentRect.w - 32, + contentRect.h - 28, + }; + + if (!viewModel.content.appTiles.empty()) { + if (!render_app_tiles_grid(renderer, smallFont, viewModel, gridRect, textureCache, assetCache)) { + return false; + } + } else if (!viewModel.content.bodyLines.empty() && !render_apps_empty_state(renderer, smallFont, viewModel, gridRect)) { + return false; + } + } else { + const bool settingsScreen = viewModel.frame.screen == app::ScreenId::settings; + const bool hasDetailMenu = settingsScreen && !viewModel.content.detailMenuRows.empty(); + const int panelInset = std::max(12, screenWidth / 96); + const int panelPadding = std::max(14, screenWidth / 96); + const SDL_Rect panelArea { + contentRect.x + panelInset, + contentRect.y + panelInset, + std::max(1, contentRect.w - (panelInset * 2)), + std::max(1, contentRect.h - (panelInset * 2)), + }; + const int menuPanelWidth = std::max(232, (panelArea.w * 31) / 100); + const SDL_Rect menuPanel {panelArea.x, panelArea.y, menuPanelWidth, panelArea.h}; + const SDL_Rect bodyPanel {panelArea.x + menuPanelWidth + panelGap, panelArea.y, panelArea.w - menuPanelWidth - panelGap, panelArea.h}; + fill_rect(renderer, menuPanel, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xC8); + fill_rect(renderer, bodyPanel, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0x88); + draw_rect( + renderer, + menuPanel, + viewModel.content.leftPanelActive ? ACCENT_RED : TEXT_RED, + viewModel.content.leftPanelActive ? ACCENT_GREEN : TEXT_GREEN, + viewModel.content.leftPanelActive ? ACCENT_BLUE : TEXT_BLUE, + viewModel.content.leftPanelActive ? 0xD8 : 0x48 + ); + draw_rect( + renderer, + bodyPanel, + viewModel.content.rightPanelActive ? ACCENT_RED : TEXT_RED, + viewModel.content.rightPanelActive ? ACCENT_GREEN : TEXT_GREEN, + viewModel.content.rightPanelActive ? ACCENT_BLUE : TEXT_BLUE, + viewModel.content.rightPanelActive ? 0xD8 : 0x48 + ); + + const SDL_Rect menuHeaderRect {menuPanel.x + panelPadding, menuPanel.y + panelPadding, menuPanel.w - (panelPadding * 2), std::max(34, TTF_FontLineSkip(smallFont) + 10)}; + fill_rect(renderer, menuHeaderRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xD8); + if (!render_text_line_simple(renderer, smallFont, settingsScreen ? "Categories" : "Actions", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, menuHeaderRect.x + 10, menuHeaderRect.y + std::max(6, (menuHeaderRect.h - TTF_FontLineSkip(smallFont)) / 2), menuHeaderRect.w - 20)) { + return false; + } + + if (!render_action_rows( + renderer, + bodyFont, + viewModel.content.menuRows, + {menuPanel.x + panelPadding, menuHeaderRect.y + menuHeaderRect.h + 12, menuPanel.w - (panelPadding * 2), menuPanel.h - (menuHeaderRect.h + (panelPadding * 2) + 12)}, + std::max(36, screenHeight / 13) + )) { + return false; + } + + if (hasDetailMenu) { + if (!render_settings_detail_panel(renderer, bodyFont, smallFont, viewModel, bodyPanel, panelPadding)) { + return false; + } + } else { + if (!render_body_lines( + renderer, + bodyFont, + viewModel.content.bodyLines, + {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyPanel.y + panelPadding, bodyPanel.w - (panelPadding * 2), 8} + )) { + return false; + } + } + } + + if (!render_footer_actions(renderer, smallFont, assetCache, viewModel.frame.footerActions, footerRect)) { + return false; + } + + if (viewModel.notification.visible && !viewModel.notification.content.message.empty() && !render_notification(renderer, bodyFont, smallFont, assetCache, viewModel.notification.content, screenWidth, footerRect.y, outerMargin)) { + return false; + } + + if (viewModel.overlay.visible) { + const int overlayX = (screenWidth / 2) + (panelGap / 2); + const SDL_Rect overlayRect { + overlayX, + outerMargin + 28, + screenWidth - overlayX - outerMargin - 8, + screenHeight - ((outerMargin + 28) * 2) + }; + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, overlayRect, 0x00, 0x00, 0x00, 0xD8); + draw_rect(renderer, overlayRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF); + + if (!render_text_line(renderer, bodyFont, viewModel.overlay.title, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, overlayRect.x + 16, overlayRect.y + 16, overlayRect.w - 32)) { + return false; + } + + int overlayY = overlayRect.y + 54; + for (const std::string &line : viewModel.overlay.lines) { + int drawnHeight = 0; + if (!render_text_line(renderer, smallFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, overlayRect.x + 16, overlayY, overlayRect.w - 32, &drawnHeight)) { + return false; + } + overlayY += drawnHeight + 6; + } + } + + if (viewModel.modal.visible && viewModel.logViewer.visible) { + if (!render_log_viewer_modal(renderer, bodyFont, smallFont, assetCache, viewModel, screenWidth, screenHeight, outerMargin)) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, {0, 0, screenWidth, screenHeight}, 0x00, 0x00, 0x00, 0xA6); + const SDL_Rect modalRect { + screenWidth / 6, + screenHeight / 6, + (screenWidth * 2) / 3, + (screenHeight * 2) / 3, + }; + fill_rect(renderer, modalRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE, 0xF2); + draw_rect(renderer, modalRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + render_text_line_simple(renderer, bodyFont, "Log File", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 16, modalRect.w - 32); + render_text_line_simple(renderer, smallFont, "The full log viewer could not be rendered safely.", {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 54, modalRect.w - 32); + render_text_line_simple(renderer, smallFont, "Press B to close.", {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 54 + TTF_FontLineSkip(smallFont) + 8, modalRect.w - 32); + } + } else if (viewModel.modal.visible) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + fill_rect(renderer, {0, 0, screenWidth, screenHeight}, 0x00, 0x00, 0x00, 0xA6); + const SDL_Rect modalRect { + screenWidth / 6, + screenHeight / 6, + (screenWidth * 2) / 3, + (screenHeight * 2) / 3, + }; + fill_rect(renderer, modalRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE, 0xF2); + draw_rect(renderer, modalRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + if (!render_text_line(renderer, bodyFont, viewModel.modal.title, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 16, modalRect.w - 32)) { + return false; + } + + int modalY = modalRect.y + 54; + for (const std::string &line : viewModel.modal.lines) { + int drawnHeight = 0; + if (!render_text_line(renderer, smallFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, modalRect.x + 16, modalY, modalRect.w - 32, &drawnHeight)) { + return false; + } + modalY += drawnHeight + 6; + } + + if (!viewModel.modal.actions.empty()) { + if (!render_action_rows(renderer, bodyFont, viewModel.modal.actions, {modalRect.x + 16, modalY + 8, modalRect.w - 32, modalRect.h - (modalY - modalRect.y) - 24}, std::max(34, TTF_FontLineSkip(bodyFont) + 12))) { + return false; + } + } else if (!viewModel.modal.footerActions.empty()) { + const SDL_Rect modalFooterRect {modalRect.x + 16, modalRect.y + modalRect.h - 56, modalRect.w - 32, 40}; + if (!render_footer_actions(renderer, smallFont, assetCache, viewModel.modal.footerActions, modalFooterRect)) { + return false; + } + } + } + + if (viewModel.keypad.visible) { + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + const SDL_Rect scrimRect {0, 0, screenWidth, screenHeight}; + fill_rect(renderer, scrimRect, 0x00, 0x00, 0x00, 0x9C); + + const int modalWidth = std::min(screenWidth - (outerMargin * 2), std::max(360, screenWidth / 2)); + const int buttonGap = 10; + const int buttonColumnCount = std::max(1, static_cast(viewModel.keypad.columnCount)); + const int buttonRowCount = std::max(1, static_cast((viewModel.keypad.buttons.size() + viewModel.keypad.columnCount - 1) / viewModel.keypad.columnCount)); + const int preferredButtonHeight = std::max(40, TTF_FontLineSkip(bodyFont) + 16); + const int modalInnerWidth = modalWidth - 32; + const int modalTextHeight = keypad_modal_text_height(renderer, smallFont, viewModel, modalInnerWidth, keypadModalLayoutCache); + const int desiredButtonAreaHeight = (buttonRowCount * preferredButtonHeight) + (buttonGap * std::max(0, buttonRowCount - 1)); + const int desiredModalHeight = 52 + modalTextHeight + 16 + desiredButtonAreaHeight + 28; + const int modalHeight = std::min(screenHeight - (outerMargin * 2), std::max(300, desiredModalHeight)); + const SDL_Rect modalRect { + (screenWidth - modalWidth) / 2, + (screenHeight - modalHeight) / 2, + modalWidth, + modalHeight, + }; + + fill_rect(renderer, modalRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE, 0xF0); + draw_rect(renderer, modalRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + + if ( + !ensure_wrapped_text_texture(renderer, bodyFont, viewModel.keypad.title, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.w - 32, &keypadModalLayoutCache->titleTexture) || + !render_cached_text_texture(renderer, keypadModalLayoutCache->titleTexture, modalRect.x + 16, modalRect.y + 16) + ) { + return false; + } + + int modalY = modalRect.y + 52; + keypadModalLayoutCache->lineTextures.resize(viewModel.keypad.lines.size()); + for (std::size_t index = 0; index < viewModel.keypad.lines.size(); ++index) { + int drawnHeight = 0; + if (!render_cached_text_texture(renderer, keypadModalLayoutCache->lineTextures[index], modalRect.x + 16, modalY, &drawnHeight)) { + return false; + } + modalY += drawnHeight + 6; + } + + const int buttonAreaTop = modalY + 16; + const int buttonAreaHeight = (modalRect.y + modalRect.h) - buttonAreaTop - 24; + const int buttonWidth = (modalRect.w - 32 - (buttonGap * (buttonColumnCount - 1))) / buttonColumnCount; + const int buttonHeight = std::max(34, (buttonAreaHeight - (buttonGap * std::max(0, buttonRowCount - 1))) / buttonRowCount); + + if (keypadModalLayoutCache->buttonLabelTextures.size() > viewModel.keypad.buttons.size()) { + for (std::size_t index = viewModel.keypad.buttons.size(); index < keypadModalLayoutCache->buttonLabelTextures.size(); ++index) { + clear_cached_text_texture(&keypadModalLayoutCache->buttonLabelTextures[index]); + } + } + keypadModalLayoutCache->buttonLabelTextures.resize(viewModel.keypad.buttons.size()); + + for (std::size_t index = 0; index < viewModel.keypad.buttons.size(); ++index) { + const auto row = static_cast(index / viewModel.keypad.columnCount); + const auto column = static_cast(index % viewModel.keypad.columnCount); + const SDL_Rect buttonRect { + modalRect.x + 16 + (column * (buttonWidth + buttonGap)), + buttonAreaTop + (row * (buttonHeight + buttonGap)), + buttonWidth, + buttonHeight, + }; + const ui::ShellModalButton &button = viewModel.keypad.buttons[index]; + + if (button.selected) { + fill_rect(renderer, buttonRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0x55); + } else { + fill_rect(renderer, buttonRect, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xE0); + } + draw_rect(renderer, buttonRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + + const SDL_Color buttonColor = button.enabled ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; + if ( + !ensure_single_line_text_texture(renderer, bodyFont, button.label, buttonColor, buttonRect.w, &keypadModalLayoutCache->buttonLabelTextures[index]) || + !render_cached_centered_text_texture(renderer, keypadModalLayoutCache->buttonLabelTextures[index], buttonRect) + ) { + return false; + } + } + } + + SDL_RenderPresent(renderer); + SDL_RenderSetScale(renderer, 1.0f, 1.0f); + return true; + } + + void close_controller(SDL_GameController *controller) { + if (controller != nullptr) { + SDL_GameControllerClose(controller); + } + } + + bool should_open_added_controller(const SDL_GameController *controller, const SDL_ControllerDeviceEvent &event) { + return controller == nullptr && SDL_IsGameController(event.which); + } + + bool should_close_removed_controller(const SDL_GameController *controller, const SDL_ControllerDeviceEvent &event) { + return controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.which); + } + + bool hosts_screen_exit_combo_allowed(const app::ClientState &state) { + return state.shell.activeScreen == app::ScreenId::home || state.shell.activeScreen == app::ScreenId::hosts; + } + + void update_trigger_repeat_tick(input::UiCommand command, Uint32 now, Uint32 *leftTriggerRepeatTick, Uint32 *rightTriggerRepeatTick) { + if (command == input::UiCommand::fast_previous_page) { + if (leftTriggerRepeatTick != nullptr) { + *leftTriggerRepeatTick = now; + } + return; + } + if (command == input::UiCommand::fast_next_page && rightTriggerRepeatTick != nullptr) { + *rightTriggerRepeatTick = now; + } + } + + input::UiCommand translate_unrepeated_keydown(const SDL_KeyboardEvent &event) { + if (event.repeat != 0) { + return input::UiCommand::none; + } + return translate_keyboard_key(event.keysym.sym, event.keysym.mod); + } + + struct ShellInputState { + bool leftTriggerPressed = false; + bool rightTriggerPressed = false; + bool leftShoulderPressed = false; + bool rightShoulderPressed = false; + bool controllerStartPressed = false; + bool controllerBackPressed = false; + bool controllerExitComboArmed = false; + bool controllerExitComboTriggered = false; + Uint32 controllerStartDownTick = 0U; + Uint32 controllerBackDownTick = 0U; + Uint32 leftShoulderRepeatTick = 0U; + Uint32 rightShoulderRepeatTick = 0U; + Uint32 leftTriggerRepeatTick = 0U; + Uint32 rightTriggerRepeatTick = 0U; + ControllerNavigationHoldState moveUpHoldState {}; + ControllerNavigationHoldState moveDownHoldState {}; + ControllerNavigationHoldState moveLeftHoldState {}; + ControllerNavigationHoldState moveRightHoldState {}; + bool controllerNavigationNeutralRequired = false; + }; + + void reset_shell_input_state(ShellInputState *inputState) { + if (inputState == nullptr) { + return; + } + + *inputState = {}; + } + + void disarm_controller_exit_combo(ShellInputState *inputState) { + if (inputState == nullptr) { + return; + } + + inputState->controllerExitComboArmed = false; + inputState->controllerExitComboTriggered = false; + } + + void update_exit_combo_hold(app::ClientState &state, ShellInputState *inputState) { + if (inputState == nullptr || inputState->controllerExitComboTriggered) { + return; + } + + if (!inputState->controllerStartPressed || !inputState->controllerBackPressed || !hosts_screen_exit_combo_allowed(state)) { + return; + } + + inputState->controllerExitComboArmed = true; + if (const Uint32 comboStartTick = inputState->controllerStartDownTick > inputState->controllerBackDownTick ? inputState->controllerStartDownTick : inputState->controllerBackDownTick; SDL_GetTicks() - comboStartTick < EXIT_COMBO_HOLD_MILLISECONDS) { + return; + } + + inputState->controllerExitComboTriggered = true; + state.shell.shouldExit = true; + logging::info("app", "Exit requested from held Start+Back on the hosts screen"); + } + + template + void process_log_viewer_repeat_commands( + const app::ClientState &state, + Uint32 now, + ShellInputState *inputState, + const ProcessCommand &processCommand + ) { + if (inputState == nullptr || state.modal.id != app::ModalId::log_viewer) { + return; + } + + if (inputState->leftShoulderPressed && now - inputState->leftShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { + inputState->leftShoulderRepeatTick = now; + processCommand(input::UiCommand::previous_page); + } + if (inputState->rightShoulderPressed && now - inputState->rightShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { + inputState->rightShoulderRepeatTick = now; + processCommand(input::UiCommand::next_page); + } + if (inputState->leftTriggerPressed && now - inputState->leftTriggerRepeatTick >= LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS) { + inputState->leftTriggerRepeatTick = now; + processCommand(input::UiCommand::fast_previous_page); + } + if (inputState->rightTriggerPressed && now - inputState->rightTriggerRepeatTick >= LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS) { + inputState->rightTriggerRepeatTick = now; + processCommand(input::UiCommand::fast_next_page); + } + } + + void handle_controller_device_added(SDL_GameController **controller, const SDL_ControllerDeviceEvent &event) { + if (controller == nullptr || !should_open_added_controller(*controller, event)) { + return; + } + + *controller = SDL_GameControllerOpen(event.which); + if (*controller != nullptr) { + logging::info("input", "Controller connected"); + } + } + + void handle_controller_device_removed(SDL_GameController **controller, const SDL_ControllerDeviceEvent &event, ShellInputState *inputState) { + if (controller == nullptr || !should_close_removed_controller(*controller, event)) { + return; + } + + close_controller(*controller); + *controller = nullptr; + reset_shell_input_state(inputState); + logging::warn("input", "Controller disconnected"); + } + + input::UiCommand handle_controller_button_down_event(const SDL_ControllerButtonEvent &event, const app::ClientState &state, ShellInputState *inputState) { + if (inputState == nullptr) { + return input::UiCommand::none; + } + + const Uint32 controllerButtonDownTick = SDL_GetTicks(); + if (event.button == SDL_CONTROLLER_BUTTON_START) { + if (!inputState->controllerStartPressed) { + inputState->controllerStartPressed = true; + inputState->controllerStartDownTick = controllerButtonDownTick; + } + } else if (event.button == SDL_CONTROLLER_BUTTON_BACK) { + if (!inputState->controllerBackPressed) { + inputState->controllerBackPressed = true; + inputState->controllerBackDownTick = controllerButtonDownTick; + } + } else { + if (event.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + inputState->leftShoulderPressed = true; + inputState->leftShoulderRepeatTick = controllerButtonDownTick; + } else if (event.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + inputState->rightShoulderPressed = true; + inputState->rightShoulderRepeatTick = controllerButtonDownTick; + } + + const input::UiCommand command = translate_controller_button(event.button); + if (is_navigation_command(command)) { + seed_controller_navigation_hold_state( + controllerButtonDownTick, + command, + &inputState->moveUpHoldState, + &inputState->moveDownHoldState, + &inputState->moveLeftHoldState, + &inputState->moveRightHoldState + ); + } + if (inputState->controllerStartPressed && inputState->controllerBackPressed && hosts_screen_exit_combo_allowed(state)) { + inputState->controllerExitComboArmed = true; + } + return command; + } + + if (inputState->controllerStartPressed && inputState->controllerBackPressed && hosts_screen_exit_combo_allowed(state)) { + inputState->controllerExitComboArmed = true; + } + return input::UiCommand::none; + } + + input::UiCommand handle_controller_button_up_event(const SDL_ControllerButtonEvent &event, ShellInputState *inputState) { + if (inputState == nullptr) { + return input::UiCommand::none; + } + + input::UiCommand command = input::UiCommand::none; + if (event.button == SDL_CONTROLLER_BUTTON_START && inputState->controllerStartPressed) { + inputState->controllerStartPressed = false; + if (!inputState->controllerExitComboArmed && !inputState->controllerExitComboTriggered) { + command = input::map_gamepad_button_to_ui_command(input::GamepadButton::start); + } + } else if (event.button == SDL_CONTROLLER_BUTTON_BACK && inputState->controllerBackPressed) { + inputState->controllerBackPressed = false; + if (!inputState->controllerExitComboArmed && !inputState->controllerExitComboTriggered) { + command = input::map_gamepad_button_to_ui_command(input::GamepadButton::back); + } + } else if (event.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + inputState->leftShoulderPressed = false; + } else if (event.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + inputState->rightShoulderPressed = false; + } + + if (!inputState->controllerStartPressed && !inputState->controllerBackPressed) { + disarm_controller_exit_combo(inputState); + } + release_controller_navigation_hold_state( + translate_controller_button(event.button), + &inputState->moveUpHoldState, + &inputState->moveDownHoldState, + &inputState->moveLeftHoldState, + &inputState->moveRightHoldState + ); + return command; + } + + input::UiCommand handle_shell_event( + app::ClientState &state, + const SDL_Event &event, + SDL_GameController **controller, + ShellInputState *inputState + ) { + switch (event.type) { + case SDL_QUIT: + state.shell.shouldExit = true; + return input::UiCommand::none; + case SDL_CONTROLLERDEVICEADDED: + handle_controller_device_added(controller, event.cdevice); + return input::UiCommand::none; + case SDL_CONTROLLERDEVICEREMOVED: + handle_controller_device_removed(controller, event.cdevice, inputState); + return input::UiCommand::none; + case SDL_CONTROLLERBUTTONDOWN: + return handle_controller_button_down_event(event.cbutton, state, inputState); + case SDL_CONTROLLERBUTTONUP: + return handle_controller_button_up_event(event.cbutton, inputState); + case SDL_CONTROLLERAXISMOTION: + if (inputState == nullptr) { + return input::UiCommand::none; + } + + return translate_trigger_axis(event.caxis, &inputState->leftTriggerPressed, &inputState->rightTriggerPressed); + case SDL_KEYDOWN: + return translate_unrepeated_keydown(event.key); + default: + return input::UiCommand::none; + } + } + + template + void process_polled_shell_events( + app::ClientState &state, + SDL_GameController **controller, + ShellInputState *inputState, + const ProcessCommand &processCommand, + bool *skipPolledControllerNavigation + ) { + SDL_Event event; + if (skipPolledControllerNavigation != nullptr) { + *skipPolledControllerNavigation = false; + } + if (SDL_WaitEventTimeout(&event, SHELL_EVENT_WAIT_TIMEOUT_MILLISECONDS) == 0) { + return; + } + + do { + const input::UiCommand command = handle_shell_event(state, event, controller, inputState); + if (event.type == SDL_CONTROLLERAXISMOTION && inputState != nullptr) { + update_trigger_repeat_tick(command, SDL_GetTicks(), &inputState->leftTriggerRepeatTick, &inputState->rightTriggerRepeatTick); + } + + processCommand(command); + if (command == input::UiCommand::none || is_navigation_command(command) || inputState == nullptr) { + continue; + } + + inputState->controllerNavigationNeutralRequired = true; + if (skipPolledControllerNavigation != nullptr) { + *skipPolledControllerNavigation = true; + } + reset_controller_navigation_hold_states( + &inputState->moveUpHoldState, + &inputState->moveDownHoldState, + &inputState->moveLeftHoldState, + &inputState->moveRightHoldState + ); + } while (SDL_PollEvent(&event)); + } + + template + void process_controller_navigation( + SDL_GameController *controller, + ShellInputState *inputState, + bool skipPolledControllerNavigation, + const ProcessCommand &processCommand + ) { + if (inputState == nullptr) { + return; + } + + if (inputState->controllerNavigationNeutralRequired) { + if (is_controller_navigation_active(controller)) { + reset_controller_navigation_hold_states( + &inputState->moveUpHoldState, + &inputState->moveDownHoldState, + &inputState->moveLeftHoldState, + &inputState->moveRightHoldState + ); + } else { + inputState->controllerNavigationNeutralRequired = false; + } + } + + if (skipPolledControllerNavigation || inputState->controllerNavigationNeutralRequired) { + return; + } + + processCommand(poll_controller_navigation( + controller, + SDL_GetTicks(), + &inputState->moveUpHoldState, + &inputState->moveDownHoldState, + &inputState->moveLeftHoldState, + &inputState->moveRightHoldState + )); + } + + /** + * @brief Stores the SDL resources and render caches owned by the shell loop. + */ + struct ShellResources { + SDL_Renderer *renderer = nullptr; ///< Renderer used for all shell drawing. + TTF_Font *titleFont = nullptr; ///< Large font used for screen titles. + TTF_Font *bodyFont = nullptr; ///< Standard body font. + TTF_Font *smallFont = nullptr; ///< Small font for secondary labels. + SDL_GameController *controller = nullptr; ///< Primary open game controller, when available. + CoverArtTextureCache coverArtTextureCache; ///< Cached cover-art textures. + AssetTextureCache assetTextureCache; ///< Cached UI asset textures. + KeypadModalLayoutCache keypadModalLayoutCache; ///< Cached keypad modal layout artifacts. + unsigned long encoderSettings = 0; ///< Active video encoder settings for layout calculations. + bool imageInitialized = false; ///< Whether SDL_image was initialized in this shell session. + bool ttfInitialized = false; ///< Whether SDL_ttf was initialized in this shell session. + }; + + /** + * @brief Stores mutable shell loop state that changes across frames. + */ + struct ShellRuntimeState { + bool running = true; ///< False when the shell should stop processing frames. + bool keypadRedrawRequested = true; ///< True when the add-host keypad must redraw immediately. + ShellInputState inputState {}; ///< Current controller and trigger input state. + Uint32 nextHostProbeTick = 0; ///< Next scheduled host probe time. + PairingTaskState pairingTask {}; ///< Background pairing workflow state. + AppListTaskState appListTask {}; ///< Background app-list fetch state. + AppArtTaskState appArtTask {}; ///< Background box-art download state. + HostProbeTaskState hostProbeTask {}; ///< Background host probe state. + }; + + /** + * @brief Describes an initialization failure while preparing the interactive shell. + */ + struct ShellInitializationFailure { + const char *category = "sdl"; ///< Logging category associated with the failure. + std::string message; ///< Human-readable failure detail. + }; + + /** + * @brief Wait for an SDL thread and discard its integer return code. + * + * @param thread Thread handle to join. + */ + void wait_for_thread(SDL_Thread *thread) { + if (thread == nullptr) { + return; + } + + int threadResult = 0; + SDL_WaitThread(thread, &threadResult); + (void) threadResult; + } + + /** + * @brief Open the first detected SDL game controller for shell navigation. + * + * @return The opened controller, or nullptr when none could be opened. + */ + SDL_GameController *open_primary_controller() { + for (int joystickIndex = 0; joystickIndex < SDL_NumJoysticks(); ++joystickIndex) { + if (!SDL_IsGameController(joystickIndex)) { + continue; + } + + SDL_GameController *controller = SDL_GameControllerOpen(joystickIndex); + if (controller == nullptr) { + continue; + } + + logging::info("input", "Opened primary controller"); + return controller; + } + + return nullptr; + } + + /** + * @brief Release the SDL resources and render caches owned by the shell loop. + * + * @param resources Shell resources to release. + */ + void close_shell_resources(ShellResources *resources) { + if (resources == nullptr) { + return; + } + + close_controller(resources->controller); + resources->controller = nullptr; + clear_cover_art_texture_cache(&resources->coverArtTextureCache); + clear_asset_texture_cache(&resources->assetTextureCache); + clear_keypad_modal_layout_cache(&resources->keypadModalLayoutCache); + + if (resources->smallFont != nullptr) { + TTF_CloseFont(resources->smallFont); + resources->smallFont = nullptr; + } + if (resources->bodyFont != nullptr) { + TTF_CloseFont(resources->bodyFont); + resources->bodyFont = nullptr; + } + if (resources->titleFont != nullptr) { + TTF_CloseFont(resources->titleFont); + resources->titleFont = nullptr; + } + if (resources->renderer != nullptr) { + SDL_DestroyRenderer(resources->renderer); + resources->renderer = nullptr; + } + if (resources->imageInitialized) { + IMG_Quit(); + resources->imageInitialized = false; + } + if (resources->ttfInitialized) { + TTF_Quit(); + resources->ttfInitialized = false; + } + } + + /** + * @brief Initialize the SDL renderer, fonts, caches, and controller used by the shell. + * + * @param window SDL window hosting the interactive shell. + * @param videoMode Active output mode used to size fonts. + * @param resources Shell resources to populate. + * @param failure Receives a failure category and message when initialization fails. + * @return True when all required shell resources were prepared. + */ + bool initialize_shell_resources(SDL_Window *window, const VIDEO_MODE &videoMode, ShellResources *resources, ShellInitializationFailure *failure) { + if (window == nullptr || resources == nullptr || failure == nullptr) { + return false; + } + + if (TTF_Init() != 0) { + failure->category = "ttf"; + failure->message = std::string("TTF_Init failed: ") + TTF_GetError(); + return false; + } + resources->ttfInitialized = true; + + IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG); + resources->imageInitialized = true; + + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1"); + + resources->renderer = SDL_CreateRenderer(window, -1, 0); + if (resources->renderer == nullptr) { + failure->category = "sdl"; + failure->message = std::string("SDL_CreateRenderer failed: ") + SDL_GetError(); + close_shell_resources(resources); + return false; + } + + SDL_SetRenderDrawBlendMode(resources->renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(resources->renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + SDL_RenderClear(resources->renderer); + SDL_RenderPresent(resources->renderer); + + const std::string fontPath = build_asset_path("fonts\\vegur-regular.ttf"); + resources->titleFont = TTF_OpenFont(fontPath.c_str(), std::max(24, videoMode.height / 16)); + resources->bodyFont = TTF_OpenFont(fontPath.c_str(), std::max(18, videoMode.height / 24)); + resources->smallFont = TTF_OpenFont(fontPath.c_str(), std::max(14, videoMode.height / 34)); + if (resources->titleFont == nullptr || resources->bodyFont == nullptr || resources->smallFont == nullptr) { + failure->category = "ttf"; + failure->message = std::string("Failed to load shell font from ") + fontPath + ": " + TTF_GetError(); + close_shell_resources(resources); + return false; + } + + resources->controller = open_primary_controller(); + resources->encoderSettings = XVideoGetEncoderSettings(); + return true; + } + + /** + * @brief Initialize mutable shell runtime state for a new interactive session. + * + * @param state Client state whose logging configuration should be applied. + * @param runtime Runtime state to prepare. + */ + void initialize_shell_runtime(const app::ClientState &state, ShellRuntimeState *runtime) { + if (runtime == nullptr) { + return; + } + + reset_pairing_task(&runtime->pairingTask); + reset_app_list_task(&runtime->appListTask); + reset_app_art_task(&runtime->appArtTask); + reset_host_probe_task(&runtime->hostProbeTask); + logging::set_minimum_level(logging::LogLevel::trace); + logging::set_file_minimum_level(state.settings.loggingLevel); + logging::set_debugger_console_minimum_level(state.settings.xemuConsoleLoggingLevel); + logging::info("app", "Entered interactive shell"); + } + + /** + * @brief Render the current shell frame and update redraw bookkeeping. + * + * @param videoMode Active output mode used by the shell renderer. + * @param state Client state to visualize. + * @param resources Shell resources supplying the renderer and caches. + * @param runtime Runtime state receiving redraw and shutdown updates. + * @return True when the frame rendered successfully. + */ + bool draw_current_shell_frame(const VIDEO_MODE &videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { + if (resources == nullptr || runtime == nullptr) { + return false; + } + + const std::vector retainedEntries = logging::snapshot(logging::LogLevel::info); + if (const auto viewModel = ui::build_shell_view_model(state, retainedEntries); + draw_shell(resources->renderer, videoMode, resources->encoderSettings, resources->titleFont, resources->bodyFont, resources->smallFont, viewModel, &resources->coverArtTextureCache, &resources->assetTextureCache, &resources->keypadModalLayoutCache)) { + runtime->keypadRedrawRequested = false; + return true; + } + + report_shell_failure("render", std::string("Shell render failed: ") + SDL_GetError()); + runtime->running = false; + state.shell.shouldExit = true; + return false; + } + + /** + * @brief Complete any background shell tasks whose results are ready. + * + * @param state Client state to update. + * @param resources Shell resources that own render caches. + * @param runtime Runtime state that owns the background tasks. + */ + void finish_shell_background_tasks(app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { + if (resources == nullptr || runtime == nullptr) { + return; + } + + finish_pairing_task_if_ready(state, &runtime->pairingTask); + finish_app_list_task_if_ready(state, &runtime->appListTask); + finish_app_art_task_if_ready(state, &runtime->appArtTask, &resources->coverArtTextureCache); + finish_host_probe_task_if_ready(state, &runtime->hostProbeTask); + } + + /** + * @brief Start any background shell tasks that are due to run on this frame. + * + * @param state Client state used to decide which tasks are eligible. + * @param runtime Runtime state that owns the background tasks. + * @param now Current SDL tick count. + */ + void start_shell_background_tasks_if_needed(app::ClientState &state, ShellRuntimeState *runtime, Uint32 now) { + if (runtime == nullptr) { + return; + } + + start_host_probe_task_if_needed(state, &runtime->hostProbeTask, now, &runtime->nextHostProbeTick); + start_app_list_task_if_needed(state, &runtime->appListTask, now); + start_app_art_task_if_needed(state, &runtime->appArtTask); + } + + /** + * @brief Apply a single translated UI command inside the shell loop. + * + * @param videoMode Active output mode used by the shell renderer. + * @param state Client state to mutate. + * @param command UI command to process. + * @param resources Shell resources supplying render caches. + * @param runtime Runtime state that owns background tasks and redraw flags. + */ + void process_shell_command( + const VIDEO_MODE &videoMode, + app::ClientState &state, + input::UiCommand command, + ShellResources *resources, + ShellRuntimeState *runtime + ) { + if (command == input::UiCommand::none || resources == nullptr || runtime == nullptr) { + return; + } + + runtime->keypadRedrawRequested = true; + + const app::ScreenId previousScreen = state.shell.activeScreen; + const app::AppUpdate update = app::handle_command(state, command); + logging::set_file_minimum_level(state.settings.loggingLevel); + logging::set_debugger_console_minimum_level(state.settings.xemuConsoleLoggingLevel); + log_app_update(state, update); + show_log_file_if_requested(state, update); + cancel_pairing_if_requested(state, update, &runtime->pairingTask); + test_host_connection_if_requested(state, update); + browse_host_apps_if_requested(state, update); + pair_host_if_requested(state, update, &runtime->pairingTask); + delete_host_data_if_requested(state, update, &resources->coverArtTextureCache); + delete_saved_file_if_requested(state, update, &resources->coverArtTextureCache); + factory_reset_if_requested(state, update, &resources->coverArtTextureCache); + refresh_saved_files_if_needed(state); + persist_settings_if_needed(state, update); + persist_hosts_if_needed(state, update); + + if (previousScreen != state.shell.activeScreen) { + release_page_resources_for_screen(previousScreen, state.shell.activeScreen, &resources->coverArtTextureCache, &resources->keypadModalLayoutCache); + ensure_hosts_loaded_for_active_screen(state); + } + if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode, state, resources, runtime)) { + return; + } + if (state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { + clear_keypad_modal_layout_cache(&resources->keypadModalLayoutCache); + } + } + + /** + * @brief Process one shell frame, including input, background tasks, and redraws. + * + * @param videoMode Active output mode used by the shell renderer. + * @param state Client state to update. + * @param resources Shell resources used by the frame. + * @param runtime Runtime state that carries input and task progress. + * @return True when the shell should continue processing future frames. + */ + bool run_shell_frame(const VIDEO_MODE &videoMode, app::ClientState &state, ShellResources *resources, ShellRuntimeState *runtime) { + if (resources == nullptr || runtime == nullptr) { + return false; + } + + const auto processCommand = [&state, &videoMode, resources, runtime](input::UiCommand command) { + process_shell_command(videoMode, state, command, resources, runtime); + }; + + ensure_hosts_loaded_for_active_screen(state); + finish_shell_background_tasks(state, resources, runtime); + refresh_saved_files_if_needed(state); + start_shell_background_tasks_if_needed(state, runtime, SDL_GetTicks()); + + update_exit_combo_hold(state, &runtime->inputState); + process_log_viewer_repeat_commands(state, SDL_GetTicks(), &runtime->inputState, processCommand); + + bool skipPolledControllerNavigation = false; + process_polled_shell_events(state, &resources->controller, &runtime->inputState, processCommand, &skipPolledControllerNavigation); + process_controller_navigation(resources->controller, &runtime->inputState, skipPolledControllerNavigation, processCommand); + + finish_shell_background_tasks(state, resources, runtime); + start_shell_background_tasks_if_needed(state, runtime, SDL_GetTicks()); + + if ((state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && + !draw_current_shell_frame(videoMode, state, resources, runtime)) { + return false; + } + + return runtime->running && !state.shell.shouldExit; + } + + /** + * @brief Join any background shell tasks before renderer resources are released. + * + * @param runtime Runtime state that owns the background tasks. + */ + void finalize_shell_tasks(ShellRuntimeState *runtime) { + if (runtime == nullptr) { + return; + } + + if (runtime->pairingTask.activeAttempt != nullptr) { + runtime->pairingTask.activeAttempt->discardResult.store(true); + finalize_pairing_attempt(nullptr, std::move(runtime->pairingTask.activeAttempt)); + } + while (!runtime->pairingTask.retiredAttempts.empty()) { + std::unique_ptr attempt = std::move(runtime->pairingTask.retiredAttempts.back()); + runtime->pairingTask.retiredAttempts.pop_back(); + if (attempt != nullptr) { + attempt->discardResult.store(true); + } + finalize_pairing_attempt(nullptr, std::move(attempt)); + } + + wait_for_thread(runtime->appListTask.thread); + wait_for_thread(runtime->appArtTask.thread); + for (const std::unique_ptr &worker : runtime->hostProbeTask.workers) { + if (worker == nullptr) { + continue; + } + + wait_for_thread(worker->thread); + } + } + +} // namespace + +namespace ui { + + int run_shell( + SDL_Window *window, + const VIDEO_MODE &videoMode, + app::ClientState &state + ) { + if (window == nullptr) { + return report_shell_failure("sdl", "Shell requires a valid SDL window"); + } + + ShellResources resources {}; + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, videoMode, &resources, &initializationFailure)) { + return report_shell_failure(initializationFailure.category, initializationFailure.message); + } + + ShellRuntimeState runtime {}; + initialize_shell_runtime(state, &runtime); + + while (runtime.running && !state.shell.shouldExit) { + if (!run_shell_frame(videoMode, state, &resources, &runtime)) { + break; + } + } + + finalize_shell_tasks(&runtime); + close_shell_resources(&resources); + return 0; + } + +} // namespace ui diff --git a/src/ui/shell_screen.h b/src/ui/shell_screen.h new file mode 100644 index 0000000..658d32e --- /dev/null +++ b/src/ui/shell_screen.h @@ -0,0 +1,31 @@ +/** + * @file src/ui/shell_screen.h + * @brief Declares the shell screen controller. + */ +#pragma once + +// nxdk includes +#include + +// local includes +#include "src/app/client_state.h" + +struct SDL_Window; + +namespace ui { + + /** + * @brief Run the interactive SDL shell after startup completes. + * + * @param window Shared SDL window created during startup. + * @param videoMode Active output mode for the shell window. + * @param state Mutable application state. + * @return 0 on normal exit, non-zero if initialization failed. + */ + int run_shell( + SDL_Window *window, + const VIDEO_MODE &videoMode, + app::ClientState &state + ); + +} // namespace ui diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp new file mode 100644 index 0000000..c71e379 --- /dev/null +++ b/src/ui/shell_view.cpp @@ -0,0 +1,714 @@ +/** + * @file src/ui/shell_view.cpp + * @brief Implements the shell view renderer and layout helpers. + */ +// class header include +#include "src/ui/shell_view.h" + +// standard includes +#include +#include +#include + +namespace { + + bool starts_with(const std::string &value, const char *prefix) { + return value.rfind(prefix, 0U) == 0U; + } + + bool screen_supports_notifications(const app::ClientState &state) { + return state.shell.activeScreen == app::ScreenId::home || state.shell.activeScreen == app::ScreenId::hosts || state.shell.activeScreen == app::ScreenId::apps || state.shell.activeScreen == app::ScreenId::settings; + } + + std::string format_file_size(std::uint64_t sizeBytes) { + if (sizeBytes < 1024ULL) { + return std::to_string(sizeBytes) + " B"; + } + if (sizeBytes < (1024ULL * 1024ULL)) { + return std::to_string((sizeBytes + 1023ULL) / 1024ULL) + " KB"; + } + return std::to_string((sizeBytes + (1024ULL * 1024ULL) - 1ULL) / (1024ULL * 1024ULL)) + " MB"; + } + + bool is_minor_status_message(const app::ClientState &state) { + if (state.shell.statusMessage.empty()) { + return true; + } + + if (starts_with(state.shell.statusMessage, "Loaded recent log file lines") || starts_with(state.shell.statusMessage, "No log file has been written yet") || starts_with(state.shell.statusMessage, "Testing connection to ") || starts_with(state.shell.statusMessage, "Editing host ") || starts_with(state.shell.statusMessage, "Updated host ") || starts_with(state.shell.statusMessage, "Cancelled host ") || starts_with(state.shell.statusMessage, "Using default Moonlight host port") || starts_with(state.shell.statusMessage, "Loading apps for ") || starts_with(state.shell.statusMessage, "Pairing is preparing the client identity")) { + return true; + } + + if (state.shell.activeScreen == app::ScreenId::apps) { + if (const app::HostRecord *host = app::apps_host(state); host != nullptr) { + return host->appListState == app::HostAppListState::loading && state.shell.statusMessage == host->appListStatusMessage; + } + } + + return false; + } + + std::string page_title(const app::ClientState &state) { + switch (state.shell.activeScreen) { + case app::ScreenId::home: + case app::ScreenId::hosts: + return {}; + case app::ScreenId::apps: + if (const app::HostRecord *host = app::apps_host(state); host != nullptr) { + return host->displayName; + } + return "Apps"; + case app::ScreenId::add_host: + return "Add Host"; + case app::ScreenId::pair_host: + return "Pair Host"; + case app::ScreenId::settings: + return "Settings"; + } + + return "Hosts"; + } + + std::string host_tile_status(const app::HostRecord &host) { + if (host.reachability == app::HostReachability::unknown) { + return "Checking..."; + } + if (host.reachability != app::HostReachability::online) { + return "Offline"; + } + if (host.pairingState != app::PairingState::paired) { + return "Needs Pairing"; + } + return "Online"; + } + + std::string host_tile_icon(const app::HostRecord &host) { + if (host.reachability == app::HostReachability::online && host.pairingState == app::PairingState::paired) { + return "icons\\host-monitor-online.svg"; + } + if (host.reachability == app::HostReachability::online) { + return "icons\\host-monitor-pairing.svg"; + } + return "icons\\host-monitor-offline.svg"; + } + + std::vector toolbar_buttons(const app::ClientState &state) { + return { + {"settings", "Settings", "G", "icons\\gear.svg", state.shell.activeScreen == app::ScreenId::hosts && state.hosts.focusArea == app::HostsFocusArea::toolbar && state.hosts.selectedToolbarButtonIndex % 3U == 0U}, + {"support", "Support", "?", "icons\\support.svg", state.shell.activeScreen == app::ScreenId::hosts && state.hosts.focusArea == app::HostsFocusArea::toolbar && state.hosts.selectedToolbarButtonIndex % 3U == 1U}, + {"add-host", "Add Host", "+", "icons\\add-host.svg", state.shell.activeScreen == app::ScreenId::hosts && state.hosts.focusArea == app::HostsFocusArea::toolbar && state.hosts.selectedToolbarButtonIndex % 3U == 2U}, + }; + } + + std::vector host_tiles(const app::ClientState &state) { + std::vector tiles; + tiles.reserve(state.hosts.items.size()); + for (std::size_t index = 0; index < state.hosts.items.size(); ++index) { + const app::HostRecord &host = state.hosts.items[index]; + tiles.emplace_back(ui::ShellHostTile { + host.address, + host.displayName, + host_tile_status(host), + host_tile_icon(host), + host.pairingState, + host.reachability, + state.shell.activeScreen == app::ScreenId::hosts && state.hosts.focusArea == app::HostsFocusArea::grid && index == state.hosts.selectedHostIndex, + }); + } + return tiles; + } + + std::vector app_tiles(const app::ClientState &state) { + std::vector tiles; + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr || host->pairingState != app::PairingState::paired) { + return tiles; + } + + for (std::size_t index = 0, visibleIndex = 0; index < host->apps.size(); ++index) { + const app::HostAppRecord &appRecord = host->apps[index]; + if (!state.apps.showHiddenApps && appRecord.hidden) { + continue; + } + + const std::string detail = appRecord.running ? "Running now" : std::string {}; + + std::string badgeLabel; + if (appRecord.favorite) { + badgeLabel = "Favorite"; + } else if (appRecord.hdrSupported) { + badgeLabel = "HDR"; + } else if (appRecord.hidden) { + badgeLabel = "Hidden"; + } + + tiles.emplace_back(ui::ShellAppTile { + std::to_string(appRecord.id), + appRecord.name, + detail, + badgeLabel, + appRecord.boxArtCacheKey, + appRecord.hidden, + appRecord.favorite, + appRecord.boxArtCached, + appRecord.running, + state.shell.activeScreen == app::ScreenId::apps && visibleIndex == state.apps.selectedAppIndex, + }); + ++visibleIndex; + } + + return tiles; + } + + std::string active_add_host_field_label(const app::ClientState &state) { + return state.addHostDraft.activeField == app::AddHostField::address ? "Address" : "Port"; + } + + std::string keypad_value(const app::ClientState &state) { + if (state.addHostDraft.keypad.visible) { + return state.addHostDraft.keypad.stagedInput.empty() && state.addHostDraft.activeField == app::AddHostField::port ? "default (47989)" : state.addHostDraft.keypad.stagedInput; + } + + if (state.addHostDraft.activeField == app::AddHostField::address) { + return state.addHostDraft.addressInput; + } + if (state.addHostDraft.portInput.empty()) { + return "default (47989)"; + } + return state.addHostDraft.portInput; + } + + const char *settings_category_label(app::SettingsCategory category) { + switch (category) { + case app::SettingsCategory::logging: + return "Logging"; + case app::SettingsCategory::display: + return "Display"; + case app::SettingsCategory::input: + return "Input"; + case app::SettingsCategory::reset: + return "Reset"; + } + + return "Logging"; + } + + std::vector keypad_buttons(const app::ClientState &state) { + const std::vector labels = state.addHostDraft.activeField == app::AddHostField::address ? std::vector {"1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0"} : std::vector {"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"}; + + std::vector buttons; + buttons.reserve(labels.size()); + for (std::size_t index = 0; index < labels.size(); ++index) { + buttons.emplace_back(ui::ShellModalButton { + labels[index], + true, + state.addHostDraft.keypad.visible && index == state.addHostDraft.keypad.selectedButtonIndex, + }); + } + + return buttons; + } + + std::vector keypad_modal_lines(const app::ClientState &state) { + std::vector lines = { + std::string("Editing field: ") + active_add_host_field_label(state), + std::string("Staged value: ") + keypad_value(state), + "Use the D-pad or left stick to choose a key. Hold a direction to keep moving.", + }; + + if (state.addHostDraft.activeField == app::AddHostField::address) { + lines.emplace_back("Enter a dotted IPv4 address such as 192.168.0.10."); + } else { + lines.emplace_back("Enter digits for a custom TCP port, or leave it empty to keep the default of 47989."); + } + + return lines; + } + + std::vector hosts_body_lines(const app::ClientState &state) { + if (state.hosts.items.empty()) { + return { + "No PCs have been added yet.", + "Use Add Host to save a host manually.", + "A Moonlight-style discovery grid now owns the home screen.", + }; + } + + return { + "Select a PC to pair or browse its apps.", + "Press Y on a controller, or I on a keyboard, for host actions.", + }; + } + + std::vector apps_body_lines(const app::ClientState &state) { + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr) { + return {"No host is selected."}; + } + if (host->pairingState != app::PairingState::paired) { + return {"This host is not paired yet. Return and select it to begin pairing."}; + } + if (host->appListState == app::HostAppListState::loading) { + return {}; + } + if (host->appListState == app::HostAppListState::failed) { + return { + host->appListStatusMessage.empty() ? "The app list could not be refreshed." : host->appListStatusMessage, + }; + } + if (host->apps.empty()) { + return {"Host did not return any launchable apps for this host."}; + } + + return {}; + } + + std::vector add_host_body_lines(const app::ClientState &state) { + std::vector lines = { + "Manual host entry with a keypad modal.", + std::string("Address: ") + app::current_add_host_address(state), + std::string("Port: ") + (state.addHostDraft.portInput.empty() ? std::string("default (47989)") : state.addHostDraft.portInput), + "Press A to edit either field with the keypad modal.", + }; + + if (!state.addHostDraft.validationMessage.empty()) { + lines.emplace_back("Validation: " + state.addHostDraft.validationMessage); + } + if (!state.addHostDraft.connectionMessage.empty()) { + lines.emplace_back("Connection: " + state.addHostDraft.connectionMessage); + } + return lines; + } + + std::vector pair_host_body_lines(const app::ClientState &state) { + std::vector lines = { + std::string("Target host: ") + state.pairingDraft.targetAddress, + }; + if (state.pairingDraft.stage == app::PairingStage::idle) { + lines.emplace_back("Checking whether the host is reachable before showing a PIN."); + } else { + lines.emplace_back(std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort)); + if (!state.pairingDraft.generatedPin.empty()) { + lines.emplace_back(std::string("PIN: ") + app::current_pairing_pin(state)); + lines.emplace_back("Enter the PIN on the host."); + } + } + if (!state.pairingDraft.statusMessage.empty()) { + lines.emplace_back("Status: " + state.pairingDraft.statusMessage); + } + return lines; + } + + std::vector settings_body_lines(const app::ClientState &state) { + std::vector lines = { + std::string("Category: ") + settings_category_label(state.settings.selectedCategory), + }; + if (state.settings.selectedCategory == app::SettingsCategory::logging) { + lines.emplace_back("Runtime log file: reset on every startup"); + lines.push_back(std::string("Log file path: ") + (state.settings.logFilePath.empty() ? "not configured" : state.settings.logFilePath)); + lines.push_back(std::string("File logging level: ") + logging::to_string(state.settings.loggingLevel)); + lines.push_back(std::string("xemu console logging level: ") + logging::to_string(state.settings.xemuConsoleLoggingLevel)); + lines.emplace_back("File logging writes to disk and should usually stay at NONE unless you are debugging an issue."); + lines.emplace_back("xemu console logging writes through DbgPrint(). Start xemu with -device lpc47m157 -serial stdio."); + lines.emplace_back("Startup console messages are always shown before the splash screen."); + return lines; + } + if (state.settings.selectedCategory == app::SettingsCategory::reset) { + if (state.settings.savedFiles.empty()) { + lines.emplace_back("Saved files: none found."); + return lines; + } + lines.emplace_back("Saved files on disk:"); + for (const startup::SavedFileEntry &savedFile : state.settings.savedFiles) { + lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); + } + return lines; + } + if (state.settings.selectedCategory == app::SettingsCategory::display) { + lines.emplace_back("Display options will be added here."); + return lines; + } + + lines.emplace_back("Input options will be added here."); + return lines; + } + + std::vector body_lines(const app::ClientState &state) { + switch (state.shell.activeScreen) { + case app::ScreenId::home: + case app::ScreenId::hosts: + return hosts_body_lines(state); + case app::ScreenId::apps: + return apps_body_lines(state); + case app::ScreenId::add_host: + return add_host_body_lines(state); + case app::ScreenId::pair_host: + return pair_host_body_lines(state); + case app::ScreenId::settings: + return settings_body_lines(state); + } + + return {}; + } + + std::vector menu_rows(const app::ClientState &state) { + std::vector rows; + const ui::MenuItem *selectedItem = state.menu.selected_item(); + for (const ui::MenuItem &item : state.menu.items()) { + rows.emplace_back(ui::ShellActionRow { + item.id, + item.label, + item.enabled, + selectedItem != nullptr && selectedItem->id == item.id, + false, + }); + } + return rows; + } + + std::vector detail_menu_rows(const app::ClientState &state) { + std::vector rows; + const ui::MenuItem *selectedItem = state.detailMenu.selected_item(); + for (const ui::MenuItem &item : state.detailMenu.items()) { + rows.emplace_back(ui::ShellActionRow { + item.id, + item.label, + item.enabled, + state.settings.focusArea == app::SettingsFocusArea::options && selectedItem != nullptr && selectedItem->id == item.id, + false, + }); + } + return rows; + } + + ui::ShellNotification notification(const app::ClientState &state) { + return { + "Notification", + state.shell.statusMessage, + { + {"dismiss-notification", "Dismiss", "icons\\button-x.svg", {}, false}, + }, + }; + } + + void fill_support_modal_view(ui::ShellViewModel *viewModel) { + viewModel->modal.title = "Support"; + viewModel->modal.lines = { + "Moonlight Xbox OG prototype UI", + "Use the footer actions below to close this dialog.", + "Open host and app context menus from the Y action on the main screens.", + }; + viewModel->modal.footerActions = { + {"close", "Close", "icons\\button-a.svg", "icons\\button-start.svg", true}, + {"back", "Back", "icons\\button-b.svg", {}, false}, + }; + } + + void fill_host_actions_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + viewModel->modal.title = "Host Actions"; + viewModel->modal.actions = { + {"view-all-apps", "View all apps", true, state.modal.selectedActionIndex == 0U, false}, + {"test-connection", "Test Network Connection", true, state.modal.selectedActionIndex == 1U, false}, + {"delete-host", "Delete PC", true, state.modal.selectedActionIndex == 2U, false}, + {"view-host-details", "View Details", true, state.modal.selectedActionIndex == 3U, false}, + }; + } + + void fill_host_details_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + viewModel->modal.title = "Host Details"; + const app::HostRecord *host = app::selected_host(state); + if (host == nullptr) { + return; + } + + const char *reachabilityLabel = "UNKNOWN"; + if (host->reachability == app::HostReachability::online) { + reachabilityLabel = "ONLINE"; + } else if (host->reachability == app::HostReachability::offline) { + reachabilityLabel = "OFFLINE"; + } + + viewModel->modal.lines = { + "Name: " + host->displayName, + std::string("State: ") + reachabilityLabel, + std::string("Active Address: ") + (host->activeAddress.empty() ? "NULL" : host->activeAddress), + std::string("UUID: ") + (host->uuid.empty() ? "NULL" : host->uuid), + std::string("Local Address: ") + (host->localAddress.empty() ? "NULL" : host->localAddress), + std::string("Remote Address: ") + (host->remoteAddress.empty() ? "NULL" : host->remoteAddress), + std::string("IPv6 Address: ") + (host->ipv6Address.empty() ? "NULL" : host->ipv6Address), + std::string("Manual Address: ") + (host->manualAddress.empty() ? host->address : host->manualAddress), + std::string("MAC Address: ") + (host->macAddress.empty() ? "NULL" : host->macAddress), + std::string("Pair State: ") + (host->pairingState == app::PairingState::paired ? "PAIRED" : "NOT_PAIRED"), + "Running Game ID: " + std::to_string(host->runningGameId), + "HTTPS Port: " + std::to_string(app::effective_host_port(host->httpsPort)), + }; + } + + void fill_app_actions_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + if (const app::HostAppRecord *appRecord = app::selected_app(state); appRecord != nullptr) { + viewModel->modal.title = appRecord->name; + viewModel->modal.actions = { + {"toggle-hidden-app", "Hide app", true, state.modal.selectedActionIndex == 0U, appRecord->hidden}, + {"view-app-details", "View details", true, state.modal.selectedActionIndex == 1U, false}, + {"create-shortcut", "Create shortcut", true, state.modal.selectedActionIndex == 2U, appRecord->favorite}, + }; + } + } + + void fill_app_details_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + if (const app::HostAppRecord *appRecord = app::selected_app(state); appRecord != nullptr) { + viewModel->modal.title = "App Details"; + viewModel->modal.lines = { + "Name: " + appRecord->name, + std::string("HDR Supported: ") + (appRecord->hdrSupported ? "YES" : "NO"), + "ID: " + std::to_string(appRecord->id), + }; + } + } + + void fill_log_viewer_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + viewModel->modal.title = "Log File"; + viewModel->logViewer.visible = true; + viewModel->logViewer.path = state.settings.logFilePath.empty() ? "not configured" : state.settings.logFilePath; + viewModel->logViewer.lines = state.settings.logViewerLines; + viewModel->logViewer.scrollOffset = state.settings.logViewerScrollOffset; + viewModel->logViewer.placement = state.settings.logViewerPlacement; + viewModel->modal.footerActions = { + {"older", "Older", "icons\\button-lb.svg", {}, false}, + {"newer", "Newer", "icons\\button-rb.svg", {}, false}, + {"fast-older", "Fast Older", "icons\\button-lt.svg", {}, false}, + {"fast-newer", "Fast Newer", "icons\\button-rt.svg", {}, false}, + {"move-pane", "Move Pane", "icons\\button-x.svg", {}, false}, + {"close", "Close", "icons\\button-b.svg", {}, true}, + }; + } + + void fill_confirmation_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + viewModel->modal.title = state.confirmation.title; + viewModel->modal.lines = state.confirmation.lines; + viewModel->modal.footerActions = { + {"confirm", "OK", "icons\\button-a.svg", {}, state.modal.selectedActionIndex == 0U}, + {"cancel", "Cancel", "icons\\button-b.svg", {}, state.modal.selectedActionIndex != 0U}, + }; + } + + void fill_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + if (!state.modal.active()) { + return; + } + + viewModel->modal.visible = true; + switch (state.modal.id) { + case app::ModalId::support: + fill_support_modal_view(viewModel); + return; + case app::ModalId::host_actions: + fill_host_actions_modal_view(state, viewModel); + return; + case app::ModalId::host_details: + fill_host_details_modal_view(state, viewModel); + return; + case app::ModalId::app_actions: + fill_app_actions_modal_view(state, viewModel); + return; + case app::ModalId::app_details: + fill_app_details_modal_view(state, viewModel); + return; + case app::ModalId::log_viewer: + fill_log_viewer_modal_view(state, viewModel); + return; + case app::ModalId::confirmation: + fill_confirmation_modal_view(state, viewModel); + return; + case app::ModalId::none: + return; + } + } + + std::vector footer_actions(const app::ClientState &state) { + switch (state.shell.activeScreen) { + case app::ScreenId::home: + case app::ScreenId::hosts: + { + std::string openLabel = "Pair"; + if (state.hosts.focusArea == app::HostsFocusArea::toolbar) { + openLabel = "Select"; + } else if (const app::HostRecord *selectedHost = app::selected_host(state); selectedHost != nullptr && selectedHost->pairingState == app::PairingState::paired) { + openLabel = "Open"; + } + std::vector actions = { + {"open", std::move(openLabel), "icons\\button-a.svg", {}, true}, + }; + if (state.hosts.focusArea == app::HostsFocusArea::grid && app::selected_host(state) != nullptr) { + actions.emplace_back(ui::ShellFooterAction {"host-menu", "Host Menu", "icons\\button-y.svg", {}, false}); + } + actions.emplace_back(ui::ShellFooterAction {"exit", "Exit", "icons\\button-select.svg", "icons\\button-start.svg", false}); + return actions; + } + case app::ScreenId::apps: + { + std::vector actions; + if (app::selected_app(state) != nullptr) { + actions.emplace_back(ui::ShellFooterAction {"launch", "Launch", "icons\\button-a.svg", {}, true}); + actions.emplace_back(ui::ShellFooterAction {"app-menu", "App Menu", "icons\\button-y.svg", {}, false}); + } + actions.emplace_back(ui::ShellFooterAction {"back", "Back", "icons\\button-b.svg", {}, false}); + return actions; + } + case app::ScreenId::add_host: + if (state.addHostDraft.keypad.visible) { + return { + {"enter", "Enter Key", "icons\\button-a.svg", {}, true}, + {"delete", "Delete", "icons\\button-x.svg", {}, false}, + {"cancel", "Cancel", "icons\\button-b.svg", {}, false}, + {"accept", "Accept", "icons\\button-start.svg", {}, false}, + }; + } + return { + {"select", "Select", "icons\\button-a.svg", {}, true}, + {"back", "Back", "icons\\button-b.svg", {}, false}, + }; + case app::ScreenId::pair_host: + return { + {"back", "Cancel", "icons\\button-b.svg", {}, false}, + }; + case app::ScreenId::settings: + return { + {"select", "Select", "icons\\button-a.svg", {}, true}, + {"back", state.settings.focusArea == app::SettingsFocusArea::options ? "Categories" : "Back", "icons\\button-b.svg", {}, false}, + }; + } + + return {}; + } + +} // namespace + +namespace ui { + + /** + * @brief Return whether a screen uses the split menu-and-detail layout. + * + * @param screen Screen identifier to inspect. + * @return true when the screen renders a split menu layout. + */ + bool screen_uses_split_menu_layout(app::ScreenId screen) { + return screen == app::ScreenId::settings || screen == app::ScreenId::add_host || screen == app::ScreenId::pair_host; + } + + /** + * @brief Populate which shell-view panels are currently active. + * + * @param state Current reducer-owned client state. + * @param viewModel View model being assembled for rendering. + */ + void fill_view_model_panel_state(const app::ClientState &state, ShellViewModel *viewModel) { + if (!screen_uses_split_menu_layout(state.shell.activeScreen)) { + return; + } + + viewModel->content.leftPanelActive = state.shell.activeScreen != app::ScreenId::settings || state.settings.focusArea == app::SettingsFocusArea::categories; + viewModel->content.rightPanelActive = state.shell.activeScreen == app::ScreenId::settings && state.settings.focusArea == app::SettingsFocusArea::options; + } + + /** + * @brief Copy the currently selected menu row label and description into the view model. + * + * @param state Current reducer-owned client state. + * @param viewModel View model being assembled for rendering. + */ + void fill_view_model_selected_menu_details(const app::ClientState &state, ShellViewModel *viewModel) { + if (!screen_uses_split_menu_layout(state.shell.activeScreen)) { + return; + } + + if (state.shell.activeScreen == app::ScreenId::settings && state.settings.focusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { + viewModel->content.selectedMenuRowLabel = state.detailMenu.selected_item()->label; + viewModel->content.selectedMenuRowDescription = state.detailMenu.selected_item()->description; + return; + } + if (state.menu.selected_item() != nullptr) { + viewModel->content.selectedMenuRowLabel = state.menu.selected_item()->label; + viewModel->content.selectedMenuRowDescription = state.menu.selected_item()->description; + } + } + + /** + * @brief Fill the diagnostics overlay with streaming stats and recent log lines. + * + * @param state Current reducer-owned client state. + * @param logEntries Retained log entries available for overlay display. + * @param statsLines Preformatted streaming statistics lines. + * @param viewModel View model being assembled for rendering. + */ + void fill_view_model_overlay(const app::ClientState &state, const std::vector &logEntries, const std::vector &statsLines, ShellViewModel *viewModel) { + if (!viewModel->overlay.visible) { + return; + } + + if (!statsLines.empty()) { + viewModel->overlay.lines.insert(viewModel->overlay.lines.end(), statsLines.begin(), statsLines.end()); + } else { + viewModel->overlay.lines.emplace_back("No active stream"); + } + + const std::size_t logLineLimit = 10; + const std::size_t availableLogCount = logEntries.size(); + const std::size_t maxOffset = availableLogCount > logLineLimit ? availableLogCount - logLineLimit : 0; + const std::size_t clampedOffset = std::min(state.shell.overlayScrollOffset, maxOffset); + const std::size_t startIndex = availableLogCount > logLineLimit ? availableLogCount - logLineLimit - clampedOffset : 0; + const std::size_t endIndex = std::min(availableLogCount, startIndex + logLineLimit); + + for (std::size_t index = startIndex; index < endIndex; ++index) { + viewModel->overlay.lines.push_back(logging::format_entry(logEntries[index])); + } + + if (clampedOffset > 0U) { + viewModel->overlay.lines.emplace(viewModel->overlay.lines.begin(), "Showing earlier log entries"); + } + } + + ShellViewModel build_shell_view_model( + const app::ClientState &state, + const std::vector &logEntries, + const std::vector &statsLines + ) { + ShellViewModel viewModel {}; + viewModel.frame.screen = state.shell.activeScreen; + viewModel.frame.title = "Moonlight"; + viewModel.frame.pageTitle = page_title(state); + viewModel.frame.statusMessage = state.shell.statusMessage; + viewModel.notification.visible = screen_supports_notifications(state) && !state.shell.statusMessage.empty() && !is_minor_status_message(state) && !state.modal.active() && !(state.shell.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible); + if (viewModel.notification.visible) { + viewModel.notification.content = notification(state); + } + viewModel.content.hostColumnCount = 3U; + viewModel.content.appColumnCount = 4U; + viewModel.content.toolbarButtons = toolbar_buttons(state); + viewModel.content.hostTiles = host_tiles(state); + viewModel.content.appTiles = app_tiles(state); + viewModel.content.bodyLines = body_lines(state); + viewModel.content.menuRows = menu_rows(state); + viewModel.content.detailMenuRows = detail_menu_rows(state); + fill_view_model_panel_state(state, &viewModel); + fill_view_model_selected_menu_details(state, &viewModel); + viewModel.frame.footerActions = footer_actions(state); + viewModel.overlay.visible = state.shell.overlayVisible; + viewModel.overlay.title = "Diagnostics"; + viewModel.keypad.visible = state.shell.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible; + viewModel.keypad.title = state.addHostDraft.activeField == app::AddHostField::address ? "Address Keypad" : "Port Keypad"; + viewModel.keypad.columnCount = 3U; + + if (viewModel.keypad.visible) { + viewModel.keypad.lines = keypad_modal_lines(state); + viewModel.keypad.buttons = keypad_buttons(state); + } + + fill_modal_view(state, &viewModel); + + fill_view_model_overlay(state, logEntries, statsLines, &viewModel); + + return viewModel; + } + +} // namespace ui diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h new file mode 100644 index 0000000..ae9fd5c --- /dev/null +++ b/src/ui/shell_view.h @@ -0,0 +1,204 @@ +/** + * @file src/ui/shell_view.h + * @brief Declares the shell view renderer and layout helpers. + */ +#pragma once + +// standard includes +#include +#include +#include + +// local includes +#include "src/app/client_state.h" +#include "src/logging/logger.h" + +namespace ui { + + /** + * @brief Render-ready button shown in the hosts toolbar. + */ + struct ShellToolbarButton { + std::string id; ///< Stable button identifier. + std::string label; ///< User-facing toolbar label. + std::string glyph; ///< Optional text glyph used when no icon asset is available. + std::string iconAssetPath; ///< Optional asset path for the button icon. + bool selected = false; ///< True when the button currently has focus. + }; + + /** + * @brief Render-ready host tile for the Moonlight-style hosts page. + */ + struct ShellHostTile { + std::string id; ///< Stable host tile identifier. + std::string displayName; ///< Primary host label. + std::string statusLabel; ///< Secondary host status text. + std::string iconAssetPath; ///< Asset path for the tile icon. + app::PairingState pairingState = app::PairingState::not_paired; ///< Pairing state displayed on the tile. + app::HostReachability reachability = app::HostReachability::unknown; ///< Reachability state displayed on the tile. + bool selected = false; ///< True when the tile currently has focus. + }; + + /** + * @brief Render-ready app tile for the per-host apps page. + */ + struct ShellAppTile { + std::string id; ///< Stable app tile identifier. + std::string name; ///< Primary app label. + std::string detail; ///< Secondary app detail text. + std::string badgeLabel; ///< Optional badge label such as HDR or Running. + std::string boxArtCacheKey; ///< Cache key used to resolve box art. + bool hidden = false; ///< True when the app is hidden by default. + bool favorite = false; ///< True when the app is flagged as a favorite. + bool boxArtCached = false; ///< True when box art is already available locally. + bool running = false; ///< True when the app is currently running on the host. + bool selected = false; ///< True when the tile currently has focus. + }; + + /** + * @brief Render-ready vertical action row used by menus and modals. + */ + struct ShellActionRow { + std::string id; ///< Stable row identifier. + std::string label; ///< User-facing row label. + bool enabled = true; ///< True when the row can be activated. + bool selected = false; ///< True when the row currently has focus. + bool checked = false; ///< True when the row represents an enabled option. + }; + + /** + * @brief Render-ready footer chip pairing a button icon with an action label. + */ + struct ShellFooterAction { + std::string id; ///< Stable footer action identifier. + std::string label; ///< User-facing footer label. + std::string iconAssetPath; ///< Primary icon asset path. + std::string secondaryIconAssetPath; ///< Optional secondary icon asset path. + bool emphasized = false; ///< True when the action should receive visual emphasis. + }; + + /** + * @brief Render-ready bottom-right notification shown above the shell content. + */ + struct ShellNotification { + std::string title; ///< Notification title. + std::string message; ///< Notification body message. + std::vector actions; ///< Footer actions shown with the notification. + }; + + /** + * @brief Render-ready button in the keypad modal. + */ + struct ShellModalButton { + std::string label; ///< User-facing button label. + bool enabled = true; ///< True when the button can be activated. + bool selected = false; ///< True when the button currently has focus. + }; + + /** + * @brief Shell-wide frame metadata shared across all render paths. + */ + struct ShellFrameView { + app::ScreenId screen = app::ScreenId::hosts; ///< Active screen being rendered. + std::string title; ///< Shell-wide title. + std::string pageTitle; ///< Primary page heading. + std::string statusMessage; ///< Status line shown near the footer. + std::vector footerActions; ///< Footer actions shown for the current screen. + }; + + /** + * @brief Main page content shown behind overlays and modals. + */ + struct ShellPageContentView { + std::vector toolbarButtons; ///< Toolbar buttons for the hosts page. + std::vector hostTiles; ///< Host tiles shown on the hosts page. + std::size_t hostColumnCount = 3U; ///< Number of columns used to lay out host tiles. + std::vector appTiles; ///< App tiles shown on the apps page. + std::size_t appColumnCount = 4U; ///< Number of columns used to lay out app tiles. + std::vector bodyLines; ///< Generic body lines for text-driven screens. + std::vector menuRows; ///< Primary action rows for the active screen. + std::vector detailMenuRows; ///< Secondary action rows for details or settings. + std::string selectedMenuRowLabel; ///< Label of the currently selected primary menu row. + std::string selectedMenuRowDescription; ///< Description of the currently selected row, when available. + bool leftPanelActive = false; ///< True when the left navigation panel should use the active accent border. + bool rightPanelActive = false; ///< True when the right content panel should use the active accent border. + }; + + /** + * @brief Transient notification view rendered above the main shell content. + */ + struct ShellNotificationView { + bool visible = false; ///< True when a transient notification should be rendered. + ShellNotification content; ///< Notification content when visible. + }; + + /** + * @brief Diagnostics overlay view rendered above the shell content. + */ + struct ShellOverlayView { + bool visible = false; ///< True when the diagnostics overlay should be rendered. + std::string title; ///< Diagnostics overlay title. + std::vector lines; ///< Diagnostics overlay body lines. + }; + + /** + * @brief Modal dialog view rendered on top of the current shell page. + */ + struct ShellModalView { + bool visible = false; ///< True when a modal dialog should be rendered. + std::string title; ///< Modal dialog title. + std::vector lines; ///< Modal dialog body lines. + std::vector actions; ///< Modal action rows. + std::vector footerActions; ///< Footer actions displayed while a modal is open. + }; + + /** + * @brief Embedded log viewer state surfaced by the log-file modal. + */ + struct ShellLogViewerView { + bool visible = false; ///< True when the log viewer should be rendered. + std::string path; ///< Path of the currently loaded log file. + std::vector lines; ///< Loaded log lines shown in the viewer. + std::size_t scrollOffset = 0U; ///< Vertical scroll offset inside the log viewer. + app::LogViewerPlacement placement = app::LogViewerPlacement::full; ///< Placement of the log viewer pane. + }; + + /** + * @brief Add-host keypad modal view rendered above the split add-host screen. + */ + struct ShellKeypadModalView { + bool visible = false; ///< True when the keypad modal should be rendered. + std::string title; ///< Title shown at the top of the keypad modal. + std::vector lines; ///< Instruction and draft lines shown in the keypad modal. + std::vector buttons; ///< Buttons rendered inside the keypad modal. + std::size_t columnCount = 0U; ///< Number of columns used to lay out keypad buttons. + }; + + /** + * @brief Render-ready shell state derived from the app model. + */ + struct ShellViewModel { + ShellFrameView frame; ///< Shell-wide frame metadata. + ShellNotificationView notification; ///< Transient notification rendered above the page. + ShellPageContentView content; ///< Main page content for the active screen. + ShellOverlayView overlay; ///< Diagnostics overlay content. + ShellModalView modal; ///< Modal dialog content. + ShellLogViewerView logViewer; ///< Embedded log viewer surfaced by the log modal. + ShellKeypadModalView keypad; ///< Add-host keypad modal content. + }; + + /** + * @brief Build a render-ready shell view from app state and diagnostics. + * + * @param state Current app shell state. + * @param logEntries Recent log entries for the optional overlay. + * @param statsLines Optional streaming statistics overlay lines. + * @return A render-ready shell view model. + */ + ShellViewModel build_shell_view_model( + const app::ClientState &state, + const std::vector &logEntries, + const std::vector &statsLines = {} + ); + +} // namespace ui diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6edfc89..dec535f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,11 @@ set(INSTALL_GTEST OFF CACHE BOOL "" FORCE) set(INSTALL_GMOCK OFF CACHE BOOL "" FORCE) add_subdirectory("${GTEST_SOURCE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}/googletest" EXCLUDE_FROM_ALL) +if(NOT TARGET moonlight-common-c OR NOT TARGET Moonlight::OpenSSL) + message(FATAL_ERROR + "tests/CMakeLists.txt requires the shared Moonlight dependency targets from the top-level configure") +endif() + set(TEST_COVERAGE_COMPILE_OPTIONS) set(TEST_COVERAGE_LINK_OPTIONS) if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" @@ -23,9 +28,9 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" list(APPEND TEST_COVERAGE_LINK_OPTIONS -fprofile-arcs -ftest-coverage) endif() -if (WIN32) +if(WIN32 OR MINGW OR CMAKE_HOST_WIN32) set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) # cmake-lint: disable=C0103 -endif () +endif() file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/tests/*.h @@ -36,15 +41,25 @@ add_executable(${PROJECT_NAME} ${MOONLIGHT_HOST_TESTABLE_SOURCES}) set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) + target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/tests/support ${CMAKE_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/third-party/tomlplusplus/include ${GTEST_SOURCE_DIR}/googletest/include ${GTEST_SOURCE_DIR}/googlemock/include) target_link_libraries(${PROJECT_NAME} PRIVATE - gtest_main) + gtest_main + Moonlight::OpenSSL) +target_compile_definitions(${PROJECT_NAME} + PRIVATE + TOML_EXCEPTIONS=0 + TOML_ENABLE_WINDOWS_COMPAT=0) +if(WIN32 OR MINGW OR CMAKE_HOST_WIN32) + target_link_libraries(${PROJECT_NAME} PRIVATE ws2_32) +endif() target_compile_options(${PROJECT_NAME} PRIVATE ${TEST_COVERAGE_COMPILE_OPTIONS}) @@ -52,11 +67,11 @@ target_link_options(${PROJECT_NAME} PRIVATE ${TEST_COVERAGE_LINK_OPTIONS}) -add_dependencies(${PROJECT_NAME} gtest gtest_main gmock gmock_main) +add_dependencies(${PROJECT_NAME} gtest gtest_main gmock gmock_main moonlight-common-c) include(GoogleTest) set(TEST_RUNTIME_PROPERTIES) -if(WIN32) +if(WIN32 OR MINGW OR CMAKE_HOST_WIN32) set(_test_runtime_path "${MOONLIGHT_HOST_TOOL_DIR}") if(_test_runtime_path STREQUAL "") get_filename_component(_test_runtime_path "${CMAKE_CXX_COMPILER}" DIRECTORY) diff --git a/tests/support/filesystem_test_utils.h b/tests/support/filesystem_test_utils.h new file mode 100644 index 0000000..c6f5963 --- /dev/null +++ b/tests/support/filesystem_test_utils.h @@ -0,0 +1,37 @@ +/** + * @file tests/support/filesystem_test_utils.h + * @brief Provides test support for filesystem test utility helpers. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace test_support { + + inline std::string join_path(const std::string &left, const std::string &right) { + return (std::filesystem::path(left) / right).make_preferred().string(); + } + + inline void remove_if_present(const std::string &path) { + std::remove(path.c_str()); + } + + inline void remove_directory_if_present(const std::string &path) { + std::error_code error; + std::filesystem::remove(path, error); + } + + inline void remove_tree_if_present(const std::string &path) { + std::error_code error; + std::filesystem::remove_all(path, error); + } + + inline bool create_directory(const std::filesystem::path &path) { + std::error_code error; + return std::filesystem::create_directory(path, error) || std::filesystem::is_directory(path, error); + } + +} // namespace test_support diff --git a/tests/support/hal/debug.h b/tests/support/hal/debug.h index e4d4757..d13e147 100644 --- a/tests/support/hal/debug.h +++ b/tests/support/hal/debug.h @@ -1,5 +1,10 @@ +/** + * @file tests/support/hal/debug.h + * @brief Provides test support for a host-side nxdk debug shim. + */ #pragma once +// standard includes #include #define debugPrint(...) std::printf(__VA_ARGS__) diff --git a/tests/support/hal/video.h b/tests/support/hal/video.h index 13cc227..13196fb 100644 --- a/tests/support/hal/video.h +++ b/tests/support/hal/video.h @@ -1,3 +1,7 @@ +/** + * @file tests/support/hal/video.h + * @brief Provides test support for a host-side nxdk video shim. + */ #pragma once struct VIDEO_MODE { diff --git a/tests/support/network_test_constants.h b/tests/support/network_test_constants.h new file mode 100644 index 0000000..4e3f320 --- /dev/null +++ b/tests/support/network_test_constants.h @@ -0,0 +1,76 @@ +/** + * @file tests/support/network_test_constants.h + * @brief Provides test support for shared network test constants. + */ +#pragma once + +// standard includes +#include +#include +#include + +namespace test_support { + + /// Shared IPv4 fixtures used by unit tests. + inline constexpr std::array kTestIpv4Addresses = { + "192.168.1.20", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "10.0.0.25", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "193.168.1.10", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.10", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.11", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.12", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.13", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.14", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "10.0.0.15", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.1.21", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.1.25", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "203.0.113.7", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "203.0.113.9", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "127.0.0.1", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.50", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.51", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.42", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "255.255.255.0", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "192.168.0.1", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "10.0.2.15", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + "10.0.2.2", // NOSONAR(cpp:S1313) test fixtures intentionally use concrete IPv4 values. + }; + + /// Index for `kTestIpv4Addresses` entries used by tests. + inline constexpr std::size_t kIpLivingRoom = 0U; + inline constexpr std::size_t kIpOffice = 1U; + inline constexpr std::size_t kIpManualCustomPort = 2U; + inline constexpr std::size_t kIpHostGridA = 3U; + inline constexpr std::size_t kIpHostGridB = 4U; + inline constexpr std::size_t kIpHostGridC = 5U; + inline constexpr std::size_t kIpHostGridD = 6U; + inline constexpr std::size_t kIpHostGridE = 7U; + inline constexpr std::size_t kIpSteamDeckDock = 8U; + inline constexpr std::size_t kIpLivingRoomNeighbor = 9U; + inline constexpr std::size_t kIpServerLocal = 10U; + inline constexpr std::size_t kIpServerExternal = 11U; + inline constexpr std::size_t kIpServerExternalAlt = 12U; + inline constexpr std::size_t kIpLoopback = 13U; + inline constexpr std::size_t kIpExternalFallback = 14U; + inline constexpr std::size_t kIpLocalFallback = 15U; + inline constexpr std::size_t kIpRuntimeAddress = 16U; + inline constexpr std::size_t kIpRuntimeSubnetMask = 17U; + inline constexpr std::size_t kIpRuntimeGateway = 18U; + inline constexpr std::size_t kIpRuntimeDhcpAddress = 19U; + inline constexpr std::size_t kIpRuntimeDhcpGateway = 20U; + + /// Shared host port fixtures used by unit tests. + inline constexpr std::array kTestPorts = { + 48000, + 47984, + 47989, + 47990, + }; + + /// Index for `kTestPorts` entries used by tests. + inline constexpr std::size_t kPortDefaultHost = 0U; + inline constexpr std::size_t kPortPairing = 1U; + inline constexpr std::size_t kPortResolvedHttp = 2U; + inline constexpr std::size_t kPortResolvedHttps = 3U; + +} // namespace test_support diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp new file mode 100644 index 0000000..e60544d --- /dev/null +++ b/tests/unit/app/client_state_test.cpp @@ -0,0 +1,1015 @@ +/** + * @file tests/unit/app/client_state_test.cpp + * @brief Verifies client state models and transitions. + */ +// class header include +#include "src/app/client_state.h" + +// lib includes +#include +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + TEST(ClientStateTest, StartsOnTheHostsScreenWithTheToolbarSelected) { + const app::ClientState state = app::create_initial_state(); + + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.hosts.focusArea, app::HostsFocusArea::toolbar); + EXPECT_EQ(state.hosts.selectedToolbarButtonIndex, 2U); + EXPECT_FALSE(state.shell.overlayVisible); + EXPECT_FALSE(state.shell.shouldExit); + EXPECT_FALSE(state.hosts.dirty); + EXPECT_EQ(state.settings.loggingLevel, logging::LogLevel::none); + } + + TEST(ClientStateTest, ReplacesHostsFromPersistenceWithoutMarkingThemDirty) { + app::ClientState state = app::create_initial_state(); + + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired}, + }, + "Loaded 2 saved host(s)"); + + ASSERT_EQ(state.hosts.size(), 2U); + EXPECT_FALSE(state.hosts.dirty); + EXPECT_EQ(state.shell.statusMessage, "Loaded 2 saved host(s)"); + EXPECT_EQ(state.hosts.focusArea, app::HostsFocusArea::grid); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); + } + + TEST(ClientStateTest, HostsToolbarCanOpenSettingsAndAddHost) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(update.navigation.activatedItemId, "settings-button"); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(update.navigation.activatedItemId, "add-host-button"); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::add_host); + } + + TEST(ClientStateTest, SettingsCanRequestLogViewingAndCycleBothLoggingLevelsFromNone) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.settings.selectedCategory, app::SettingsCategory::logging); + EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.requests.logViewRequested); + EXPECT_EQ(update.navigation.activatedItemId, "view-log-file"); + + app::handle_command(state, input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_FALSE(update.requests.logViewRequested); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.loggingLevel, logging::LogLevel::error); + EXPECT_EQ(state.shell.statusMessage, "Logging level set to ERROR"); + + app::handle_command(state, input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.xemuConsoleLoggingLevel, logging::LogLevel::error); + EXPECT_EQ(state.shell.statusMessage, "xemu console logging level set to ERROR"); + } + + TEST(ClientStateTest, TogglingAndScrollingTheOverlayUpdatesTheVisibleState) { + app::ClientState state = app::create_initial_state(); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::toggle_overlay); + EXPECT_TRUE(update.navigation.overlayChanged); + EXPECT_TRUE(update.navigation.overlayVisibilityChanged); + EXPECT_TRUE(state.shell.overlayVisible); + EXPECT_EQ(state.shell.overlayScrollOffset, 0U); + + update = app::handle_command(state, input::UiCommand::previous_page); + EXPECT_TRUE(update.navigation.overlayChanged); + EXPECT_GT(state.shell.overlayScrollOffset, 0U); + + update = app::handle_command(state, input::UiCommand::next_page); + EXPECT_TRUE(update.navigation.overlayChanged); + EXPECT_EQ(state.shell.overlayScrollOffset, 0U); + } + + TEST(ClientStateTest, BackFromHostsDoesNotRequestShutdown) { + app::ClientState state = app::create_initial_state(); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + + EXPECT_FALSE(update.navigation.exitRequested); + EXPECT_FALSE(state.shell.shouldExit); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + } + + TEST(ClientStateTest, CanSaveAManualHostEntryWithACustomPort) { + app::ClientState state = app::create_initial_state(); + + state.hosts.focusArea = app::HostsFocusArea::toolbar; + state.hosts.selectedToolbarButtonIndex = 2U; + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::add_host); + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpManualCustomPort]; + state.addHostDraft.portInput = "48000"; + state.menu.select_item_by_id("save-host"); + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_EQ(state.hosts.front().address, test_support::kTestIpv4Addresses[test_support::kIpManualCustomPort]); + EXPECT_EQ(state.hosts.front().port, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_EQ(state.hosts.front().displayName, "Host " + std::string(test_support::kTestIpv4Addresses[test_support::kIpManualCustomPort])); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); + } + + TEST(ClientStateTest, RejectsDuplicateHostEntriesAndAllowsCancellationBackToHosts) { + app::ClientState state = app::create_initial_state(); + + state.hosts.focusArea = app::HostsFocusArea::toolbar; + state.hosts.selectedToolbarButtonIndex = 2U; + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; + state.menu.select_item_by_id("save-host"); + app::handle_command(state, input::UiCommand::activate); + + ASSERT_EQ(state.hosts.size(), 1U); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + + state.hosts.focusArea = app::HostsFocusArea::toolbar; + state.hosts.selectedToolbarButtonIndex = 2U; + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.navigation.screenChanged); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::add_host); + + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; + state.menu.select_item_by_id("save-host"); + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_FALSE(update.navigation.screenChanged); + EXPECT_EQ(state.addHostDraft.validationMessage, "That host is already saved"); + + state.menu.select_item_by_id("cancel-add-host"); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + } + + TEST(ClientStateTest, SelectingAnUnpairedHostStartsPairing) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired}, + }); + + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_TRUE(update.requests.pairingRequested); + EXPECT_EQ(update.requests.pairingAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(update.requests.pairingPort, app::DEFAULT_HOST_PORT); + EXPECT_TRUE(app::is_valid_pairing_pin(update.requests.pairingPin)); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::pair_host); + EXPECT_EQ(state.pairingDraft.stage, app::PairingStage::pin_ready); + EXPECT_EQ(state.pairingDraft.generatedPin, update.requests.pairingPin); + EXPECT_EQ(state.pairingDraft.statusMessage, "Enter the PIN on the host. Pairing will continue automatically."); + EXPECT_FALSE(state.hosts.loaded); + EXPECT_TRUE(state.hosts.empty()); + EXPECT_TRUE(state.hosts.activeLoaded); + } + + TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired, app::HostReachability::offline}, + }); + + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_FALSE(update.navigation.screenChanged); + EXPECT_FALSE(update.requests.pairingRequested); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.shell.statusMessage, "Host is offline. Bring it online before pairing."); + } + + TEST(ClientStateTest, BackingOutOfThePairingScreenRequestsPairingCancellation) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::pair_host); + ASSERT_TRUE(update.requests.pairingRequested); + + update = app::handle_command(state, input::UiCommand::back); + + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_TRUE(update.requests.pairingCancelledRequested); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + } + + TEST(ClientStateTest, HostGridNavigationMatchesTheRenderedThreeColumnLayout) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host A", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", test_support::kTestIpv4Addresses[test_support::kIpHostGridB], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", test_support::kTestIpv4Addresses[test_support::kIpHostGridC], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", test_support::kTestIpv4Addresses[test_support::kIpHostGridD], 0, app::PairingState::paired, app::HostReachability::online}, + }); + + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.hosts.selectedHostIndex, 1U); + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.hosts.selectedHostIndex, 2U); + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); + } + + TEST(ClientStateTest, HostGridCanMoveDownIntoAPartialNextRowFromAnyColumn) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host A", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", test_support::kTestIpv4Addresses[test_support::kIpHostGridB], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", test_support::kTestIpv4Addresses[test_support::kIpHostGridC], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", test_support::kTestIpv4Addresses[test_support::kIpHostGridD], 0, app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.hosts.selectedHostIndex, 1U); + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); + + state.hosts.selectedHostIndex = 2U; + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); + } + + TEST(ClientStateTest, HostGridWrapsRightToTheNextRowAndLeftToThePreviousRow) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host A", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", test_support::kTestIpv4Addresses[test_support::kIpHostGridB], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", test_support::kTestIpv4Addresses[test_support::kIpHostGridC], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", test_support::kTestIpv4Addresses[test_support::kIpHostGridD], 0, app::PairingState::paired, app::HostReachability::online}, + {"Host E", test_support::kTestIpv4Addresses[test_support::kIpHostGridE], 0, app::PairingState::paired, app::HostReachability::online}, + }); + + state.hosts.selectedHostIndex = 2U; + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); + + app::handle_command(state, input::UiCommand::move_left); + EXPECT_EQ(state.hosts.selectedHostIndex, 2U); + } + + TEST(ClientStateTest, SelectingAPairedHostOpensTheAppsScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_FALSE(update.navigation.screenChanged); + EXPECT_TRUE(update.requests.appsBrowseRequested); + EXPECT_FALSE(update.requests.appsBrowseShowHidden); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + } + + TEST(ClientStateTest, EnteringTheAppsScreenUnloadsTheHostsPageData) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::apps); + EXPECT_FALSE(state.hosts.loaded); + EXPECT_TRUE(state.hosts.empty()); + EXPECT_TRUE(state.hosts.activeLoaded); + EXPECT_EQ(state.hosts.active.address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + } + + TEST(ClientStateTest, SelectingAnOfflinePairedHostDoesNotOpenTheAppsScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::offline}, + }); + + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_FALSE(update.navigation.screenChanged); + EXPECT_TRUE(update.requests.appsBrowseRequested); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.shell.statusMessage.empty()); + } + + TEST(ClientStateTest, AppliesFetchedAppListsAndPreservesPerAppFlags) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.active.runningGameId = 101; + state.hosts.active.apps = { + {"Steam", 101, false, true, true, "cached-steam", true, false}, + }; + + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], { + {"Steam", 101, true, false, false, "cached-steam", false, false}, + {"Desktop", 102, false, false, false, "cached-desktop", true, false}, + }, + 0x55AAU, + true, + "Loaded 2 app(s)"); + + ASSERT_EQ(state.hosts.active.apps.size(), 2U); + EXPECT_EQ(state.hosts.active.appListState, app::HostAppListState::ready); + EXPECT_TRUE(state.hosts.active.apps[0].hidden); + EXPECT_TRUE(state.hosts.active.apps[0].favorite); + EXPECT_TRUE(state.hosts.active.apps[0].boxArtCached); + EXPECT_TRUE(state.hosts.active.apps[0].running); + EXPECT_TRUE(state.hosts.active.apps[1].boxArtCached); + EXPECT_TRUE(state.shell.statusMessage.empty()); + } + + TEST(ClientStateTest, AppliesFetchedAppListsWhenBackgroundTasksReportTheResolvedHttpPort) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + state.hosts.front().resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttp], { + {"Steam", 101, true, false, false, "steam-cover", true, false}, + }, + 0xBEEFU, + true, + "Loaded 1 app(s)"); + + ASSERT_EQ(state.hosts.active.apps.size(), 1U); + EXPECT_EQ(state.hosts.active.appListState, app::HostAppListState::ready); + EXPECT_EQ(state.hosts.active.apps.front().name, "Steam"); + EXPECT_TRUE(state.shell.statusMessage.empty()); + } + + TEST(ClientStateTest, MarksCoverArtCachedWhenBackgroundTasksReportTheResolvedHttpsPort) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.active.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + state.hosts.active.apps = { + {"Steam", 101, true, false, false, "steam-cover", false, false}, + }; + + app::mark_cover_art_cached(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttps], 101); + + ASSERT_EQ(state.hosts.active.apps.size(), 1U); + EXPECT_TRUE(state.hosts.active.apps.front().boxArtCached); + } + + TEST(ClientStateTest, SuccessfulAppListRefreshMarksHostsDirtyForPersistence) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_FALSE(state.hosts.dirty); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], { + {"Steam", 101, true, false, false, "steam-cover", true, false}, + }, + 0xACEDU, + true, + "Loaded 1 app(s)"); + + EXPECT_TRUE(state.hosts.dirty); + ASSERT_EQ(state.hosts.active.apps.size(), 1U); + EXPECT_EQ(state.hosts.active.appListContentHash, 0xACEDU); + } + + TEST(ClientStateTest, FailedRefreshKeepsCachedAppsAvailable) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.active.apps = { + {"Steam", 101, false, false, false, "cached-steam", true, false}, + }; + state.hosts.active.appListContentHash = 0x1234U; + + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], {}, 0, false, "Timed out while refreshing apps"); + + EXPECT_EQ(state.hosts.active.appListState, app::HostAppListState::ready); + ASSERT_EQ(state.hosts.active.apps.size(), 1U); + EXPECT_EQ(state.hosts.active.apps.front().name, "Steam"); + EXPECT_EQ(state.shell.statusMessage, "Timed out while refreshing apps"); + } + + TEST(ClientStateTest, LeavingTheAppsScreenUnloadsTheInMemoryAppList) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.active.apps = { + {"Steam", 101, false, false, false, "cached-steam", true, false}, + }; + state.hosts.active.appListState = app::HostAppListState::ready; + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.hosts.active.apps.empty()); + EXPECT_EQ(state.hosts.active.appListState, app::HostAppListState::idle); + } + + TEST(ClientStateTest, ExplicitUnpairedAppListFailureMarksTheHostAsNotPaired) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + + app::apply_app_list_result( + state, + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + {}, + 0, + false, + "The host reports that this client is no longer paired. Pair the host again." + ); + + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::not_paired); + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::failed); + } + + TEST(ClientStateTest, ExplicitUnpairedAppListFailureClearsCachedApps) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + state.hosts.front().apps = { + {"Steam", 101, false, false, false, "cached-steam", true, false}, + }; + state.hosts.front().appListContentHash = 0x1234U; + + app::apply_app_list_result( + state, + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + {}, + 0, + false, + "The client is not authorized. Certificate verification failed." + ); + + EXPECT_TRUE(state.hosts.front().apps.empty()); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::not_paired); + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::failed); + } + + TEST(ClientStateTest, TransientAppListFailuresDoNotMarkTheHostAsNotPaired) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], {}, 0, false, "Timed out while refreshing apps"); + + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::paired); + } + + TEST(ClientStateTest, AppGridWrapsHorizontallyAndFindsTheClosestItemInPartialRows) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], { + {"App 1", 1, false, false, false, "app-1", false, false}, + {"App 2", 2, false, false, false, "app-2", false, false}, + {"App 3", 3, false, false, false, "app-3", false, false}, + {"App 4", 4, false, false, false, "app-4", false, false}, + {"App 5", 5, false, false, false, "app-5", false, false}, + }, + 0x99U, + true, + "Loaded 5 app(s)"); + + state.apps.selectedAppIndex = 3U; + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.apps.selectedAppIndex, 4U); + + app::handle_command(state, input::UiCommand::move_left); + EXPECT_EQ(state.apps.selectedAppIndex, 3U); + + state.apps.selectedAppIndex = 2U; + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.apps.selectedAppIndex, 4U); + } + + TEST(ClientStateTest, LogViewerCanScrollAndCyclePlacement) { + app::ClientState state = app::create_initial_state(); + app::apply_log_viewer_contents(state, {"line-1", "line-2", "line-3", "line-4"}, "Loaded log file preview"); + + ASSERT_EQ(state.modal.id, app::ModalId::log_viewer); + EXPECT_EQ(state.shell.statusMessage, "Loaded log file preview"); + EXPECT_EQ(state.settings.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_EQ(state.settings.logViewerScrollOffset, 0U); + + app::handle_command(state, input::UiCommand::previous_page); + EXPECT_EQ(state.settings.logViewerScrollOffset, 1U); + + app::handle_command(state, input::UiCommand::fast_previous_page); + EXPECT_EQ(state.settings.logViewerScrollOffset, 3U); + + app::handle_command(state, input::UiCommand::next_page); + EXPECT_EQ(state.settings.logViewerScrollOffset, 2U); + + app::handle_command(state, input::UiCommand::delete_character); + EXPECT_EQ(state.settings.logViewerPlacement, app::LogViewerPlacement::left); + + app::handle_command(state, input::UiCommand::open_context_menu); + EXPECT_EQ(state.settings.logViewerPlacement, app::LogViewerPlacement::right); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.navigation.modalClosed); + EXPECT_EQ(state.modal.id, app::ModalId::none); + } + + TEST(ClientStateTest, LeavingTheAppsScreenClearsTransientAppStatusAndIgnoresLaterRefreshText) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::apps); + EXPECT_TRUE(state.shell.statusMessage.empty()); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.shell.statusMessage.empty()); + + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], { + {"Steam", 101, false, false, false, "steam-cover", false, false}, + }, + 0, + false, + "The host applist response did not contain any app entries"); + + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.shell.statusMessage.empty()); + EXPECT_EQ(state.hosts.active.appListState, app::HostAppListState::idle); + EXPECT_TRUE(state.hosts.active.appListStatusMessage.empty()); + } + + TEST(ClientStateTest, SettingsCanRequestDeletionOfSavedFilesFromTheResetCategory) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::replace_saved_files(state, { + {R"(E:\UDATA\12345678\moonlight.log)", "moonlight.log", 128U}, + }); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::reset); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + ASSERT_TRUE(state.detailMenu.select_item_by_id("delete-saved-file:E:\\UDATA\\12345678\\moonlight.log")); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.navigation.modalOpened); + EXPECT_EQ(state.modal.id, app::ModalId::confirmation); + + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.persistence.savedFileDeleteRequested); + EXPECT_EQ(update.persistence.savedFileDeletePath, "E:\\UDATA\\12345678\\moonlight.log"); + } + + TEST(ClientStateTest, SettingsFactoryResetUsesAConfirmationDialog) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::reset); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + const app::AppUpdate openUpdate = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(openUpdate.navigation.modalOpened); + ASSERT_EQ(state.modal.id, app::ModalId::confirmation); + + const app::AppUpdate confirmUpdate = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(confirmUpdate.persistence.factoryResetRequested); + } + + TEST(ClientStateTest, HostContextMenuCanDeleteTheSelectedHost) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.persistence.hostsChanged); + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_EQ(state.hosts.front().displayName, "Office PC"); + } + + TEST(ClientStateTest, DeletingAPairedHostRequestsPersistentCleanupAndMarksItForManualRePairing) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + state.hosts.front().apps = { + {"Steam", 101, false, false, false, "steam-cover", true, false}, + {"Desktop", 102, false, false, false, "desktop-cover", true, false}, + {"Duplicate", 103, false, false, false, "steam-cover", true, false}, + }; + state.hosts.front().appListState = app::HostAppListState::ready; + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.persistence.hostsChanged); + EXPECT_TRUE(update.persistence.hostDeleteCleanupRequested); + EXPECT_TRUE(update.persistence.deletedHostWasPaired); + EXPECT_EQ(update.persistence.deletedHostAddress, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(update.persistence.deletedHostPort, test_support::kTestPorts[test_support::kPortDefaultHost]); + ASSERT_EQ(update.persistence.deletedHostCoverArtCacheKeys.size(), 2U); + EXPECT_EQ(update.persistence.deletedHostCoverArtCacheKeys[0], "steam-cover"); + EXPECT_EQ(update.persistence.deletedHostCoverArtCacheKeys[1], "desktop-cover"); + EXPECT_TRUE(state.hosts.empty()); + EXPECT_TRUE(app::host_requires_manual_pairing(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost])); + } + + TEST(ClientStateTest, SuccessfulRePairingClearsTheManualRePairRequirementAfterHostDeletion) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate deleteUpdate = app::handle_command(state, input::UiCommand::activate); + + ASSERT_TRUE(deleteUpdate.persistence.hostDeleteCleanupRequested); + ASSERT_TRUE(app::host_requires_manual_pairing(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost])); + + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::not_paired, app::HostReachability::online}, + }); + + EXPECT_TRUE(app::apply_pairing_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], true, "Paired successfully")); + EXPECT_FALSE(app::host_requires_manual_pairing(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost])); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::paired); + } + + TEST(ClientStateTest, RequestsAConnectionTestFromTheAddHostScreen) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; + state.addHostDraft.portInput = "48000"; + state.menu.select_item_by_id("test-connection"); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.requests.connectionTestRequested); + EXPECT_EQ(update.requests.connectionTestAddress, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); + EXPECT_EQ(update.requests.connectionTestPort, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_EQ(state.shell.statusMessage, "Testing connection to " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + ":48000..."); + } + + TEST(ClientStateTest, StagesCancelsDeletesAndAcceptsAddHostKeypadEdits) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_FALSE(update.navigation.screenChanged); + EXPECT_TRUE(state.addHostDraft.keypad.visible); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 0U); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); + + app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + "1"); + + app::handle_command(state, input::UiCommand::delete_character); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); + + app::handle_command(state, input::UiCommand::back); + EXPECT_FALSE(state.addHostDraft.keypad.visible); + EXPECT_EQ(state.addHostDraft.addressInput, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); + + app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(state.addHostDraft.keypad.visible); + app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + "1"); + app::handle_command(state, input::UiCommand::confirm); + EXPECT_FALSE(state.addHostDraft.keypad.visible); + EXPECT_EQ(state.addHostDraft.addressInput, std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + "1"); + } + + TEST(ClientStateTest, AddHostKeypadWrapsHorizontallyAcrossRowEdges) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::activate); + app::handle_command(state, input::UiCommand::activate); + ASSERT_TRUE(state.addHostDraft.keypad.visible); + + state.addHostDraft.keypad.selectedButtonIndex = 2U; + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 0U); + + app::handle_command(state, input::UiCommand::move_left); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 2U); + + state.addHostDraft.keypad.selectedButtonIndex = 10U; + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 9U); + + app::handle_command(state, input::UiCommand::move_left); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 10U); + } + + TEST(ClientStateTest, AddHostKeypadWrapsVerticallyAcrossTopAndBottomRows) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::activate); + app::handle_command(state, input::UiCommand::activate); + ASSERT_TRUE(state.addHostDraft.keypad.visible); + + state.addHostDraft.keypad.selectedButtonIndex = 1U; + app::handle_command(state, input::UiCommand::move_up); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 10U); + + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 1U); + + state.addHostDraft.keypad.selectedButtonIndex = 8U; + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 10U); + + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 1U); + } + + TEST(ClientStateTest, ApplyingConnectionTestResultsUpdatesTheVisibleDraftAndActiveHost) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; + state.hosts.activeLoaded = true; + state.hosts.active = { + "Host A", + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::not_paired, + app::HostReachability::unknown, + }; + + app::apply_connection_test_result(state, true, "Connected successfully"); + + EXPECT_TRUE(state.addHostDraft.lastConnectionSucceeded); + EXPECT_EQ(state.addHostDraft.connectionMessage, "Connected successfully"); + EXPECT_EQ(state.shell.statusMessage, "Connected successfully"); + EXPECT_EQ(state.hosts.active.reachability, app::HostReachability::online); + } + + TEST(ClientStateTest, AddHostStartPairingValidatesInputAndAddsNewHostsWhenNeeded) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::add_host); + ASSERT_TRUE(state.menu.select_item_by_id("start-pairing")); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_FALSE(update.requests.pairingRequested); + EXPECT_EQ(state.addHostDraft.validationMessage, "Enter a valid IPv4 host address"); + + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]; + state.addHostDraft.portInput = "48000"; + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.persistence.hostsChanged); + EXPECT_TRUE(update.requests.pairingRequested); + EXPECT_EQ(update.requests.pairingAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(update.requests.pairingPort, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::pair_host); + EXPECT_TRUE(state.hosts.activeLoaded); + } + + TEST(ClientStateTest, HostActionsModalCanRequestConnectionTestsAndOpenDetails) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.requests.connectionTestRequested); + EXPECT_EQ(update.requests.connectionTestAddress, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(update.requests.connectionTestPort, test_support::kTestPorts[test_support::kPortDefaultHost]); + + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.modal.id, app::ModalId::host_details); + EXPECT_FALSE(update.navigation.modalClosed); + } + + TEST(ClientStateTest, AppActionsModalCanToggleFlagsAndShowDetails) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::apps; + state.hosts.activeLoaded = true; + state.apps.showHiddenApps = true; + state.hosts.active = { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + app::HostReachability::online, + }; + state.hosts.active.apps = { + {"Steam", 101, false, false, false, "steam-cover", true, false}, + }; + + app::handle_command(state, input::UiCommand::open_context_menu); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.persistence.hostsChanged); + EXPECT_TRUE(state.hosts.active.apps.front().hidden); + + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.modal.id, app::ModalId::app_details); + EXPECT_FALSE(update.navigation.modalClosed); + + app::handle_command(state, input::UiCommand::back); + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.persistence.hostsChanged); + EXPECT_TRUE(state.hosts.active.apps.front().favorite); + } + + TEST(ClientStateTest, SettingsPlaceholderActivationAndBackNavigationUpdateFocusAndStatus) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "settings-category:display"); + EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.shell.statusMessage, "display-placeholder is not implemented yet"); + + update = app::handle_command(state, input::UiCommand::back); + EXPECT_EQ(state.settings.focusArea, app::SettingsFocusArea::categories); + EXPECT_FALSE(update.navigation.screenChanged); + } + + TEST(ClientStateTest, ConfirmationModalCanBeCancelledWithoutRequestingPersistenceChanges) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::reset); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.modal.id, app::ModalId::confirmation); + EXPECT_TRUE(update.navigation.modalOpened); + + app::handle_command(state, input::UiCommand::move_right); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.navigation.modalClosed); + EXPECT_FALSE(update.persistence.factoryResetRequested); + EXPECT_EQ(state.shell.statusMessage, "Cancelled the pending reset action"); + } + + TEST(ClientStateTest, AppsScreenCanLaunchSelectionsAndClearStatusWithDeleteCharacter) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::apps; + state.hosts.activeLoaded = true; + state.hosts.active = { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + app::HostReachability::online, + }; + state.hosts.active.apps = { + {"Steam", 101, false, false, false, "steam-cover", true, false}, + }; + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(update.navigation.activatedItemId, "launch-app"); + EXPECT_EQ(state.shell.statusMessage, "Launching Steam is not implemented yet"); + + update = app::handle_command(state, input::UiCommand::delete_character); + EXPECT_TRUE(state.shell.statusMessage.empty()); + EXPECT_FALSE(update.navigation.screenChanged); + } + + TEST(ClientStateTest, SuccessfulPairingReturnsToHostsAndKeepsTheHostSelected) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired, app::HostReachability::online}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + + state.hosts.selectedHostIndex = 1U; + EXPECT_TRUE(app::apply_pairing_result(state, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], app::DEFAULT_HOST_PORT, true, "Paired successfully")); + + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::paired); + } + +} // namespace diff --git a/tests/unit/app/host_records_test.cpp b/tests/unit/app/host_records_test.cpp new file mode 100644 index 0000000..ee051a2 --- /dev/null +++ b/tests/unit/app/host_records_test.cpp @@ -0,0 +1,207 @@ +/** + * @file tests/unit/app/host_records_test.cpp + * @brief Verifies host record models and utilities. + */ +// class header include +#include "src/app/host_records.h" + +// standard includes +#include + +// lib includes +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + TEST(HostRecordsTest, NormalizesAndValidatesIpv4Addresses) { + EXPECT_EQ(app::normalize_ipv4_address("192.168.001.010"), "192.168.1.10"); + EXPECT_TRUE(app::is_valid_ipv4_address("10.0.0.5")); + EXPECT_FALSE(app::is_valid_ipv4_address("256.0.0.1")); + EXPECT_FALSE(app::is_valid_ipv4_address("10.0.0")); + } + + TEST(HostRecordsTest, ValidatesRecordsBeforeTheyAreSaved) { + const app::HostRecord validRecord { + "Living Room PC", + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + 0, + app::PairingState::not_paired, + }; + std::string errorMessage; + + EXPECT_TRUE(app::validate_host_record(validRecord, &errorMessage)); + EXPECT_TRUE(errorMessage.empty()); + + const app::HostRecord invalidRecord { + "", + "999.168.1.20", + 0, + app::PairingState::not_paired, + }; + EXPECT_FALSE(app::validate_host_record(invalidRecord, &errorMessage)); + EXPECT_FALSE(errorMessage.empty()); + } + + TEST(HostRecordsTest, RejectsDisplayNamesWithTabsAndNonNormalizedAddresses) { + std::string errorMessage; + + EXPECT_FALSE(app::validate_host_record({"Living\tRoom", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, &errorMessage)); + EXPECT_NE(errorMessage.find("tabs or new lines"), std::string::npos); + + errorMessage.clear(); + EXPECT_FALSE(app::validate_host_record({"Living Room", "192.168.001.010", 0, app::PairingState::paired}, &errorMessage)); + EXPECT_NE(errorMessage.find("already be normalized"), std::string::npos); + } + + TEST(HostRecordsTest, SerializesAndParsesRoundTripHostLists) { + const std::vector records = { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, + {"Steam Deck Dock", test_support::kTestIpv4Addresses[test_support::kIpSteamDeckDock], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::not_paired}, + }; + + const std::string serializedRecords = app::serialize_host_records(records); + const app::ParseHostRecordsResult parsedRecords = app::parse_host_records(serializedRecords); + + ASSERT_TRUE(parsedRecords.errors.empty()); + ASSERT_EQ(parsedRecords.records.size(), 2U); + EXPECT_EQ(parsedRecords.records[0].displayName, "Living Room PC"); + EXPECT_EQ(parsedRecords.records[0].address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(parsedRecords.records[0].port, 0); + EXPECT_EQ(parsedRecords.records[0].pairingState, app::PairingState::paired); + EXPECT_EQ(parsedRecords.records[1].displayName, "Steam Deck Dock"); + EXPECT_EQ(parsedRecords.records[1].address, test_support::kTestIpv4Addresses[test_support::kIpSteamDeckDock]); + EXPECT_EQ(parsedRecords.records[1].port, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_EQ(parsedRecords.records[1].pairingState, app::PairingState::not_paired); + } + + TEST(HostRecordsTest, RoundTripsCachedAppListMetadata) { + app::HostRecord host { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + }; + host.runningGameId = 102U; + host.resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + host.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + host.appListContentHash = 0xABCD1234ULL; + host.apps = { + {"Steam Big Picture", 101, true, false, true, "steam-big-picture", true, false}, + {"Desktop", 102, false, true, false, "desktop-cover", false, false}, + }; + + const app::ParseHostRecordsResult parsedRecords = app::parse_host_records(app::serialize_host_records({host})); + + ASSERT_TRUE(parsedRecords.errors.empty()); + ASSERT_EQ(parsedRecords.records.size(), 1U); + ASSERT_EQ(parsedRecords.records.front().apps.size(), 2U); + EXPECT_EQ(parsedRecords.records.front().runningGameId, 102U); + EXPECT_EQ(parsedRecords.records.front().resolvedHttpPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); + EXPECT_EQ(parsedRecords.records.front().httpsPort, test_support::kTestPorts[test_support::kPortResolvedHttps]); + EXPECT_EQ(parsedRecords.records.front().appListContentHash, 0xABCD1234ULL); + EXPECT_EQ(parsedRecords.records.front().appListState, app::HostAppListState::ready); + EXPECT_EQ(parsedRecords.records.front().apps[0].name, "Steam Big Picture"); + EXPECT_TRUE(parsedRecords.records.front().apps[0].favorite); + EXPECT_TRUE(parsedRecords.records.front().apps[0].boxArtCached); + EXPECT_FALSE(parsedRecords.records.front().apps[0].running); + EXPECT_TRUE(parsedRecords.records.front().apps[1].hidden); + EXPECT_TRUE(parsedRecords.records.front().apps[1].running); + } + + TEST(HostRecordsTest, PercentEncodesCachedAppFieldsThatContainSeparators) { + app::HostRecord host { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + }; + host.apps = { + {"Steam, Desktop|HDR", 101, true, true, true, "cover/key,101|hdr", true, false}, + }; + + const std::string serialized = app::serialize_host_records({host}); + const app::ParseHostRecordsResult parsedRecords = app::parse_host_records(serialized); + + ASSERT_TRUE(parsedRecords.errors.empty()); + ASSERT_EQ(parsedRecords.records.size(), 1U); + ASSERT_EQ(parsedRecords.records.front().apps.size(), 1U); + EXPECT_EQ(parsedRecords.records.front().apps.front().name, "Steam, Desktop|HDR"); + EXPECT_EQ(parsedRecords.records.front().apps.front().boxArtCacheKey, "cover/key,101|hdr"); + EXPECT_TRUE(parsedRecords.records.front().apps.front().hidden); + EXPECT_TRUE(parsedRecords.records.front().apps.front().favorite); + EXPECT_TRUE(parsedRecords.records.front().apps.front().boxArtCached); + } + + TEST(HostRecordsTest, ReportsMalformedSerializedLinesWithoutDroppingValidRecords) { + const std::string serializedRecords = + "Living Room PC\t192.168.1.20\t\tpaired\t0,0,0,0\t\n" + "Broken Host\tnot-an-ip\t\tnot_paired\t0,0,0,0\t\n" + "Bad Format\n" + "Office PC\t10.0.0.25\t48000\tnot_paired\t0,0,0,0\t\n"; + + const app::ParseHostRecordsResult parsedRecords = app::parse_host_records(serializedRecords); + + ASSERT_EQ(parsedRecords.records.size(), 2U); + EXPECT_EQ(parsedRecords.records[0].address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(parsedRecords.records[0].port, 0); + EXPECT_EQ(parsedRecords.records[1].address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(parsedRecords.records[1].port, test_support::kTestPorts[test_support::kPortDefaultHost]); + ASSERT_EQ(parsedRecords.errors.size(), 2U); + EXPECT_NE(parsedRecords.errors[0].find("Line 2"), std::string::npos); + EXPECT_NE(parsedRecords.errors[1].find("Line 3"), std::string::npos); + } + + TEST(HostRecordsTest, DetectsDuplicateSavedAddresses) { + const std::vector records = { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, + }; + + EXPECT_TRUE(app::contains_host_address(records, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom])); + EXPECT_FALSE(app::contains_host_address(records, test_support::kTestIpv4Addresses[test_support::kIpLivingRoomNeighbor])); + EXPECT_FALSE(app::contains_host_address(records, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortDefaultHost])); + } + + TEST(HostRecordsTest, MatchesHostEndpointsAgainstResolvedHttpAndHttpsPorts) { + app::HostRecord host { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + }; + host.resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + host.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + + EXPECT_TRUE(app::host_matches_endpoint(host, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost])); + EXPECT_TRUE(app::host_matches_endpoint(host, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttp])); + EXPECT_TRUE(app::host_matches_endpoint(host, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttps])); + EXPECT_FALSE(app::host_matches_endpoint(host, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortPairing])); + } + + TEST(HostRecordsTest, ParsesPortOverridesAndFallsBackToTheDefaultPort) { + uint16_t parsedPort = 0; + + EXPECT_TRUE(app::try_parse_host_port({}, &parsedPort)); + EXPECT_EQ(parsedPort, 0); + EXPECT_EQ(app::effective_host_port(parsedPort), app::DEFAULT_HOST_PORT); + + EXPECT_TRUE(app::try_parse_host_port("48000", &parsedPort)); + EXPECT_EQ(parsedPort, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_EQ(app::effective_host_port(parsedPort), test_support::kTestPorts[test_support::kPortDefaultHost]); + + EXPECT_FALSE(app::try_parse_host_port("0", &parsedPort)); + EXPECT_FALSE(app::try_parse_host_port("70000", &parsedPort)); + EXPECT_FALSE(app::try_parse_host_port("47a89", &parsedPort)); + } + + TEST(HostRecordsTest, ReturnsStableEnumNames) { + EXPECT_STREQ(app::to_string(app::PairingState::not_paired), "not_paired"); + EXPECT_STREQ(app::to_string(app::PairingState::paired), "paired"); + EXPECT_STREQ(app::to_string(app::HostReachability::unknown), "unknown"); + EXPECT_STREQ(app::to_string(app::HostReachability::online), "online"); + EXPECT_STREQ(app::to_string(app::HostReachability::offline), "offline"); + } + +} // namespace diff --git a/tests/unit/app/pairing_flow_test.cpp b/tests/unit/app/pairing_flow_test.cpp new file mode 100644 index 0000000..a88f008 --- /dev/null +++ b/tests/unit/app/pairing_flow_test.cpp @@ -0,0 +1,39 @@ +/** + * @file tests/unit/app/pairing_flow_test.cpp + * @brief Verifies the host pairing flow. + */ +// class header include +#include "src/app/pairing_flow.h" + +// lib includes +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + TEST(PairingFlowTest, CreatesAFreshPairingDraftWithTheDefaultPin) { + const app::PairingDraft draft = app::create_pairing_draft( + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + "4821" + ); + + EXPECT_EQ(draft.targetAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + EXPECT_EQ(draft.targetPort, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_EQ(draft.stage, app::PairingStage::pin_ready); + EXPECT_EQ(draft.generatedPin, "4821"); + EXPECT_EQ(draft.statusMessage, "Enter the PIN on the host. Pairing will continue automatically."); + } + + TEST(PairingFlowTest, AcceptsOnlyFourDigitPins) { + EXPECT_TRUE(app::is_valid_pairing_pin("1234")); + EXPECT_TRUE(app::is_valid_pairing_pin("0007")); + + EXPECT_FALSE(app::is_valid_pairing_pin("123")); + EXPECT_FALSE(app::is_valid_pairing_pin("12345")); + EXPECT_FALSE(app::is_valid_pairing_pin("12a4")); + } + +} // namespace diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp new file mode 100644 index 0000000..ca87480 --- /dev/null +++ b/tests/unit/app/settings_storage_test.cpp @@ -0,0 +1,179 @@ +/** + * @file tests/unit/app/settings_storage_test.cpp + * @brief Verifies application settings persistence. + */ +// test header include +#include "src/app/settings_storage.h" + +// standard includes +#include +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" + +namespace { + + void write_text_file(const std::string &path, std::string_view content) { + FILE *file = std::fopen(path.c_str(), "wb"); + ASSERT_NE(file, nullptr); + ASSERT_EQ(std::fwrite(content.data(), 1, content.size(), file), content.size()); + ASSERT_EQ(std::fclose(file), 0); + } + + class SettingsStorageTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest + protected: + std::string testDirectory = "settings-storage-test"; + std::string settingsPath = test_support::join_path(testDirectory, "moonlight.toml"); + + void SetUp() override { + ASSERT_TRUE(test_support::create_directory(testDirectory)); + } + + void TearDown() override { + test_support::remove_if_present(settingsPath); + test_support::remove_directory_if_present(testDirectory); + } + }; + + TEST_F(SettingsStorageTest, SavesAndLoadsTomlSettings) { + const app::AppSettings savedSettings { + logging::LogLevel::debug, + logging::LogLevel::warning, + app::LogViewerPlacement::left, + }; + + const app::SaveAppSettingsResult saveResult = app::save_app_settings(savedSettings, settingsPath); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::debug); + EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::warning); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::left); + EXPECT_FALSE(loadResult.cleanupRequired); + } + + TEST_F(SettingsStorageTest, MissingFilesReturnDefaultsWithoutWarnings) { + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + + EXPECT_FALSE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_FALSE(loadResult.cleanupRequired); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + } + + TEST_F(SettingsStorageTest, InvalidValuesFallBackToDefaultsWithWarnings) { + write_text_file( + settingsPath, + "[logging]\n" + "file_minimum_level = \"loud\"\n" + "xemu_console_minimum_level = \"chatty\"\n\n" + "[debug]\n" + "startup_console_enabled = \"sometimes\"\n\n" + "[ui]\n" + "log_viewer_placement = \"top\"\n" + ); + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_GE(loadResult.warnings.size(), 3U); + EXPECT_TRUE(loadResult.cleanupRequired); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + } + + TEST_F(SettingsStorageTest, LegacyLoggingKeyLoadsAndRequestsCleanup) { + write_text_file( + settingsPath, + "[logging]\n" + "minimum_level = \"error\"\n\n" + "[ui]\n" + "log_viewer_placement = \"right\"\n" + ); + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.cleanupRequired); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::error); + EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::right); + } + + TEST_F(SettingsStorageTest, AcceptsMixedCaseLoggingValuesAndWarnAlias) { + write_text_file( + settingsPath, + "[logging]\n" + "file_minimum_level = \"DeBuG\"\n" + "xemu_console_minimum_level = \"WARN\"\n\n" + "[ui]\n" + "log_viewer_placement = \"RIGHT\"\n" + ); + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::debug); + EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::warning); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::right); + } + + TEST_F(SettingsStorageTest, MarksUnknownKeysAndLegacySectionsForCleanup) { + write_text_file( + settingsPath, + "[logging]\n" + "file_minimum_level = \"info\"\n" + "obsolete_key = true\n\n" + "[ui]\n" + "log_viewer_placement = \"left\"\n" + "theme = \"green\"\n\n" + "[debug]\n" + "startup_console_enabled = true\n\n" + "[other]\n" + "value = 1\n" + ); + + const app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.cleanupRequired); + EXPECT_GE(loadResult.warnings.size(), 4U); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::info); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::left); + } + + TEST_F(SettingsStorageTest, ReportsParseAndTypeErrorsAsWarnings) { + write_text_file( + settingsPath, + "[logging]\n" + "file_minimum_level = 7\n" + "xemu_console_minimum_level = false\n\n" + "[ui]\n" + "log_viewer_placement = 42\n" + ); + + app::LoadAppSettingsResult loadResult = app::load_app_settings(settingsPath); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_GE(loadResult.warnings.size(), 3U); + EXPECT_EQ(loadResult.settings.loggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.xemuConsoleLoggingLevel, logging::LogLevel::none); + EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); + + write_text_file(settingsPath, "[logging\nfile_minimum_level = \"info\"\n"); + loadResult = app::load_app_settings(settingsPath); + EXPECT_TRUE(loadResult.fileFound); + ASSERT_FALSE(loadResult.warnings.empty()); + EXPECT_NE(loadResult.warnings.front().find("Failed to parse settings file"), std::string::npos); + } + +} // namespace diff --git a/tests/unit/input/navigation_input_test.cpp b/tests/unit/input/navigation_input_test.cpp new file mode 100644 index 0000000..b425235 --- /dev/null +++ b/tests/unit/input/navigation_input_test.cpp @@ -0,0 +1,57 @@ +/** + * @file tests/unit/input/navigation_input_test.cpp + * @brief Verifies controller navigation input handling. + */ +// class header include +#include "src/input/navigation_input.h" + +// lib includes +#include + +namespace { + + TEST(NavigationInputTest, MapsControllerButtonsToNavigationCommands) { + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_up), input::UiCommand::move_up); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_down), input::UiCommand::move_down); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_left), input::UiCommand::move_left); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::dpad_right), input::UiCommand::move_right); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::a), input::UiCommand::activate); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::b), input::UiCommand::back); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::back), input::UiCommand::back); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::x), input::UiCommand::delete_character); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::start), input::UiCommand::confirm); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::left_shoulder), input::UiCommand::previous_page); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::right_shoulder), input::UiCommand::next_page); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::y), input::UiCommand::open_context_menu); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(static_cast(999)), input::UiCommand::none); + } + + TEST(NavigationInputTest, MapsControllerAxisDirectionsToNavigationCommands) { + EXPECT_EQ(input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_up), input::UiCommand::move_up); + EXPECT_EQ(input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_down), input::UiCommand::move_down); + EXPECT_EQ(input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_left), input::UiCommand::move_left); + EXPECT_EQ(input::map_gamepad_axis_direction_to_ui_command(input::GamepadAxisDirection::left_stick_right), input::UiCommand::move_right); + EXPECT_EQ(input::map_gamepad_axis_direction_to_ui_command(static_cast(999)), input::UiCommand::none); + } + + TEST(NavigationInputTest, MapsKeyboardKeysToNavigationCommands) { + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::up), input::UiCommand::move_up); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::down), input::UiCommand::move_down); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::left), input::UiCommand::move_left); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::right), input::UiCommand::move_right); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::enter), input::UiCommand::confirm); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::escape), input::UiCommand::back); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::backspace), input::UiCommand::delete_character); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::delete_key), input::UiCommand::delete_character); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::space), input::UiCommand::activate); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::tab, false), input::UiCommand::next_page); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::tab, true), input::UiCommand::previous_page); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::page_up), input::UiCommand::previous_page); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::page_down), input::UiCommand::next_page); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::i), input::UiCommand::open_context_menu); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::m), input::UiCommand::open_context_menu); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(input::KeyboardKey::f3), input::UiCommand::toggle_overlay); + EXPECT_EQ(input::map_keyboard_key_to_ui_command(static_cast(999)), input::UiCommand::none); + } + +} // namespace diff --git a/tests/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp new file mode 100644 index 0000000..414459d --- /dev/null +++ b/tests/unit/logging/log_file_test.cpp @@ -0,0 +1,144 @@ +/** + * @file tests/unit/logging/log_file_test.cpp + * @brief Verifies log file lifecycle helpers. + */ +// test header include +#include "src/logging/log_file.h" + +// standard includes +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" + +namespace { + + void write_raw_text_file(const std::string &path, std::string_view content) { + FILE *file = std::fopen(path.c_str(), "wb"); + ASSERT_NE(file, nullptr); + ASSERT_EQ(std::fwrite(content.data(), 1, content.size(), file), content.size()); + ASSERT_EQ(std::fclose(file), 0); + } + + std::string test_log_file_path(const char *name) { + return test_support::join_path(test_support::join_path("test-output", "logging"), name); + } + + TEST(LogFileTest, AppendsEntriesAndLoadsRecentLines) { + const std::string filePath = test_log_file_path("moonlight.log"); + std::remove(filePath.c_str()); + + ASSERT_TRUE(logging::append_log_file_entry({1, logging::LogLevel::info, "app", "first", {2026, 4, 5, 13, 0, 1, 234}}, filePath)); + ASSERT_TRUE(logging::append_log_file_entry({2, logging::LogLevel::warning, "net", "second", {2026, 4, 5, 13, 0, 2, 345}}, filePath)); + ASSERT_TRUE(logging::append_log_file_entry({3, logging::LogLevel::error, "ui", "third", {2026, 4, 5, 13, 0, 3, 456}}, filePath)); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 2U); + ASSERT_TRUE(loadedLog.fileFound); + EXPECT_TRUE(loadedLog.errorMessage.empty()); + ASSERT_EQ(loadedLog.lines.size(), 2U); + EXPECT_EQ(loadedLog.lines[0], "[2026-04-05 13:00:02.345] [WARN] net: second"); + EXPECT_EQ(loadedLog.lines[1], "[2026-04-05 13:00:03.456] [ERROR] ui: third"); + } + + TEST(LogFileTest, MissingFilesReturnNoError) { + const std::string filePath = test_log_file_path("missing.log"); + std::remove(filePath.c_str()); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 8U); + EXPECT_FALSE(loadedLog.fileFound); + EXPECT_TRUE(loadedLog.lines.empty()); + EXPECT_TRUE(loadedLog.errorMessage.empty()); + } + + TEST(LogFileTest, ResetLogFileTruncatesExistingContents) { + const std::string filePath = test_log_file_path("reset.log"); + std::remove(filePath.c_str()); + + ASSERT_TRUE(logging::append_log_file_entry({1, logging::LogLevel::info, "app", "first", {2026, 4, 5, 13, 0, 1, 0}}, filePath)); + ASSERT_TRUE(logging::append_log_file_entry({2, logging::LogLevel::warning, "ui", "second", {2026, 4, 5, 13, 0, 2, 0}}, filePath)); + ASSERT_TRUE(logging::reset_log_file(filePath)); + ASSERT_TRUE(logging::append_log_file_entry({3, logging::LogLevel::error, "net", "fresh", {2026, 4, 5, 13, 0, 3, 0}}, filePath)); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 0U); + ASSERT_TRUE(loadedLog.fileFound); + ASSERT_EQ(loadedLog.lines.size(), 1U); + EXPECT_EQ(loadedLog.lines.front(), "[2026-04-05 13:00:03.000] [ERROR] net: fresh"); + } + + TEST(LogFileTest, LoadsLongPhysicalLinesWithoutSplittingThemIntoFragments) { + const std::string filePath = test_log_file_path("long-lines.log"); + std::remove(filePath.c_str()); + + const std::string longMessage(1600U, 'x'); + ASSERT_TRUE(logging::append_log_file_entry({1, logging::LogLevel::info, "app", longMessage, {2026, 4, 5, 13, 0, 43, 210}}, filePath)); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 0U); + ASSERT_TRUE(loadedLog.fileFound); + ASSERT_EQ(loadedLog.lines.size(), 1U); + EXPECT_EQ(loadedLog.lines.front(), std::string("[2026-04-05 13:00:43.210] [INFO] app: ") + longMessage); + } + + TEST(LogFileTest, RuntimeLogFileSinkCanResetTheTargetFileExplicitly) { + const std::string filePath = test_log_file_path("runtime-reset.log"); + std::remove(filePath.c_str()); + + ASSERT_TRUE(logging::append_log_file_entry({1, logging::LogLevel::warning, "app", "stale", {2026, 4, 5, 13, 0, 1, 0}}, filePath)); + + const logging::RuntimeLogFileSink sink(filePath); + ASSERT_TRUE(sink.reset()); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 0U); + ASSERT_TRUE(loadedLog.fileFound); + EXPECT_TRUE(loadedLog.lines.empty()); + } + + TEST(LogFileTest, RuntimeLogFileSinkAppendsAcceptedEntriesToItsConfiguredFile) { + const std::string filePath = test_log_file_path("runtime-sink.log"); + std::remove(filePath.c_str()); + + logging::RuntimeLogFileSink sink(filePath); + ASSERT_TRUE(sink.consume({2, logging::LogLevel::info, "app", "fresh", {2026, 4, 5, 13, 0, 2, 0}})); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 0U); + ASSERT_TRUE(loadedLog.fileFound); + ASSERT_EQ(loadedLog.lines.size(), 1U); + EXPECT_EQ(loadedLog.lines.front(), "[2026-04-05 13:00:02.000] [INFO] app: fresh"); + } + + TEST(LogFileTest, PersistsSourceLocationsWhenEntriesProvideThem) { + const std::string filePath = test_log_file_path("source-location.log"); + std::remove(filePath.c_str()); + + ASSERT_TRUE(logging::append_log_file_entry({4, logging::LogLevel::info, "app", "with location", {2026, 4, 5, 13, 0, 4, 0}, {"C:\\repo\\Moonlight-XboxOG\\src\\main.cpp", 241}}, filePath)); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 0U); + ASSERT_TRUE(loadedLog.fileFound); + ASSERT_EQ(loadedLog.lines.size(), 1U); + EXPECT_EQ(loadedLog.lines.front(), "[2026-04-05 13:00:04.000] [INFO] [src/main.cpp:241] app: with location"); + } + + TEST(LogFileTest, LoadsTheFinalLineEvenWhenTheFileDoesNotEndWithANewline) { + const std::string filePath = test_log_file_path("unterminated.log"); + std::remove(filePath.c_str()); + ASSERT_TRUE(test_support::create_directory(test_support::join_path("test-output", "logging"))); + write_raw_text_file(filePath, "first line\r\nsecond line\r\nthird line"); + + const logging::LoadLogFileResult loadedLog = logging::load_log_file(filePath, 0U); + + ASSERT_TRUE(loadedLog.fileFound); + ASSERT_EQ(loadedLog.lines.size(), 3U); + EXPECT_EQ(loadedLog.lines.back(), "third line"); + } + + TEST(LogFileTest, RuntimeLogFileSinkExposesItsConfiguredPath) { + const std::string filePath = test_log_file_path("accessor.log"); + const logging::RuntimeLogFileSink sink(filePath); + + EXPECT_EQ(sink.file_path(), filePath); + } + +} // namespace diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp new file mode 100644 index 0000000..44d5c8d --- /dev/null +++ b/tests/unit/logging/logger_test.cpp @@ -0,0 +1,250 @@ +/** + * @file tests/unit/logging/logger_test.cpp + * @brief Verifies logging configuration and output. + */ +// test header include +#include "src/logging/logger.h" + +// standard includes +#include +#include + +// lib includes +#include + +namespace { + + TEST(LoggerTest, FormatsWallClockTimestampsWithDateAndMilliseconds) { + EXPECT_EQ(logging::format_timestamp({2026, 4, 5, 13, 7, 9, 42}), "2026-04-05 13:07:09.042"); + EXPECT_EQ(logging::format_timestamp({}), "0000-00-00 00:00:00.000"); + } + + TEST(LoggerTest, FormatsTheNoneLogLevelLabel) { + EXPECT_STREQ(logging::to_string(logging::LogLevel::none), "NONE"); + } + + TEST(LoggerTest, FormatsSourceLocationsWithCppExtensions) { + EXPECT_EQ(logging::format_source_location({"C:\\repo\\Moonlight-XboxOG\\src\\main.cpp", 241}), "src/main.cpp:241"); + EXPECT_EQ(logging::format_source_location({"C:\\repo\\Moonlight-XboxOG\\tests\\unit\\logging\\logger_test.cpp", 19}), "tests/unit/logging/logger_test.cpp:19"); + } + + TEST(LoggerTest, FormatsSourceLocationsAndEntriesWhenOnlyBasenamesAreAvailable) { + EXPECT_TRUE(logging::format_source_location({}).empty()); + EXPECT_EQ(logging::format_source_location({"C:\\temp\\standalone_file.cpp", 8}), "standalone_file.cpp:8"); + EXPECT_EQ(logging::format_entry({1, logging::LogLevel::warning, {}, "plain message", {2026, 4, 5, 13, 7, 9, 42}, {}}), "[WARN] plain message"); + } + + TEST(LoggerTest, StoresEntriesAboveTheConfiguredMinimumLevel) { + logging::Logger logger(4, []() { + return logging::LogTimestamp {2026, 4, 5, 13, 7, 9, 42}; + }); + logger.set_minimum_level(logging::LogLevel::debug); + logger.set_startup_debug_enabled(false); + + EXPECT_FALSE(logger.log(logging::LogLevel::trace, "streaming", "ignored")); + EXPECT_TRUE(logger.log(logging::LogLevel::debug, "streaming", "accepted")); + EXPECT_TRUE(logger.log(logging::LogLevel::error, "network", "failed")); + + ASSERT_EQ(logger.entries().size(), 2U); + EXPECT_EQ(logger.entries().front().sequence, 1U); + EXPECT_EQ(logger.entries().back().sequence, 2U); + EXPECT_EQ(logger.entries().front().category, "streaming"); + EXPECT_EQ(logger.entries().back().message, "failed"); + EXPECT_EQ(logger.entries().front().timestamp.year, 2026); + EXPECT_EQ(logger.entries().front().timestamp.month, 4); + EXPECT_EQ(logger.entries().front().timestamp.day, 5); + EXPECT_EQ(logger.entries().front().timestamp.hour, 13); + EXPECT_EQ(logger.entries().front().timestamp.minute, 7); + EXPECT_EQ(logger.entries().front().timestamp.second, 9); + EXPECT_EQ(logger.entries().front().timestamp.millisecond, 42); + } + + TEST(LoggerTest, DropsTheOldestEntriesWhenCapacityIsReached) { + logging::Logger logger(2); + logger.set_minimum_level(logging::LogLevel::info); + logger.set_startup_debug_enabled(false); + + EXPECT_TRUE(logger.log(logging::LogLevel::info, "app", "first")); + EXPECT_TRUE(logger.log(logging::LogLevel::warning, "app", "second")); + EXPECT_TRUE(logger.log(logging::LogLevel::error, "app", "third")); + + ASSERT_EQ(logger.entries().size(), 2U); + EXPECT_EQ(logger.entries().front().message, "second"); + EXPECT_EQ(logger.entries().back().message, "third"); + } + + TEST(LoggerTest, InvokesRegisteredSinksForAcceptedEntries) { + logging::Logger logger; + std::vector seenMessages; + + logger.set_minimum_level(logging::LogLevel::info); + logger.set_startup_debug_enabled(false); + + logger.add_sink([&seenMessages](const logging::LogEntry &entry) { + seenMessages.push_back(logging::format_entry(entry)); + }); + + const int expectedLine = __LINE__ + 1; + EXPECT_TRUE(logger.log(logging::LogLevel::info, "ui", "opened")); + + ASSERT_EQ(seenMessages.size(), 1U); + EXPECT_EQ(seenMessages.front(), "[INFO] [tests/unit/logging/logger_test.cpp:" + std::to_string(expectedLine) + "] ui: opened"); + } + + TEST(LoggerTest, LoggerMethodsCaptureTheCallsiteLocationWithoutMacros) { + logging::Logger localLogger; + localLogger.set_minimum_level(logging::LogLevel::info); + localLogger.set_startup_debug_enabled(false); + + const int expectedLine = __LINE__ + 1; + EXPECT_TRUE(localLogger.info("ui", "opened")); + + ASSERT_EQ(localLogger.entries().size(), 1U); + EXPECT_EQ(localLogger.entries().front().sourceLocation.line, expectedLine); + ASSERT_NE(localLogger.entries().front().sourceLocation.file, nullptr); + EXPECT_NE(std::string(localLogger.entries().front().sourceLocation.file).find("logger_test.cpp"), std::string::npos); + EXPECT_EQ(logging::format_entry(localLogger.entries().front()), "[INFO] [tests/unit/logging/logger_test.cpp:" + std::to_string(expectedLine) + "] ui: opened"); + } + + TEST(LoggerTest, NamespaceLevelGlobalLoggingCapturesTheCallsiteLocation) { + logging::Logger localLogger; + localLogger.set_minimum_level(logging::LogLevel::info); + localLogger.set_startup_debug_enabled(false); + logging::set_global_logger(&localLogger); + + const int expectedLine = __LINE__ + 1; + EXPECT_TRUE(logging::info("ui", "opened globally")); + + ASSERT_EQ(localLogger.entries().size(), 1U); + EXPECT_EQ(localLogger.entries().front().sourceLocation.line, expectedLine); + ASSERT_NE(localLogger.entries().front().sourceLocation.file, nullptr); + EXPECT_NE(std::string(localLogger.entries().front().sourceLocation.file).find("logger_test.cpp"), std::string::npos); + logging::set_global_logger(nullptr); + } + + TEST(LoggerTest, NamespaceLevelGlobalLoggingReturnsFalseWhenNoLoggerIsRegistered) { + logging::set_global_logger(nullptr); + + EXPECT_FALSE(logging::info("ui", "ignored")); + } + + TEST(LoggerTest, NamespaceLevelHelpersForwardConfigurationToTheRegisteredLogger) { + logging::Logger logger(3, []() { + return logging::LogTimestamp {2026, 4, 5, 13, 7, 9, 42}; + }); + std::vector fileMessages; + + logging::set_global_logger(&logger); + logging::set_minimum_level(logging::LogLevel::warning); + logging::set_file_sink([&fileMessages](const logging::LogEntry &entry) { + fileMessages.push_back(logging::format_entry(entry)); + }); + logging::set_file_minimum_level(logging::LogLevel::info); + logging::set_debugger_console_minimum_level(logging::LogLevel::error); + logging::set_startup_debug_enabled(false); + + EXPECT_EQ(logger.minimum_level(), logging::LogLevel::warning); + EXPECT_EQ(logger.file_minimum_level(), logging::LogLevel::info); + EXPECT_EQ(logger.debugger_console_minimum_level(), logging::LogLevel::error); + EXPECT_FALSE(logger.startup_debug_enabled()); + EXPECT_TRUE(logging::has_global_logger()); + + EXPECT_TRUE(logging::warn("ui", "retained")); + EXPECT_EQ(logging::snapshot(logging::LogLevel::warning).size(), 1U); + ASSERT_EQ(fileMessages.size(), 1U); + EXPECT_EQ(fileMessages.front(), logging::format_entry(logger.entries().front())); + + logging::set_global_logger(nullptr); + EXPECT_FALSE(logging::has_global_logger()); + } + + TEST(LoggerTest, DispatchesTheDedicatedRuntimeFileSinkIndependentlyFromTheRetainedBufferLevel) { + logging::Logger logger; + std::vector fileMessages; + + logger.set_minimum_level(logging::LogLevel::none); + logger.set_startup_debug_enabled(false); + logger.set_file_sink([&fileMessages](const logging::LogEntry &entry) { + fileMessages.push_back(logging::format_entry(entry)); + }); + logger.set_file_minimum_level(logging::LogLevel::warning); + + const int expectedLine = __LINE__ + 2; + EXPECT_FALSE(logger.log(logging::LogLevel::info, "ui", "ignored")); + EXPECT_TRUE(logger.log(logging::LogLevel::warning, "ui", "written")); + + EXPECT_TRUE(logger.entries().empty()); + ASSERT_EQ(fileMessages.size(), 1U); + EXPECT_EQ(fileMessages.front(), "[WARN] [tests/unit/logging/logger_test.cpp:" + std::to_string(expectedLine) + "] ui: written"); + } + + TEST(LoggerTest, AdditionalSinksHonorTheirOwnMinimumLevels) { + logging::Logger logger; + std::vector seenMessages; + logger.set_minimum_level(logging::LogLevel::none); + logger.set_startup_debug_enabled(false); + + logger.add_sink([&seenMessages](const logging::LogEntry &entry) { + seenMessages.push_back(entry.message); + }, + logging::LogLevel::error); + + EXPECT_FALSE(logger.log(logging::LogLevel::warning, "ui", "ignored")); + EXPECT_TRUE(logger.log(logging::LogLevel::error, "ui", "accepted")); + ASSERT_EQ(seenMessages.size(), 1U); + EXPECT_EQ(seenMessages.front(), "accepted"); + } + + TEST(LoggerTest, SnapshotFiltersByMinimumLevel) { + logging::Logger logger; + logger.set_minimum_level(logging::LogLevel::trace); + logger.set_startup_debug_enabled(false); + + EXPECT_TRUE(logger.log(logging::LogLevel::info, "ui", "info")); + EXPECT_TRUE(logger.log(logging::LogLevel::warning, "ui", "warning")); + EXPECT_TRUE(logger.log(logging::LogLevel::error, "ui", "error")); + + const std::vector snapshot = logger.snapshot(logging::LogLevel::warning); + + ASSERT_EQ(snapshot.size(), 2U); + EXPECT_EQ(snapshot.front().level, logging::LogLevel::warning); + EXPECT_EQ(snapshot.back().level, logging::LogLevel::error); + } + + TEST(LoggerTest, DefaultsToSuppressingRuntimeLogsUntilALevelIsSelected) { + logging::Logger logger; + logger.set_startup_debug_enabled(false); + + EXPECT_EQ(logger.minimum_level(), logging::LogLevel::none); + EXPECT_FALSE(logger.should_log(logging::LogLevel::error)); + EXPECT_FALSE(logger.log(logging::LogLevel::error, "app", "suppressed")); + EXPECT_TRUE(logger.entries().empty()); + } + + TEST(LoggerTest, CapacityZeroFallsBackToASingleRetainedEntry) { + logging::Logger logger(0, []() { + return logging::LogTimestamp {2026, 4, 5, 13, 7, 9, 42}; + }); + logger.set_minimum_level(logging::LogLevel::info); + logger.set_startup_debug_enabled(false); + + EXPECT_EQ(logger.capacity(), 1U); + EXPECT_TRUE(logger.info("app", "first")); + EXPECT_TRUE(logger.error("app", "second")); + ASSERT_EQ(logger.entries().size(), 1U); + EXPECT_EQ(logger.entries().front().message, "second"); + } + + TEST(LoggerTest, StartupConsoleHelpersPreserveLabelsAndCanBeToggled) { + logging::set_startup_console_enabled(true); + EXPECT_TRUE(logging::startup_console_enabled()); + EXPECT_EQ(logging::format_startup_console_line(logging::LogLevel::none, {}, "booting"), "[INFO] booting"); + EXPECT_EQ(logging::format_startup_console_line(logging::LogLevel::warning, "network", "offline"), "[WARN] network: offline"); + + logging::set_startup_console_enabled(false); + EXPECT_FALSE(logging::startup_console_enabled()); + logging::print_startup_console_line(logging::LogLevel::info, "app", "muted in host tests"); + logging::set_startup_console_enabled(true); + } + +} // namespace diff --git a/tests/unit/logging/startup_debug_test.cpp b/tests/unit/logging/startup_debug_test.cpp new file mode 100644 index 0000000..0db9a75 --- /dev/null +++ b/tests/unit/logging/startup_debug_test.cpp @@ -0,0 +1,34 @@ +/** + * @file tests/unit/logging/startup_debug_test.cpp + * @brief Verifies startup debug logging. + */ +// test header include +#include "src/logging/logger.h" + +// lib includes +#include + +namespace { + + TEST(StartupDebugTest, FormatsStructuredStartupConsoleLines) { + EXPECT_EQ( + logging::format_startup_console_line(logging::LogLevel::warning, "network", "Runtime networking is unavailable"), + "[WARN] network: Runtime networking is unavailable" + ); + EXPECT_EQ( + logging::format_startup_console_line(logging::LogLevel::info, "memory", "Total physical memory: 64 MiB (16384 pages)"), + "[INFO] memory: Total physical memory: 64 MiB (16384 pages)" + ); + } + + TEST(StartupDebugTest, CanDisableStartupConsoleEmissionGlobally) { + logging::set_startup_console_enabled(true); + EXPECT_TRUE(logging::startup_console_enabled()); + + logging::set_startup_console_enabled(false); + EXPECT_FALSE(logging::startup_console_enabled()); + + logging::set_startup_console_enabled(true); + } + +} // namespace diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp new file mode 100644 index 0000000..e56f946 --- /dev/null +++ b/tests/unit/network/host_pairing_test.cpp @@ -0,0 +1,1127 @@ +/** + * @file tests/unit/network/host_pairing_test.cpp + * @brief Verifies host pairing helpers. + */ +// test header include +#include "src/network/host_pairing.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + using network::testing::HostPairingHttpTestRequest; + using network::testing::HostPairingHttpTestResponse; + + constexpr std::string_view kUnpairedClientErrorMessage = "The host reports that this client is no longer paired. Pair the host again."; + + class ScopedHostPairingHttpTestHandler { + public: + explicit ScopedHostPairingHttpTestHandler(network::testing::HostPairingHttpTestHandler handler) { + network::testing::set_host_pairing_http_test_handler(std::move(handler)); + } + + ~ScopedHostPairingHttpTestHandler() { + network::testing::clear_host_pairing_http_test_handler(); + } + + ScopedHostPairingHttpTestHandler(const ScopedHostPairingHttpTestHandler &) = delete; + ScopedHostPairingHttpTestHandler &operator=(const ScopedHostPairingHttpTestHandler &) = delete; + }; + + struct ScriptedHttpExchange { + std::function validateRequest; + bool success = true; + int statusCode = 200; + std::string body; + std::string errorMessage; + }; + + class ScriptedHostPairingHttpHandler { + public: + explicit ScriptedHostPairingHttpHandler(std::vector exchanges): + exchanges_(std::move(exchanges)) { + } + + bool operator()(const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *) { + if (index_ >= exchanges_.size()) { + ADD_FAILURE() << "Unexpected host_pairing HTTP request: " << request.pathAndQuery; + if (errorMessage != nullptr) { + *errorMessage = "Unexpected host_pairing HTTP request"; + } + return false; + } + + const ScriptedHttpExchange &exchange = exchanges_[index_]; + ++index_; + if (exchange.validateRequest) { + exchange.validateRequest(request); + } + if (!exchange.success) { + if (errorMessage != nullptr) { + *errorMessage = exchange.errorMessage; + } + return false; + } + + if (response != nullptr) { + response->statusCode = exchange.statusCode; + response->body = exchange.body; + } + if (errorMessage != nullptr) { + errorMessage->clear(); + } + return true; + } + + [[nodiscard]] bool all_consumed() const { + return index_ == exchanges_.size(); + } + + private: + std::vector exchanges_; + std::size_t index_ = 0U; + }; + + template + network::testing::HostPairingHttpTestHandler make_host_pairing_http_test_handler(Handler *handler) { + return [handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return (*handler)(request, response, errorMessage, cancelRequested); + }; + } + + struct PkeyDeleter { + void operator()(EVP_PKEY *key) const { + if (key != nullptr) { + EVP_PKEY_free(key); + } + } + }; + + struct BioDeleter { + void operator()(BIO *bio) const { + if (bio != nullptr) { + BIO_free(bio); + } + } + }; + + std::string extract_query_parameter(std::string_view pathAndQuery, std::string_view parameterName) { + const std::string needle = std::string(parameterName) + "="; + const std::size_t parameterStart = pathAndQuery.find(needle); + if (parameterStart == std::string_view::npos) { + return {}; + } + + const std::size_t valueStart = parameterStart + needle.size(); + const std::size_t valueEnd = pathAndQuery.find('&', valueStart); + return std::string(pathAndQuery.substr(valueStart, valueEnd == std::string_view::npos ? std::string_view::npos : valueEnd - valueStart)); + } + + std::vector to_unsigned_bytes(const std::byte *data, std::size_t size) { + std::vector bytes; + bytes.reserve(size); + for (std::size_t index = 0; index < size; ++index) { + bytes.push_back(std::to_integer(data[index])); + } + return bytes; + } + + std::vector to_std_bytes(const unsigned char *data, std::size_t size) { + std::vector bytes; + bytes.reserve(size); + for (std::size_t index = 0; index < size; ++index) { + bytes.push_back(static_cast(data[index])); + } + return bytes; + } + + std::string hex_encode_bytes(const std::byte *data, std::size_t size) { + static constexpr char kHexDigits[] = "0123456789abcdef"; + + std::string output; + output.reserve(size * 2U); + for (std::size_t index = 0; index < size; ++index) { + const unsigned int byteValue = std::to_integer(data[index]); + output.push_back(kHexDigits[(byteValue >> 4U) & 0x0F]); + output.push_back(kHexDigits[byteValue & 0x0F]); + } + return output; + } + + std::string hex_encode_text(std::string_view text) { + std::string output; + output.reserve(text.size() * 2U); + for (const char character : text) { + const unsigned int byteValue = static_cast(character); + output.push_back("0123456789abcdef"[(byteValue >> 4U) & 0x0F]); + output.push_back("0123456789abcdef"[byteValue & 0x0F]); + } + return output; + } + + std::vector hex_decode_text(std::string_view text) { + std::vector bytes; + EXPECT_EQ(text.size() % 2U, 0U); + bytes.reserve(text.size() / 2U); + for (std::size_t index = 0; index + 1U < text.size(); index += 2U) { + const std::string encodedByte(text.substr(index, 2U)); + unsigned int value = 0U; + const std::from_chars_result decodeResult = std::from_chars(encodedByte.data(), encodedByte.data() + encodedByte.size(), value, 16); + EXPECT_EQ(decodeResult.ec, std::errc()); + EXPECT_EQ(decodeResult.ptr, encodedByte.data() + encodedByte.size()); + bytes.push_back(static_cast(value)); + } + return bytes; + } + + std::vector sha256_digest(const std::byte *data, std::size_t size) { + std::array digestBuffer {}; + unsigned int digestSize = 0U; + const std::vector unsignedData = to_unsigned_bytes(data, size); + EXPECT_EQ(EVP_Digest(unsignedData.data(), unsignedData.size(), digestBuffer.data(), &digestSize, EVP_sha256(), nullptr), 1); + return to_std_bytes(digestBuffer.data(), digestSize); + } + + std::vector derive_pairing_aes_key(std::string_view saltHex, std::string_view pin) { + std::vector source = hex_decode_text(saltHex); + source.reserve(source.size() + pin.size()); + for (const char character : pin) { + source.push_back(static_cast(static_cast(character))); + } + return sha256_digest(source.data(), source.size()); + } + + std::vector aes_128_ecb_encrypt(const std::vector &plaintext, const std::vector &key) { + std::unique_ptr context(EVP_CIPHER_CTX_new(), &EVP_CIPHER_CTX_free); + EXPECT_NE(context, nullptr); + const std::vector unsignedKey = to_unsigned_bytes(key.data(), key.size()); + const std::vector unsignedPlaintext = to_unsigned_bytes(plaintext.data(), plaintext.size()); + EXPECT_EQ(EVP_EncryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, unsignedKey.data(), nullptr), 1); + EXPECT_EQ(EVP_CIPHER_CTX_set_padding(context.get(), 0), 1); + + std::vector ciphertext(plaintext.size() + 16U); + int ciphertextSize = 0; + EXPECT_EQ(EVP_EncryptUpdate(context.get(), ciphertext.data(), &ciphertextSize, unsignedPlaintext.data(), static_cast(unsignedPlaintext.size())), 1); + ciphertext.resize(static_cast(ciphertextSize)); + return to_std_bytes(ciphertext.data(), ciphertext.size()); + } + + std::string sign_sha256_hex(const std::vector &data, std::string_view privateKeyPem) { + std::unique_ptr bio(BIO_new_mem_buf(privateKeyPem.data(), static_cast(privateKeyPem.size()))); + EXPECT_NE(bio, nullptr); + std::unique_ptr privateKey(PEM_read_bio_PrivateKey(bio.get(), nullptr, nullptr, nullptr)); + EXPECT_NE(privateKey, nullptr); + + std::unique_ptr context(EVP_MD_CTX_new(), &EVP_MD_CTX_free); + EXPECT_NE(context, nullptr); + EXPECT_EQ(EVP_DigestSignInit(context.get(), nullptr, EVP_sha256(), nullptr, privateKey.get()), 1); + const std::vector unsignedData = to_unsigned_bytes(data.data(), data.size()); + EXPECT_EQ(EVP_DigestSignUpdate(context.get(), unsignedData.data(), unsignedData.size()), 1); + + std::size_t signatureSize = 0U; + EXPECT_EQ(EVP_DigestSignFinal(context.get(), nullptr, &signatureSize), 1); + std::vector signature(signatureSize); + EXPECT_EQ(EVP_DigestSignFinal(context.get(), signature.data(), &signatureSize), 1); + signature.resize(signatureSize); + return hex_encode_bytes(reinterpret_cast(signature.data()), signature.size()); + } + + std::vector filled_bytes(std::size_t size, std::byte value) { + return std::vector(size, value); + } + + std::vector sequential_bytes(std::size_t size) { + std::vector bytes(size); + for (std::size_t index = 0; index < size; ++index) { + bytes[index] = static_cast(index + 1U); + } + return bytes; + } + + std::string make_server_info_xml(bool paired, uint16_t httpPort, uint16_t httpsPort, std::string_view hostName = "Scripted Host", std::string_view uuid = "scripted-host") { + return "" + std::string(hostName) + "7.1.0.0" + std::string(uuid) + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerLocal]) + "" + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerExternal]) + + "" + std::to_string(httpPort) + "" + std::to_string(httpsPort) + "" + (paired ? "1" : "0") + ""; + } + + std::string make_pair_phase_response(std::string_view pairedValue, std::string_view tagName = {}, std::string_view tagValue = {}) { + std::string response = "" + std::string(pairedValue) + ""; + if (!tagName.empty()) { + response += "<" + std::string(tagName) + ">" + std::string(tagValue) + ""; + } + response += ""; + return response; + } + + bool fail_unexpected_pairing_request(std::string *errorMessage) { + if (errorMessage != nullptr) { + *errorMessage = "Unexpected extra pairing request"; + } + ADD_FAILURE() << "Unexpected extra pairing request"; + return false; + } + + bool handle_short_challenge_pairing_request( + std::size_t *callCount, + std::string *saltHex, + std::string_view pin, + const network::PairingIdentity &serverIdentity, + const HostPairingHttpTestRequest &request, + HostPairingHttpTestResponse *response + ) { + switch ((*callCount)++) { + case 0U: + response->statusCode = 200; + response->body = make_server_info_xml(false, 47989U, 47990U, "Pair Host", "pair-host"); + return true; + case 1U: + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "phrase"), "getservercert"); + *saltHex = extract_query_parameter(request.pathAndQuery, "salt"); + response->statusCode = 200; + response->body = make_pair_phase_response("1", "plaincert", hex_encode_text(serverIdentity.certificatePem)); + return true; + case 2U: + { + const std::vector aesKey = derive_pairing_aes_key(*saltHex, pin); + const std::vector shortPlaintext = filled_bytes(16U, std::byte {0x2A}); + const std::vector encryptedResponse = aes_128_ecb_encrypt(shortPlaintext, aesKey); + response->statusCode = 200; + response->body = make_pair_phase_response("1", "challengeresponse", hex_encode_bytes(encryptedResponse.data(), encryptedResponse.size())); + return true; + } + default: + return fail_unexpected_pairing_request(nullptr); + } + } + + struct SuccessfulPairingScriptContext { + std::size_t *callCount; + std::string *saltHex; + std::string_view pin; + const network::PairingIdentity &clientIdentity; + const network::PairingIdentity &serverIdentity; + }; + + bool handle_successful_pairing_request( + const SuccessfulPairingScriptContext &contextData, + const HostPairingHttpTestRequest &request, + HostPairingHttpTestResponse *response, + std::string *errorMessage + ) { + switch ((*contextData.callCount)++) { + case 0U: + EXPECT_FALSE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + contextData.clientIdentity.uniqueId), std::string::npos); + response->statusCode = 200; + response->body = make_server_info_xml(false, 47989U, 47990U, "Pair Host", "pair-host"); + return true; + case 1U: + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "phrase"), "getservercert"); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "clientcert"), hex_encode_text(contextData.clientIdentity.certificatePem)); + *contextData.saltHex = extract_query_parameter(request.pathAndQuery, "salt"); + response->statusCode = 200; + response->body = make_pair_phase_response("1", "plaincert", hex_encode_text(contextData.serverIdentity.certificatePem)); + return true; + case 2U: + { + const std::vector aesKey = derive_pairing_aes_key(*contextData.saltHex, contextData.pin); + const std::vector challengePlaintext = sequential_bytes(48U); + const std::vector encryptedResponse = aes_128_ecb_encrypt(challengePlaintext, aesKey); + EXPECT_FALSE(extract_query_parameter(request.pathAndQuery, "clientchallenge").empty()); + response->statusCode = 200; + response->body = make_pair_phase_response("1", "challengeresponse", hex_encode_bytes(encryptedResponse.data(), encryptedResponse.size())); + return true; + } + case 3U: + { + const std::vector serverSecret = filled_bytes(16U, std::byte {0x5A}); + EXPECT_FALSE(extract_query_parameter(request.pathAndQuery, "serverchallengeresp").empty()); + response->statusCode = 200; + response->body = make_pair_phase_response("1", "pairingsecret", hex_encode_bytes(serverSecret.data(), serverSecret.size()) + sign_sha256_hex(serverSecret, contextData.serverIdentity.privateKeyPem)); + return true; + } + case 4U: + EXPECT_FALSE(extract_query_parameter(request.pathAndQuery, "clientpairingsecret").empty()); + response->statusCode = 200; + response->body = make_pair_phase_response("1"); + return true; + case 5U: + EXPECT_TRUE(request.useTls); + if (request.tlsClientIdentity == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = "Expected a TLS client identity during pairchallenge"; + } + ADD_FAILURE() << "Expected a TLS client identity during pairchallenge"; + return false; + } + EXPECT_EQ(request.tlsClientIdentity->uniqueId, contextData.clientIdentity.uniqueId); + EXPECT_EQ(request.expectedTlsCertificatePem, contextData.serverIdentity.certificatePem); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "phrase"), "pairchallenge"); + response->statusCode = 200; + response->body = make_pair_phase_response("1"); + return true; + default: + return fail_unexpected_pairing_request(errorMessage); + } + } + + TEST(HostPairingTest, CreatesAValidClientIdentity) { + std::string errorMessage; + const network::PairingIdentity identity = network::create_pairing_identity(&errorMessage); + + EXPECT_TRUE(network::is_valid_pairing_identity(identity)); + EXPECT_TRUE(errorMessage.empty()); + EXPECT_EQ(identity.uniqueId.size(), 16U); + EXPECT_NE(identity.certificatePem.find("BEGIN CERTIFICATE"), std::string::npos); + EXPECT_NE(identity.privateKeyPem.find("BEGIN PRIVATE KEY"), std::string::npos); + } + + TEST(HostPairingTest, GeneratesSecureFourDigitPins) { + for (int attempt = 0; attempt < 32; ++attempt) { + std::string pin; + std::string errorMessage; + + ASSERT_TRUE(network::generate_pairing_pin(&pin, &errorMessage)) << errorMessage; + EXPECT_TRUE(errorMessage.empty()); + ASSERT_EQ(pin.size(), 4U); + for (char character : pin) { + EXPECT_GE(character, '0'); + EXPECT_LE(character, '9'); + } + } + } + + TEST(HostPairingTest, RequiresAnOutputBufferWhenGeneratingPins) { + std::string errorMessage; + + EXPECT_FALSE(network::generate_pairing_pin(nullptr, &errorMessage)); + EXPECT_EQ(errorMessage, "A pairing PIN output buffer is required"); + } + + TEST(HostPairingTest, RejectsIncompletePairingIdentities) { + EXPECT_FALSE(network::is_valid_pairing_identity({})); + EXPECT_FALSE(network::is_valid_pairing_identity({"ABCDEF0123456789", "not a certificate", "not a key"})); + } + + TEST(HostPairingTest, CancelledPairingReturnsImmediatelyBeforeStartingTheHandshake) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + std::atomic cancelRequested {true}; + const network::HostPairingResult result = network::pair_host({ + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], + "1234", + "MoonlightXboxOG", + identity, + }, + &cancelRequested); + + EXPECT_FALSE(result.success); + EXPECT_FALSE(result.alreadyPaired); + EXPECT_EQ(result.message, "Pairing cancelled"); + } + + TEST(HostPairingTest, ParsesServerInfoResponsesForPairing) { + const std::string xml = + "" + "Sunshine-PC" + "7.1.431.0" + "host-uuid-123" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerLocal]) + + "" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpServerExternal]) + + "" + "fe80::1234" + "00:11:22:33:44:55" + "42" + "47989" + "47990" + "1" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.serverMajorVersion, 7); + EXPECT_EQ(serverInfo.httpPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); + EXPECT_EQ(serverInfo.httpsPort, test_support::kTestPorts[test_support::kPortResolvedHttps]); + EXPECT_TRUE(serverInfo.paired); + EXPECT_EQ(serverInfo.hostName, "Sunshine-PC"); + EXPECT_EQ(serverInfo.uuid, "host-uuid-123"); + EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpServerLocal]); + EXPECT_EQ(serverInfo.localAddress, test_support::kTestIpv4Addresses[test_support::kIpServerLocal]); + EXPECT_EQ(serverInfo.remoteAddress, test_support::kTestIpv4Addresses[test_support::kIpServerExternal]); + EXPECT_EQ(serverInfo.ipv6Address, "fe80::1234"); + EXPECT_EQ(serverInfo.macAddress, "00:11:22:33:44:55"); + EXPECT_EQ(serverInfo.runningGameId, 42U); + } + + TEST(HostPairingTest, RejectsServerInfoResponsesMissingRequiredFields) { + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + EXPECT_FALSE(network::parse_server_info_response("47990", test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)); + EXPECT_FALSE(errorMessage.empty()); + } + + TEST(HostPairingTest, ParsesAlternateServerInfoFieldNamesAndFallsBackToTheRequestedPort) { + const std::string xml = + "" + "Bedroom PC" + "8.2.0.0" + "host-uuid-456" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]) + + "" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]) + + "" + "fe80::beef" + "AA:BB:CC:DD:EE:FF" + "invalid" + "bad" + "0" + "0" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.serverMajorVersion, 8); + EXPECT_EQ(serverInfo.httpPort, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_EQ(serverInfo.httpsPort, test_support::kTestPorts[test_support::kPortPairing]); + EXPECT_FALSE(serverInfo.paired); + EXPECT_EQ(serverInfo.hostName, "Bedroom PC"); + EXPECT_EQ(serverInfo.uuid, "host-uuid-456"); + EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]); + EXPECT_EQ(serverInfo.localAddress, test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]); + EXPECT_EQ(serverInfo.remoteAddress, test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]); + EXPECT_EQ(serverInfo.ipv6Address, "fe80::beef"); + EXPECT_EQ(serverInfo.macAddress, "AA:BB:CC:DD:EE:FF"); + EXPECT_EQ(serverInfo.runningGameId, 0U); + } + + TEST(HostPairingTest, PrefersRequestedAddressForFollowUpRequests) { + const std::string xml = + "" + "Sunshine-PC" + "7.1.431.0" + "host-uuid-123" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpLoopback]) + + "" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]) + + "" + "47990" + "1" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.activeAddress, test_support::kTestIpv4Addresses[test_support::kIpLoopback]); + EXPECT_EQ( + network::resolve_reachable_address(test_support::kTestIpv4Addresses[test_support::kIpRuntimeDhcpGateway], serverInfo), + test_support::kTestIpv4Addresses[test_support::kIpRuntimeDhcpGateway] + ); + } + + TEST(HostPairingTest, FallsBackToReportedAddressWhenRequestedAddressIsMissing) { + network::HostPairingServerInfo serverInfo {}; + serverInfo.activeAddress = test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]; + serverInfo.localAddress = test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]; + serverInfo.remoteAddress = test_support::kTestIpv4Addresses[test_support::kIpServerExternalAlt]; + + EXPECT_EQ(network::resolve_reachable_address({}, serverInfo), test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]); + } + + TEST(HostPairingTest, ParsesHostAppLists) { + const std::string xml = + "" + "Steam1011" + "Desktop1020" + "Broken Entryoops" + ""; + + std::vector apps; + std::string errorMessage; + + ASSERT_TRUE(network::parse_app_list_response(xml, &apps, &errorMessage)) << errorMessage; + ASSERT_EQ(apps.size(), 2U); + EXPECT_EQ(apps[0].name, "Steam"); + EXPECT_EQ(apps[0].id, 101); + EXPECT_TRUE(apps[0].hdrSupported); + EXPECT_EQ(apps[1].name, "Desktop"); + EXPECT_EQ(apps[1].id, 102); + EXPECT_FALSE(apps[1].hidden); + } + + TEST(HostPairingTest, RejectsAppListsWithoutValidApps) { + std::vector apps; + std::string errorMessage; + + EXPECT_FALSE(network::parse_app_list_response("Brokenbad", &apps, &errorMessage)); + EXPECT_FALSE(errorMessage.empty()); + } + + TEST(HostPairingTest, ReportsExplicitHostStatusErrorsWhenAppEntriesAreMissing) { + std::vector apps; + std::string errorMessage; + + EXPECT_FALSE(network::parse_app_list_response("", &apps, &errorMessage)); + EXPECT_EQ(errorMessage, "Host is busy"); + } + + TEST(HostPairingTest, ParsesAttributeBasedHostAppLists) { + const std::string xml = R"()"; + + std::vector apps; + std::string errorMessage; + + ASSERT_TRUE(network::parse_app_list_response(xml, &apps, &errorMessage)) << errorMessage; + ASSERT_EQ(apps.size(), 2U); + EXPECT_EQ(apps[0].name, "Steam"); + EXPECT_EQ(apps[0].id, 201); + EXPECT_TRUE(apps[0].hdrSupported); + EXPECT_EQ(apps[1].name, "Desktop"); + EXPECT_EQ(apps[1].id, 202); + EXPECT_FALSE(apps[1].hidden); + } + + TEST(HostPairingTest, ParsesAlternateXmlGameElementsInAppLists) { + const std::string xml = R"(Steam301false)"; + + std::vector apps; + std::string errorMessage; + + ASSERT_TRUE(network::parse_app_list_response(xml, &apps, &errorMessage)) << errorMessage; + ASSERT_EQ(apps.size(), 2U); + EXPECT_EQ(apps[0].name, "Steam"); + EXPECT_EQ(apps[0].id, 301); + EXPECT_FALSE(apps[0].hidden); + EXPECT_EQ(apps[1].name, "Desktop"); + EXPECT_EQ(apps[1].id, 302); + EXPECT_TRUE(apps[1].hdrSupported); + } + + TEST(HostPairingTest, ParsesProgramAndApplicationElementsWithAttributeFlags) { + const std::string xml = + R"()"; + + std::vector apps; + std::string errorMessage; + + ASSERT_TRUE(network::parse_app_list_response(xml, &apps, &errorMessage)) << errorMessage; + ASSERT_EQ(apps.size(), 2U); + EXPECT_EQ(apps[0].name, "Big Picture"); + EXPECT_EQ(apps[0].id, 501); + EXPECT_TRUE(apps[0].hdrSupported); + EXPECT_TRUE(apps[0].hidden); + EXPECT_EQ(apps[1].name, "Desktop"); + EXPECT_EQ(apps[1].id, 502); + EXPECT_FALSE(apps[1].hidden); + } + + TEST(HostPairingTest, RejectsNonXmlAppLists) { + std::vector apps; + std::string errorMessage; + + EXPECT_FALSE(network::parse_app_list_response(R"({"apps":[{"name":"Steam","id":401}]})", &apps, &errorMessage)); + EXPECT_NE(errorMessage.find("not XML"), std::string::npos); + } + + TEST(HostPairingTest, HashesEquivalentAppListsStablyAndDetectsChanges) { + const std::vector baseline = { + {"Steam", 101, true, false}, + {"Desktop", 102, false, false}, + }; + const std::vector identical = { + {"Steam", 101, true, false}, + {"Desktop", 102, false, false}, + }; + const std::vector changed = { + {"Steam", 101, true, false}, + {"Desktop", 102, false, true}, + }; + + EXPECT_EQ(network::hash_app_list_entries(baseline), network::hash_app_list_entries(identical)); + EXPECT_NE(network::hash_app_list_entries(baseline), network::hash_app_list_entries(changed)); + } + + TEST(HostPairingTest, DetectsExplicitUnpairedClientErrors) { + EXPECT_TRUE(network::error_indicates_unpaired_client("The host reports that this client is no longer paired. Pair the host again.")); + EXPECT_TRUE(network::error_indicates_unpaired_client("The client is not authorized. Certificate verification failed.")); + EXPECT_TRUE(network::error_indicates_unpaired_client("The host returned HTTP 401 while requesting /applist")); + EXPECT_TRUE(network::error_indicates_unpaired_client("Request failed with HTTP 403 FORBIDDEN")); + EXPECT_TRUE(network::error_indicates_unpaired_client("The client is UNAUTHORIZED to access this host")); + EXPECT_FALSE(network::error_indicates_unpaired_client("Timed out while refreshing apps")); + } + + TEST(HostPairingTest, ResolvesReachableAddressesInPriorityOrder) { + network::HostPairingServerInfo serverInfo {}; + serverInfo.activeAddress = test_support::kTestIpv4Addresses[test_support::kIpServerExternalAlt]; + serverInfo.localAddress = test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]; + serverInfo.remoteAddress = test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]; + + EXPECT_EQ( + network::resolve_reachable_address(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], serverInfo), + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom] + ); + + EXPECT_EQ(network::resolve_reachable_address({}, serverInfo), test_support::kTestIpv4Addresses[test_support::kIpServerExternalAlt]); + + serverInfo.activeAddress.clear(); + EXPECT_EQ(network::resolve_reachable_address({}, serverInfo), test_support::kTestIpv4Addresses[test_support::kIpLocalFallback]); + + serverInfo.localAddress.clear(); + EXPECT_EQ(network::resolve_reachable_address({}, serverInfo), test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]); + + serverInfo.remoteAddress.clear(); + EXPECT_TRUE(network::resolve_reachable_address({}, serverInfo).empty()); + } + + TEST(HostPairingTest, QueriesServerInfoAcrossFallbackPortsWhenTheDefaultPortFails) { + ScriptedHostPairingHttpHandler handler({ + { + [](const HostPairingHttpTestRequest &request) { + EXPECT_EQ(request.port, 47989U); + EXPECT_FALSE(request.useTls); + EXPECT_EQ(request.pathAndQuery, "/serverinfo?uniqueid=0123456789ABCDEF&uuid=11111111-2222-3333-4444-555555555555"); + }, + false, + 0, + {}, + "default port failed", + }, + { + [](const HostPairingHttpTestRequest &request) { + EXPECT_EQ(request.port, 47984U); + EXPECT_FALSE(request.useTls); + }, + true, + 200, + make_server_info_xml(false, 47984U, 47990U, "Fallback Host", "fallback-host"), + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::query_server_info(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, nullptr, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_TRUE(handler.all_consumed()); + EXPECT_EQ(serverInfo.httpPort, 47984U); + EXPECT_EQ(serverInfo.httpsPort, 47990U); + EXPECT_EQ(serverInfo.hostName, "Fallback Host"); + } + + TEST(HostPairingTest, QueriesAuthorizedServerInfoForTheCurrentClientWhenIdentityIsAvailable) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + [&identity](const HostPairingHttpTestRequest &request) { + EXPECT_FALSE(request.useTls); + EXPECT_EQ(request.tlsClientIdentity, nullptr); + EXPECT_NE(request.pathAndQuery.find("uniqueid=" + identity.uniqueId), std::string::npos); + }, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Base Host", "base-host"), + }, + { + [&identity](const HostPairingHttpTestRequest &request) { + ASSERT_NE(request.tlsClientIdentity, nullptr); + EXPECT_TRUE(request.useTls); + EXPECT_EQ(request.port, 47990U); + EXPECT_EQ(request.tlsClientIdentity->uniqueId, identity.uniqueId); + }, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Authorized Host", "authorized-host"), + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::query_server_info(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], &identity, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_TRUE(handler.all_consumed()); + EXPECT_TRUE(serverInfo.pairingStatusCurrentClientKnown); + EXPECT_TRUE(serverInfo.pairingStatusCurrentClient); + EXPECT_TRUE(serverInfo.paired); + EXPECT_EQ(serverInfo.hostName, "Authorized Host"); + EXPECT_EQ(serverInfo.uuid, "authorized-host"); + } + + TEST(HostPairingTest, MarksTheCurrentClientUnpairedWhenAuthorizedServerInfoReturnsUnauthorized) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Base Host", "base-host"), + }, + { + {}, + true, + 401, + "", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::query_server_info(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], &identity, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_TRUE(serverInfo.pairingStatusCurrentClientKnown); + EXPECT_FALSE(serverInfo.pairingStatusCurrentClient); + EXPECT_FALSE(serverInfo.paired); + } + + TEST(HostPairingTest, LeavesCurrentClientPairingStatusUnknownWhenAuthorizedServerInfoCannotBeParsed) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Base Host", "base-host"), + }, + { + {}, + true, + 200, + "response", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::query_server_info(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], &identity, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_FALSE(serverInfo.pairingStatusCurrentClientKnown); + EXPECT_TRUE(serverInfo.paired); + EXPECT_EQ(serverInfo.hostName, "Base Host"); + } + + TEST(HostPairingTest, QueryAppListPropagatesServerInfoFailures) { + ScopedHostPairingHttpTestHandler guard([](const HostPairingHttpTestRequest &, HostPairingHttpTestResponse *, std::string *errorMessage, const std::atomic *) { + if (errorMessage != nullptr) { + *errorMessage = "serverinfo unavailable"; + } + return false; + }); + + std::vector apps; + std::string errorMessage; + + EXPECT_FALSE(network::query_app_list(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], nullptr, &apps, nullptr, &errorMessage)); + EXPECT_NE(errorMessage.find("serverinfo unavailable"), std::string::npos); + } + + TEST(HostPairingTest, QueryAppListMapsUnauthorizedHttpResponsesToTheUnpairedMessage) { + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + make_server_info_xml(true, 47989U, 47990U), + }, + { + [](const HostPairingHttpTestRequest &request) { + EXPECT_TRUE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/applist?uniqueid=0123456789ABCDEF"), std::string::npos); + }, + true, + 401, + "", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + std::vector apps; + std::string errorMessage; + + EXPECT_FALSE(network::query_app_list(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], nullptr, &apps, nullptr, &errorMessage)); + EXPECT_EQ(errorMessage, kUnpairedClientErrorMessage); + EXPECT_TRUE(handler.all_consumed()); + } + + TEST(HostPairingTest, QueryAppListMapsUnauthorizedPayloadsToTheUnpairedMessage) { + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + make_server_info_xml(true, 47989U, 47990U), + }, + { + {}, + true, + 200, + R"()", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + std::vector apps; + std::string errorMessage; + + EXPECT_FALSE(network::query_app_list(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], nullptr, &apps, nullptr, &errorMessage)); + EXPECT_EQ(errorMessage, kUnpairedClientErrorMessage); + } + + TEST(HostPairingTest, QueryAppListReturnsAppsAndResolvedServerInfoOnSuccess) { + ScriptedHostPairingHttpHandler handler({ + { + {}, + true, + 200, + make_server_info_xml(true, 47989U, 47990U, "Apps Host", "apps-host"), + }, + { + {}, + true, + 200, + "Steam101Desktop1021", + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + std::vector apps; + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::query_app_list(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], test_support::kTestPorts[test_support::kPortPairing], nullptr, &apps, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_TRUE(handler.all_consumed()); + ASSERT_EQ(apps.size(), 2U); + EXPECT_EQ(apps[0].name, "Steam"); + EXPECT_EQ(apps[1].name, "Desktop"); + EXPECT_TRUE(serverInfo.pairingStatusCurrentClient); + EXPECT_EQ(serverInfo.hostName, "Apps Host"); + EXPECT_EQ(serverInfo.uuid, "apps-host"); + } + + TEST(HostPairingTest, QueryAppAssetRejectsInvalidRequestsBeforeStartingTransport) { + std::vector assetBytes; + std::string errorMessage; + + EXPECT_FALSE(network::query_app_asset({}, 47990U, nullptr, 77, &assetBytes, &errorMessage)); + EXPECT_EQ(errorMessage, "The app-asset request requires a valid host address, port, and app ID"); + } + + TEST(HostPairingTest, QueryAppAssetFallsBackAcrossCandidatePathsUntilOneSucceeds) { + std::vector requestedPaths; + ScriptedHostPairingHttpHandler handler({ + { + [&requestedPaths](const HostPairingHttpTestRequest &request) { + requestedPaths.push_back(request.pathAndQuery); + EXPECT_TRUE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("AssetIdx=0"), std::string::npos); + }, + true, + 200, + "", + }, + { + [&requestedPaths](const HostPairingHttpTestRequest &request) { + requestedPaths.push_back(request.pathAndQuery); + EXPECT_TRUE(request.useTls); + }, + true, + 200, + std::string("\x89PNG", 4), + }, + }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); + + std::vector assetBytes; + std::string errorMessage; + + ASSERT_TRUE(network::query_app_asset(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 47990U, nullptr, 77, &assetBytes, &errorMessage)) << errorMessage; + EXPECT_TRUE(handler.all_consumed()); + ASSERT_EQ(requestedPaths.size(), 2U); + EXPECT_FALSE(assetBytes.empty()); + EXPECT_EQ(assetBytes[0], 0x89U); + } + + TEST(HostPairingTest, QueryAppAssetAggregatesAttemptFailuresWhenNoCandidateSucceeds) { + std::size_t callCount = 0U; + ScopedHostPairingHttpTestHandler guard([&callCount](const HostPairingHttpTestRequest &, HostPairingHttpTestResponse *, std::string *errorMessage, const std::atomic *) { + ++callCount; + if (errorMessage != nullptr) { + *errorMessage = "network down"; + } + return false; + }); + + std::vector assetBytes; + std::string errorMessage; + + EXPECT_FALSE(network::query_app_asset(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 47990U, nullptr, 77, &assetBytes, &errorMessage)); + EXPECT_EQ(callCount, 6U); + EXPECT_NE(errorMessage.find("Failed to fetch app artwork for app ID 77"), std::string::npos); + EXPECT_NE(errorMessage.find("network down"), std::string::npos); + } + + TEST(HostPairingTest, PairHostRejectsInvalidRequestsBeforeStartingTransport) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + EXPECT_EQ(network::pair_host({{}, 47989U, "1234", "MoonlightXboxOG", identity}).message, "Pairing requires a valid host address"); + EXPECT_EQ(network::pair_host({test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 47989U, "123", "MoonlightXboxOG", identity}).message, "Pairing requires a four-digit PIN"); + EXPECT_EQ(network::pair_host({test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 47989U, "1234", "MoonlightXboxOG", {}}).message, "Client pairing identity is missing or invalid"); + } + + TEST(HostPairingTest, PairHostShortCircuitsWhenTheHostAlreadyReportsTheClientAsPaired) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + std::size_t callCount = 0U; + ScopedHostPairingHttpTestHandler guard([&callCount](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *, const std::atomic *) { + ++callCount; + EXPECT_FALSE(request.useTls); + response->statusCode = 200; + response->body = make_server_info_xml(true, 47989U, 47990U, "Already Paired Host", "already-paired-host"); + return true; + }); + + const network::HostPairingResult result = network::pair_host({ + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + 47989U, + "1234", + "MoonlightXboxOG", + identity, + }); + + EXPECT_EQ(callCount, 1U); + EXPECT_TRUE(result.success); + EXPECT_TRUE(result.alreadyPaired); + EXPECT_EQ(result.message, "The host already reports this client as paired"); + } + + TEST(HostPairingTest, PairHostPreservesThePhaseOneRejectionMessage) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + std::size_t callCount = 0U; + ScopedHostPairingHttpTestHandler guard([&callCount](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *, const std::atomic *) { + if (callCount++ == 0U) { + response->statusCode = 200; + response->body = make_server_info_xml(false, 47989U, 47990U, "Pair Host", "pair-host"); + return true; + } + + EXPECT_NE(request.pathAndQuery.find("phrase=getservercert"), std::string::npos); + response->statusCode = 200; + response->body = make_pair_phase_response("0"); + return true; + }); + + const network::HostPairingResult result = network::pair_host({ + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + 47989U, + "1234", + "MoonlightXboxOG", + identity, + }); + + EXPECT_FALSE(result.success); + EXPECT_EQ(result.message, "Pairing failed during phase 1 (getservercert): The host rejected the initial pairing request"); + } + + TEST(HostPairingTest, PairHostFailsWhenTheChallengeResponseIsTooShort) { + const network::PairingIdentity clientIdentity = network::create_pairing_identity(); + const network::PairingIdentity serverIdentity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(clientIdentity)); + ASSERT_TRUE(network::is_valid_pairing_identity(serverIdentity)); + + const std::string pin = "1234"; + std::size_t callCount = 0U; + std::string saltHex; + ScopedHostPairingHttpTestHandler guard([&](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *, const std::atomic *) { + return handle_short_challenge_pairing_request(&callCount, &saltHex, pin, serverIdentity, request, response); + }); + + const network::HostPairingResult result = network::pair_host({ + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + 47989U, + pin, + "MoonlightXboxOG", + clientIdentity, + }); + + EXPECT_FALSE(result.success); + EXPECT_EQ(result.message, "Pairing failed during phase 2 (client challenge): The host returned an incomplete challenge response during pairing"); + } + + TEST(HostPairingTest, PairHostCanCompleteAScriptedHandshakeSuccessfully) { + const network::PairingIdentity clientIdentity = network::create_pairing_identity(); + const network::PairingIdentity serverIdentity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(clientIdentity)); + ASSERT_TRUE(network::is_valid_pairing_identity(serverIdentity)); + + const std::string pin = "1234"; + std::size_t callCount = 0U; + std::string saltHex; + const SuccessfulPairingScriptContext scriptContext { + &callCount, + &saltHex, + pin, + clientIdentity, + serverIdentity, + }; + ScopedHostPairingHttpTestHandler guard([&](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *) { + return handle_successful_pairing_request(scriptContext, request, response, errorMessage); + }); + + const network::HostPairingResult result = network::pair_host({ + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + 47989U, + pin, + "MoonlightXboxOG", + clientIdentity, + }); + + EXPECT_EQ(callCount, 6U); + EXPECT_TRUE(result.success); + EXPECT_FALSE(result.alreadyPaired); + EXPECT_EQ(result.message, "Paired successfully with " + std::string(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom])); + } + +} // namespace diff --git a/tests/unit/network/runtime_network_test.cpp b/tests/unit/network/runtime_network_test.cpp new file mode 100644 index 0000000..53644db --- /dev/null +++ b/tests/unit/network/runtime_network_test.cpp @@ -0,0 +1,116 @@ +/** + * @file tests/unit/network/runtime_network_test.cpp + * @brief Verifies runtime network status management. + */ +// test header include +#include "src/network/runtime_network.h" + +// standard includes +#include + +// lib includes +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + TEST(RuntimeNetworkTest, DescribesKnownNxdkInitializationCodes) { + EXPECT_EQ(network::describe_runtime_network_initialization_code(0), "nxdk networking initialized successfully"); + EXPECT_EQ(network::describe_runtime_network_initialization_code(-1), "nxdk networking could not read or apply the configured network settings"); + EXPECT_EQ(network::describe_runtime_network_initialization_code(-2), "nxdk networking timed out while waiting for DHCP to supply an IPv4 address"); + EXPECT_EQ(network::describe_runtime_network_initialization_code(-99), "nxdk networking failed with an unexpected initialization error"); + } + + TEST(RuntimeNetworkTest, FormatsIpv4DetailsWhenNetworkIsReady) { + const network::RuntimeNetworkStatus status { + true, + true, + 0, + "nxdk networking initialized successfully", + test_support::kTestIpv4Addresses[test_support::kIpRuntimeAddress], + test_support::kTestIpv4Addresses[test_support::kIpRuntimeSubnetMask], + test_support::kTestIpv4Addresses[test_support::kIpRuntimeGateway], + }; + + const std::vector lines = network::format_runtime_network_status_lines(status); + + ASSERT_EQ(lines.size(), 5U); + EXPECT_EQ(lines[0], "nxdk networking initialized successfully"); + EXPECT_EQ(lines[1], "IPv4 address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpRuntimeAddress])); + EXPECT_EQ(lines[2], "Subnet mask: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpRuntimeSubnetMask])); + EXPECT_EQ(lines[3], "Gateway: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpRuntimeGateway])); + EXPECT_EQ(lines[4], "Initialization code: 0"); + } + + TEST(RuntimeNetworkTest, FormatsFailureWithoutIpv4Details) { + const network::RuntimeNetworkStatus status { + true, + false, + -2, + "nxdk networking timed out while waiting for DHCP to supply an IPv4 address", + {}, + {}, + {}, + }; + + const std::vector lines = network::format_runtime_network_status_lines(status); + + ASSERT_EQ(lines.size(), 2U); + EXPECT_EQ(lines[0], "nxdk networking timed out while waiting for DHCP to supply an IPv4 address"); + EXPECT_EQ(lines[1], "Initialization code: -2"); + } + + TEST(RuntimeNetworkTest, FormatsGatewayWithoutExtraConnectivityHints) { + const network::RuntimeNetworkStatus status { + true, + true, + 0, + "nxdk networking initialized successfully", + test_support::kTestIpv4Addresses[test_support::kIpRuntimeDhcpAddress], + test_support::kTestIpv4Addresses[test_support::kIpRuntimeSubnetMask], + test_support::kTestIpv4Addresses[test_support::kIpRuntimeDhcpGateway], + }; + + const std::vector lines = network::format_runtime_network_status_lines(status); + + ASSERT_EQ(lines.size(), 5U); + EXPECT_EQ(lines[3], "Gateway: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpRuntimeDhcpGateway])); + EXPECT_EQ(lines[4], "Initialization code: 0"); + } + + TEST(RuntimeNetworkTest, FormatsOnlyTheStatusFieldsThatArePresent) { + const network::RuntimeNetworkStatus status { + false, + false, + 0, + {}, + {}, + {}, + test_support::kTestIpv4Addresses[test_support::kIpRuntimeGateway], + }; + + const std::vector lines = network::format_runtime_network_status_lines(status); + + ASSERT_EQ(lines.size(), 1U); + EXPECT_EQ(lines[0], "Gateway: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpRuntimeGateway])); + } + + TEST(RuntimeNetworkTest, InitializesAndCachesTheHostRuntimeNetworkStatus) { + const network::RuntimeNetworkStatus initialized = network::initialize_runtime_networking(); + const network::RuntimeNetworkStatus &cached = network::runtime_network_status(); + + EXPECT_TRUE(initialized.initializationAttempted); + EXPECT_TRUE(initialized.ready); + EXPECT_EQ(initialized.initializationCode, 0); + EXPECT_EQ(initialized.summary, "Host build networking is provided by the operating system. nxdk network initialization is not required."); + EXPECT_TRUE(initialized.ipAddress.empty()); + EXPECT_TRUE(initialized.subnetMask.empty()); + EXPECT_TRUE(initialized.gateway.empty()); + EXPECT_TRUE(network::runtime_network_ready()); + EXPECT_EQ(cached.summary, initialized.summary); + EXPECT_EQ(cached.initializationCode, initialized.initializationCode); + } + +} // namespace diff --git a/tests/unit/platform/filesystem_utils_test.cpp b/tests/unit/platform/filesystem_utils_test.cpp new file mode 100644 index 0000000..74f554e --- /dev/null +++ b/tests/unit/platform/filesystem_utils_test.cpp @@ -0,0 +1,86 @@ +/** + * @file tests/unit/platform/filesystem_utils_test.cpp + * @brief Verifies filesystem utility helpers. + */ +// test header include +#include "src/platform/filesystem_utils.h" + +// standard includes +#include +#include +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" + +namespace { + + void write_test_file(const std::string &path, std::string_view content) { + FILE *file = std::fopen(path.c_str(), "wb"); + ASSERT_NE(file, nullptr); + ASSERT_EQ(std::fwrite(content.data(), 1, content.size(), file), content.size()); + ASSERT_EQ(std::fclose(file), 0); + } + + class FilesystemUtilsTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest + protected: + std::string rootDirectory = "filesystem-utils-test"; + std::string nestedDirectory = test_support::join_path(test_support::join_path(rootDirectory, "level-one"), "level-two"); + std::string filePath = test_support::join_path(nestedDirectory, "payload.bin"); + + void TearDown() override { + test_support::remove_tree_if_present(rootDirectory); + } + }; + + TEST_F(FilesystemUtilsTest, JoinsPathsAndFindsParentAndFileNames) { + EXPECT_EQ(platform::join_path({}, "child.txt"), "child.txt"); + EXPECT_EQ(platform::join_path("parent", {}), "parent"); + EXPECT_EQ(platform::join_path("parent", "child.txt"), test_support::join_path("parent", "child.txt")); + EXPECT_EQ(platform::join_path(test_support::join_path("parent", "nested"), "child.txt"), test_support::join_path(test_support::join_path("parent", "nested"), "child.txt")); + EXPECT_EQ(platform::parent_directory(filePath), nestedDirectory); + EXPECT_EQ(platform::parent_directory("payload.bin"), ""); + EXPECT_EQ(platform::file_name_from_path(filePath), "payload.bin"); + EXPECT_EQ(platform::file_name_from_path("payload.bin"), "payload.bin"); + } + + TEST_F(FilesystemUtilsTest, EnsuresNestedDirectoriesExistAndTreatsEmptyPathsAsNoOp) { + std::string errorMessage; + + EXPECT_TRUE(platform::ensure_directory_exists({}, &errorMessage)) << errorMessage; + EXPECT_TRUE(platform::ensure_directory_exists(nestedDirectory, &errorMessage)) << errorMessage; + EXPECT_TRUE(std::filesystem::is_directory(nestedDirectory)); + EXPECT_TRUE(platform::ensure_directory_exists(nestedDirectory, &errorMessage)) << errorMessage; + } + + TEST_F(FilesystemUtilsTest, ReportsRegularFileSizesAndRejectsDirectoriesOrMissingPaths) { + ASSERT_TRUE(platform::ensure_directory_exists(nestedDirectory, nullptr)); + write_test_file(filePath, "payload"); + + std::uint64_t sizeBytes = 0; + EXPECT_TRUE(platform::try_get_file_size(filePath, &sizeBytes)); + EXPECT_EQ(sizeBytes, 7U); + EXPECT_TRUE(platform::try_get_file_size(filePath, nullptr)); + EXPECT_FALSE(platform::try_get_file_size(nestedDirectory, &sizeBytes)); + EXPECT_FALSE(platform::try_get_file_size(test_support::join_path(rootDirectory, "missing.bin"), &sizeBytes)); + } + + TEST_F(FilesystemUtilsTest, ComparesPrefixesUsingPlatformPathRules) { + const std::string prefix = test_support::join_path(rootDirectory, "level-one"); + const std::string matchingPath = test_support::join_path(prefix, "child.txt"); + + EXPECT_TRUE(platform::path_has_prefix(matchingPath, prefix)); + EXPECT_FALSE(platform::path_has_prefix(matchingPath, {})); + EXPECT_FALSE(platform::path_has_prefix(prefix, matchingPath)); +#if defined(_WIN32) || defined(NXDK) + EXPECT_TRUE(platform::path_has_prefix("C:\\Moonlight\\Data", "c:\\moonlight")); +#else + EXPECT_FALSE(platform::path_has_prefix("/Moonlight/Data", "/moonlight")); +#endif + } + +} // namespace diff --git a/tests/unit/splash/splash_layout_test.cpp b/tests/unit/splash/splash_layout_test.cpp index 5d6bead..9e63164 100644 --- a/tests/unit/splash/splash_layout_test.cpp +++ b/tests/unit/splash/splash_layout_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/splash/splash_layout_test.cpp + * @brief Verifies splash screen layout calculations. + */ // test include #include "src/splash/splash_layout.h" @@ -18,6 +22,19 @@ namespace { EXPECT_NEAR(splash::get_display_aspect_ratio(videoMode, VIDEO_WIDESCREEN), 1280.0f / 720.0f, 0.001f); } + TEST(SplashLayoutTest, AppliesFourByThreeCorrectionToHighDefinitionModesWhenWidescreenIsDisabled) { + const VIDEO_MODE videoMode {1280, 720, 32, 60}; + + EXPECT_NEAR(splash::get_display_aspect_ratio(videoMode, 0UL), 4.0f / 3.0f, 0.001f); + } + + TEST(SplashLayoutTest, CalculatesLogicalDisplayWidthFromTheEffectiveAspectRatio) { + const VIDEO_MODE videoMode {1280, 720, 32, 60}; + + EXPECT_EQ(splash::calculate_display_width(720, videoMode, 0UL), 960); + EXPECT_EQ(splash::calculate_display_width(720, videoMode, VIDEO_WIDESCREEN), 1280); + } + TEST(SplashLayoutTest, ScalesAndCentersTheLogoInsideTheConfiguredBounds) { const VIDEO_MODE videoMode {640, 480, 32, 60}; diff --git a/tests/unit/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp new file mode 100644 index 0000000..08a3df5 --- /dev/null +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -0,0 +1,135 @@ +/** + * @file tests/unit/startup/client_identity_storage_test.cpp + * @brief Verifies client identity persistence. + */ +// test header include +#include "src/startup/client_identity_storage.h" + +// standard includes +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" + +namespace { + + void write_text_file(const std::string &path, std::string_view content) { + FILE *file = std::fopen(path.c_str(), "wb"); + ASSERT_NE(file, nullptr); + ASSERT_EQ(std::fwrite(content.data(), 1, content.size(), file), content.size()); + ASSERT_EQ(std::fclose(file), 0); + } + + class ClientIdentityStorageTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest + protected: + void TearDown() override { + test_support::remove_if_present(test_support::join_path(nestedIdentityDirectory, "uniqueid.dat")); + test_support::remove_if_present(test_support::join_path(nestedIdentityDirectory, "client.pem")); + test_support::remove_if_present(test_support::join_path(nestedIdentityDirectory, "key.pem")); + test_support::remove_directory_if_present(nestedIdentityDirectory); + test_support::remove_directory_if_present(test_support::join_path(testDirectory, "nested")); + + test_support::remove_if_present(test_support::join_path(testDirectory, "uniqueid.dat")); + test_support::remove_if_present(test_support::join_path(testDirectory, "client.pem")); + test_support::remove_if_present(test_support::join_path(testDirectory, "key.pem")); + test_support::remove_directory_if_present(testDirectory); + } + + std::string testDirectory = "pairing-storage-test"; + std::string nestedIdentityDirectory = test_support::join_path(test_support::join_path(testDirectory, "nested"), "identity"); + }; + + TEST_F(ClientIdentityStorageTest, SavesAndReloadsAClientIdentity) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity, testDirectory); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const startup::LoadClientIdentityResult loadResult = startup::load_client_identity(testDirectory); + ASSERT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_EQ(loadResult.identity.uniqueId, identity.uniqueId); + EXPECT_EQ(loadResult.identity.certificatePem, identity.certificatePem); + EXPECT_EQ(loadResult.identity.privateKeyPem, identity.privateKeyPem); + } + + TEST_F(ClientIdentityStorageTest, MissingIdentityDirectoryReturnsNoWarnings) { + const startup::LoadClientIdentityResult loadResult = startup::load_client_identity(testDirectory); + + EXPECT_FALSE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + } + + TEST_F(ClientIdentityStorageTest, CreatesNestedDirectoriesWhenSavingIdentity) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity, nestedIdentityDirectory); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const startup::LoadClientIdentityResult loadResult = startup::load_client_identity(nestedIdentityDirectory); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_EQ(loadResult.identity.uniqueId, identity.uniqueId); + } + + TEST_F(ClientIdentityStorageTest, DeletesAllPersistedClientIdentityFiles) { + const network::PairingIdentity identity = network::create_pairing_identity(); + ASSERT_TRUE(network::is_valid_pairing_identity(identity)); + + const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity, testDirectory); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + std::string errorMessage; + EXPECT_TRUE(startup::delete_client_identity(&errorMessage, testDirectory)) << errorMessage; + EXPECT_FALSE(std::remove(test_support::join_path(testDirectory, "uniqueid.dat").c_str()) == 0); + EXPECT_FALSE(std::remove(test_support::join_path(testDirectory, "client.pem").c_str()) == 0); + EXPECT_FALSE(std::remove(test_support::join_path(testDirectory, "key.pem").c_str()) == 0); + + const startup::LoadClientIdentityResult loadResult = startup::load_client_identity(testDirectory); + EXPECT_FALSE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + } + + TEST_F(ClientIdentityStorageTest, DeletingMissingIdentityFilesStillSucceeds) { + std::string errorMessage; + + EXPECT_TRUE(startup::delete_client_identity(&errorMessage, testDirectory)) << errorMessage; + EXPECT_TRUE(errorMessage.empty()); + } + + TEST_F(ClientIdentityStorageTest, TrimsTrailingNewlinesFromThePersistedUniqueId) { + ASSERT_TRUE(test_support::create_directory(testDirectory)); + write_text_file(test_support::join_path(testDirectory, "uniqueid.dat"), "unique-id\r\n"); + write_text_file(test_support::join_path(testDirectory, "client.pem"), "certificate"); + write_text_file(test_support::join_path(testDirectory, "key.pem"), "private-key"); + + const startup::LoadClientIdentityResult loadResult = startup::load_client_identity(testDirectory); + + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + EXPECT_EQ(loadResult.identity.uniqueId, "unique-id"); + } + + TEST_F(ClientIdentityStorageTest, MissingCertificateOrPrivateKeyProducesWarnings) { + ASSERT_TRUE(test_support::create_directory(testDirectory)); + write_text_file(test_support::join_path(testDirectory, "uniqueid.dat"), "unique-id"); + + startup::LoadClientIdentityResult loadResult = startup::load_client_identity(testDirectory); + EXPECT_FALSE(loadResult.fileFound); + ASSERT_EQ(loadResult.warnings.size(), 1U); + EXPECT_NE(loadResult.warnings.front().find("certificate"), std::string::npos); + + write_text_file(test_support::join_path(testDirectory, "client.pem"), "certificate"); + loadResult = startup::load_client_identity(testDirectory); + EXPECT_FALSE(loadResult.fileFound); + ASSERT_EQ(loadResult.warnings.size(), 1U); + EXPECT_NE(loadResult.warnings.front().find("private key"), std::string::npos); + } + +} // namespace diff --git a/tests/unit/startup/cover_art_cache_test.cpp b/tests/unit/startup/cover_art_cache_test.cpp new file mode 100644 index 0000000..6a2bd8d --- /dev/null +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -0,0 +1,110 @@ +/** + * @file tests/unit/startup/cover_art_cache_test.cpp + * @brief Verifies cover art cache persistence. + */ +// test header include +#include "src/startup/cover_art_cache.h" + +// standard includes +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" +#include "tests/support/network_test_constants.h" + +namespace { + + class CoverArtCacheTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest + protected: + void TearDown() override { + test_support::remove_if_present(testFilePath); + test_support::remove_directory_if_present(testDirectory); + } + + std::string testDirectory = "cover-art-cache-test"; + std::string cacheKey = startup::build_cover_art_cache_key( + "host-uuid-123", + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + 42 + ); + std::string testFilePath = test_support::join_path(testDirectory, cacheKey + ".bin"); + }; + + TEST_F(CoverArtCacheTest, SavesAndReloadsCachedCoverArtBytes) { + const std::vector bytes = {0x89, 0x50, 0x4E, 0x47, 0x10, 0x20, 0x30}; + + const startup::SaveCoverArtResult saveResult = startup::save_cover_art(cacheKey, bytes, testDirectory); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + EXPECT_TRUE(startup::cover_art_exists(cacheKey, testDirectory)); + + const startup::LoadCoverArtResult loadResult = startup::load_cover_art(cacheKey, testDirectory); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.errorMessage.empty()); + EXPECT_EQ(loadResult.bytes, bytes); + } + + TEST_F(CoverArtCacheTest, HandlesMissingEntriesWithoutErrors) { + const startup::LoadCoverArtResult loadResult = startup::load_cover_art(cacheKey, testDirectory); + + EXPECT_FALSE(loadResult.fileFound); + EXPECT_TRUE(loadResult.bytes.empty()); + EXPECT_TRUE(loadResult.errorMessage.empty()); + EXPECT_FALSE(startup::cover_art_exists(cacheKey, testDirectory)); + } + + TEST_F(CoverArtCacheTest, DeletesAnExistingCachedCoverArtEntry) { + const std::vector bytes = {0x89, 0x50, 0x4E, 0x47, 0x10, 0x20, 0x30}; + + const startup::SaveCoverArtResult saveResult = startup::save_cover_art(cacheKey, bytes, testDirectory); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + ASSERT_TRUE(startup::cover_art_exists(cacheKey, testDirectory)); + + std::string errorMessage; + EXPECT_TRUE(startup::delete_cover_art(cacheKey, &errorMessage, testDirectory)) << errorMessage; + EXPECT_FALSE(startup::cover_art_exists(cacheKey, testDirectory)); + + const startup::LoadCoverArtResult loadResult = startup::load_cover_art(cacheKey, testDirectory); + EXPECT_FALSE(loadResult.fileFound); + EXPECT_TRUE(loadResult.errorMessage.empty()); + } + + TEST_F(CoverArtCacheTest, DeletingAMissingCachedCoverArtEntryStillSucceeds) { + std::string errorMessage; + + EXPECT_TRUE(startup::delete_cover_art(cacheKey, &errorMessage, testDirectory)) << errorMessage; + EXPECT_TRUE(errorMessage.empty()); + } + + TEST_F(CoverArtCacheTest, SavesEntriesUnderNestedCachePathsWhenRequested) { + const std::string nestedCacheKey = test_support::join_path("nested", "cover-404"); + const std::vector bytes = {0x10, 0x20, 0x30}; + + const startup::SaveCoverArtResult saveResult = startup::save_cover_art(nestedCacheKey, bytes, testDirectory); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const startup::LoadCoverArtResult loadResult = startup::load_cover_art(nestedCacheKey, testDirectory); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_EQ(loadResult.bytes, bytes); + + test_support::remove_if_present(test_support::join_path(test_support::join_path(testDirectory, "nested"), "cover-404.bin")); + test_support::remove_directory_if_present(test_support::join_path(testDirectory, "nested")); + } + + TEST_F(CoverArtCacheTest, EmptyCacheKeysDeleteAsANoOpAndFallbackKeysUseTheHostAddress) { + std::string errorMessage; + + EXPECT_TRUE(startup::delete_cover_art({}, &errorMessage, testDirectory)) << errorMessage; + EXPECT_TRUE(errorMessage.empty()); + + const std::string addressOnlyKey = startup::build_cover_art_cache_key({}, test_support::kTestIpv4Addresses[test_support::kIpHostGridA], 42); + const std::string uuidKey = startup::build_cover_art_cache_key("host-uuid-123", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], 42); + + EXPECT_NE(addressOnlyKey, uuidKey); + EXPECT_NE(addressOnlyKey.find("-42"), std::string::npos); + EXPECT_NE(uuidKey.find("-42"), std::string::npos); + } + +} // namespace diff --git a/tests/unit/startup/host_storage_test.cpp b/tests/unit/startup/host_storage_test.cpp new file mode 100644 index 0000000..d665bd2 --- /dev/null +++ b/tests/unit/startup/host_storage_test.cpp @@ -0,0 +1,125 @@ +/** + * @file tests/unit/startup/host_storage_test.cpp + * @brief Verifies saved host persistence. + */ +// test header include +#include "src/startup/host_storage.h" + +// standard includes +#include +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" +#include "tests/support/network_test_constants.h" + +namespace { + + class HostStorageTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest + protected: + void TearDown() override { + test_support::remove_if_present(nestedFilePath); + test_support::remove_directory_if_present(test_support::join_path(testDirectory, "nested")); + test_support::remove_if_present(testFilePath); + test_support::remove_directory_if_present(testDirectory); + } + + std::string testDirectory = "host-storage-test"; + std::string testFilePath = "host-storage-test.tsv"; + std::string nestedFilePath = test_support::join_path(test_support::join_path(testDirectory, "nested"), "hosts.tsv"); + }; + + TEST_F(HostStorageTest, LoadsMissingFilesWithoutWarnings) { + const startup::LoadSavedHostsResult result = startup::load_saved_hosts(testFilePath); + + EXPECT_FALSE(result.fileFound); + EXPECT_TRUE(result.hosts.empty()); + EXPECT_TRUE(result.warnings.empty()); + } + + TEST_F(HostStorageTest, SavesAndReloadsHostRecords) { + const std::vector hosts = { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::not_paired}, + }; + + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hosts, testFilePath); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const startup::LoadSavedHostsResult loadResult = startup::load_saved_hosts(testFilePath); + EXPECT_TRUE(loadResult.fileFound); + EXPECT_TRUE(loadResult.warnings.empty()); + ASSERT_EQ(loadResult.hosts.size(), 2U); + EXPECT_EQ(loadResult.hosts[0].displayName, "Living Room PC"); + EXPECT_EQ(loadResult.hosts[1].address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(loadResult.hosts[1].port, test_support::kTestPorts[test_support::kPortDefaultHost]); + } + + TEST_F(HostStorageTest, CreatesNestedDirectoriesWhenSavingHosts) { + const std::vector hosts = { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, + }; + + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hosts, nestedFilePath); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const startup::LoadSavedHostsResult loadResult = startup::load_saved_hosts(nestedFilePath); + EXPECT_TRUE(loadResult.fileFound); + ASSERT_EQ(loadResult.hosts.size(), 1U); + EXPECT_EQ(loadResult.hosts[0].displayName, "Living Room PC"); + } + + TEST_F(HostStorageTest, SavesAndReloadsCachedAppLists) { + app::HostRecord host { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + }; + host.runningGameId = 101U; + host.resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; + host.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + host.appListContentHash = 0x99887766ULL; + host.apps = { + {"Steam", 101, true, false, true, "steam-cover", true, false}, + {"Desktop", 102, false, true, false, "desktop-cover", false, false}, + }; + + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts({host}, testFilePath); + ASSERT_TRUE(saveResult.success) << saveResult.errorMessage; + + const startup::LoadSavedHostsResult loadResult = startup::load_saved_hosts(testFilePath); + ASSERT_TRUE(loadResult.warnings.empty()); + ASSERT_EQ(loadResult.hosts.size(), 1U); + ASSERT_EQ(loadResult.hosts[0].apps.size(), 2U); + EXPECT_EQ(loadResult.hosts[0].appListContentHash, 0x99887766ULL); + EXPECT_EQ(loadResult.hosts[0].resolvedHttpPort, test_support::kTestPorts[test_support::kPortResolvedHttp]); + EXPECT_EQ(loadResult.hosts[0].httpsPort, test_support::kTestPorts[test_support::kPortResolvedHttps]); + EXPECT_TRUE(loadResult.hosts[0].apps[0].favorite); + EXPECT_TRUE(loadResult.hosts[0].apps[0].running); + EXPECT_TRUE(loadResult.hosts[0].apps[1].hidden); + } + + TEST_F(HostStorageTest, SurfacesParseWarningsButKeepsValidHosts) { + FILE *file = std::fopen(testFilePath.c_str(), "wb"); + ASSERT_NE(file, nullptr); + const std::string fileContent = + "Living Room PC\t" + std::string(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]) + + "\t\tpaired\t0,0,0,0\t\n" + "Broken Host\tnot-an-ip\t\tnot_paired\t0,0,0,0\t\n"; + ASSERT_EQ(std::fwrite(fileContent.data(), 1, fileContent.size(), file), fileContent.size()); + ASSERT_EQ(std::fclose(file), 0); + + const startup::LoadSavedHostsResult loadResult = startup::load_saved_hosts(testFilePath); + EXPECT_TRUE(loadResult.fileFound); + ASSERT_EQ(loadResult.hosts.size(), 1U); + EXPECT_EQ(loadResult.hosts[0].address, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); + ASSERT_EQ(loadResult.warnings.size(), 1U); + EXPECT_NE(loadResult.warnings[0].find("Line 2"), std::string::npos); + } + +} // namespace diff --git a/tests/unit/startup/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp new file mode 100644 index 0000000..aea3d44 --- /dev/null +++ b/tests/unit/startup/saved_files_test.cpp @@ -0,0 +1,157 @@ +/** + * @file tests/unit/startup/saved_files_test.cpp + * @brief Verifies saved file loading and cleanup helpers. + */ +// test header include +#include "src/startup/saved_files.h" + +// standard includes +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/filesystem_test_utils.h" + +namespace { + + void write_file_bytes(const std::string &path, const std::vector &bytes) { + FILE *file = std::fopen(path.c_str(), "wb"); + ASSERT_NE(file, nullptr); + ASSERT_EQ(std::fwrite(bytes.data(), 1, bytes.size(), file), bytes.size()); + ASSERT_EQ(std::fclose(file), 0); + } + + class SavedFilesTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest + protected: + std::string testDirectory = "saved-files-test"; + std::string pairingDirectory = test_support::join_path(testDirectory, "pairing"); + std::string hostStoragePath = test_support::join_path(testDirectory, "moonlight-hosts.tsv"); + std::string settingsFilePath = test_support::join_path(testDirectory, "moonlight.toml"); + std::string logFilePath = test_support::join_path(testDirectory, "moonlight.log"); + std::string pairingUniqueIdPath = test_support::join_path(pairingDirectory, "uniqueid.dat"); + std::string pairingCertificatePath = test_support::join_path(pairingDirectory, "client.pem"); + std::string pairingKeyPath = test_support::join_path(pairingDirectory, "key.pem"); + std::string coverArtDirectory = test_support::join_path(testDirectory, "cover-art-cache"); + std::string coverArtFilePath = test_support::join_path(coverArtDirectory, "cover-101.bin"); + std::string nestedCoverArtDirectory = test_support::join_path(coverArtDirectory, "nested"); + std::string nestedCoverArtFilePath = test_support::join_path(nestedCoverArtDirectory, "cover-202.bin"); + startup::SavedFileCatalogConfig config { + hostStoragePath, + settingsFilePath, + logFilePath, + pairingDirectory, + coverArtDirectory, + }; + + void SetUp() override { + ASSERT_TRUE(test_support::create_directory(testDirectory)); + ASSERT_TRUE(test_support::create_directory(pairingDirectory)); + ASSERT_TRUE(test_support::create_directory(coverArtDirectory)); + ASSERT_TRUE(test_support::create_directory(nestedCoverArtDirectory)); + } + + void TearDown() override { + test_support::remove_if_present(nestedCoverArtFilePath); + test_support::remove_if_present(coverArtFilePath); + test_support::remove_if_present(pairingKeyPath); + test_support::remove_if_present(pairingCertificatePath); + test_support::remove_if_present(pairingUniqueIdPath); + test_support::remove_if_present(logFilePath); + test_support::remove_if_present(settingsFilePath); + test_support::remove_if_present(hostStoragePath); + test_support::remove_directory_if_present(nestedCoverArtDirectory); + test_support::remove_directory_if_present(coverArtDirectory); + test_support::remove_directory_if_present(pairingDirectory); + test_support::remove_directory_if_present(testDirectory); + } + }; + + TEST_F(SavedFilesTest, ListsMoonlightManagedFilesThatExistOnDisk) { + write_file_bytes(hostStoragePath, {'h', 'o', 's', 't'}); + write_file_bytes(settingsFilePath, {'s', 'e', 't'}); + write_file_bytes(logFilePath, {'l', 'o', 'g'}); + write_file_bytes(pairingUniqueIdPath, {'1', '2', '3', '4'}); + write_file_bytes(pairingCertificatePath, {'c', 'e', 'r', 't'}); + write_file_bytes(pairingKeyPath, {'k', 'e', 'y'}); + write_file_bytes(coverArtFilePath, {0x89, 0x50, 0x4E, 0x47}); + + const startup::ListSavedFilesResult result = startup::list_saved_files(config); + + EXPECT_TRUE(result.warnings.empty()); + ASSERT_EQ(result.files.size(), 7U); + EXPECT_EQ(result.files[0].displayName, test_support::join_path("cover-art-cache", "cover-101.bin")); + EXPECT_EQ(result.files[1].displayName, "moonlight-hosts.tsv"); + EXPECT_EQ(result.files[2].displayName, "moonlight.log"); + EXPECT_EQ(result.files[3].displayName, "moonlight.toml"); + EXPECT_EQ(result.files[4].displayName, test_support::join_path("pairing", "client.pem")); + EXPECT_EQ(result.files[5].displayName, test_support::join_path("pairing", "key.pem")); + EXPECT_EQ(result.files[6].displayName, test_support::join_path("pairing", "uniqueid.dat")); + } + + TEST_F(SavedFilesTest, DeletesManagedSavedFiles) { + write_file_bytes(logFilePath, {'l', 'o', 'g'}); + + std::string errorMessage; + EXPECT_TRUE(startup::delete_saved_file(logFilePath, &errorMessage, config)) << errorMessage; + + FILE *file = std::fopen(logFilePath.c_str(), "rb"); + EXPECT_EQ(file, nullptr); + } + + TEST_F(SavedFilesTest, RefusesToDeleteFilesOutsideTheManagedStorageSet) { + const std::string outsidePath = "outside.txt"; + write_file_bytes(outsidePath, {'x'}); + + std::string errorMessage; + EXPECT_FALSE(startup::delete_saved_file(outsidePath, &errorMessage, config)); + EXPECT_FALSE(errorMessage.empty()); + + test_support::remove_if_present(outsidePath); + } + + TEST_F(SavedFilesTest, FactoryResetDeletesAllManagedSavedFiles) { + write_file_bytes(hostStoragePath, {'h', 'o', 's', 't'}); + write_file_bytes(settingsFilePath, {'s', 'e', 't'}); + write_file_bytes(logFilePath, {'l', 'o', 'g'}); + write_file_bytes(pairingUniqueIdPath, {'1', '2', '3', '4'}); + write_file_bytes(pairingCertificatePath, {'c', 'e', 'r', 't'}); + write_file_bytes(pairingKeyPath, {'k', 'e', 'y'}); + write_file_bytes(coverArtFilePath, {0x89, 0x50, 0x4E, 0x47}); + + std::string errorMessage; + EXPECT_TRUE(startup::delete_all_saved_files(&errorMessage, config)) << errorMessage; + + const startup::ListSavedFilesResult result = startup::list_saved_files(config); + EXPECT_TRUE(result.files.empty()); + } + + TEST_F(SavedFilesTest, ListsNestedCoverArtFilesAndDeduplicatesRepeatedPaths) { + write_file_bytes(hostStoragePath, {'h', 'o', 's', 't'}); + write_file_bytes(nestedCoverArtFilePath, {0x89, 0x50, 0x4E, 0x47}); + + startup::SavedFileCatalogConfig deduplicatedConfig = config; + deduplicatedConfig.settingsFilePath = hostStoragePath; + + const startup::ListSavedFilesResult result = startup::list_saved_files(deduplicatedConfig); + + EXPECT_TRUE(result.warnings.empty()); + ASSERT_EQ(result.files.size(), 2U); + EXPECT_EQ(result.files[0].displayName, test_support::join_path("cover-art-cache", "cover-202.bin")); + EXPECT_EQ(result.files[1].displayName, "moonlight-hosts.tsv"); + } + + TEST_F(SavedFilesTest, RejectsEmptyPathsAndAllowsMissingManagedFilesToBeDeleted) { + std::string errorMessage; + + EXPECT_FALSE(startup::delete_saved_file({}, &errorMessage, config)); + EXPECT_NE(errorMessage.find("requires a valid path"), std::string::npos); + + errorMessage.clear(); + EXPECT_TRUE(startup::delete_saved_file(logFilePath, &errorMessage, config)) << errorMessage; + EXPECT_TRUE(errorMessage.empty()); + } + +} // namespace diff --git a/tests/unit/startup/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index 9758cdc..a198968 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/startup/video_mode_test.cpp + * @brief Verifies video mode selection helpers. + */ // test includes #include "src/startup/video_mode.h" @@ -41,4 +45,19 @@ namespace { EXPECT_FALSE(startup::is_preferred_video_mode(candidateVideoMode, currentBestVideoMode)); } + TEST(VideoModeTest, Prefers720pOver1080iForHdStartupModes) { + const std::vector availableVideoModes = { + {640, 480, 32, 60}, + {1280, 720, 32, 60}, + {1920, 1080, 32, 60}, + }; + + const VIDEO_MODE bestVideoMode = startup::choose_best_video_mode(availableVideoModes); + + EXPECT_EQ(bestVideoMode.width, 1280); + EXPECT_EQ(bestVideoMode.height, 720); + EXPECT_EQ(bestVideoMode.bpp, 32); + EXPECT_EQ(bestVideoMode.refresh, 60); + } + } // namespace diff --git a/tests/unit/streaming/stats_overlay_test.cpp b/tests/unit/streaming/stats_overlay_test.cpp new file mode 100644 index 0000000..fa82b56 --- /dev/null +++ b/tests/unit/streaming/stats_overlay_test.cpp @@ -0,0 +1,61 @@ +/** + * @file tests/unit/streaming/stats_overlay_test.cpp + * @brief Verifies the streaming statistics overlay. + */ +#include "src/streaming/stats_overlay.h" + +#include +#include + +namespace { + + TEST(StreamStatsOverlayTest, FormatsKnownMetricsIntoReadableLines) { + const streaming::StreamStatisticsSnapshot snapshot { + 1280, + 720, + 60, + 18, + 6, + 4, + 2, + 15, + 1024, + 12, + 3, + false, + }; + + const std::vector lines = streaming::build_stats_overlay_lines(snapshot); + + ASSERT_EQ(lines.size(), 5U); + EXPECT_EQ(lines[0], "Stream: 1280x720 @ 60 FPS"); + EXPECT_EQ(lines[1], "Latency: RTT 18 ms | Host 6 ms | Decode 4 ms"); + EXPECT_EQ(lines[2], "Queues: Video 2 frames | Audio 15 ms"); + EXPECT_EQ(lines[3], "Video packets: 1024 rx | 12 recovered | 3 lost"); + EXPECT_EQ(lines[4], "Connection: Okay"); + } + + TEST(StreamStatsOverlayTest, OmitsUnavailableMetricGroups) { + const streaming::StreamStatisticsSnapshot snapshot { + 640, + 480, + 30, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + -1, + true, + }; + + const std::vector lines = streaming::build_stats_overlay_lines(snapshot); + + ASSERT_EQ(lines.size(), 2U); + EXPECT_EQ(lines[0], "Stream: 640x480 @ 30 FPS"); + EXPECT_EQ(lines[1], "Connection: Poor"); + } + +} // namespace diff --git a/tests/unit/ui/host_probe_result_queue_test.cpp b/tests/unit/ui/host_probe_result_queue_test.cpp new file mode 100644 index 0000000..022c9fb --- /dev/null +++ b/tests/unit/ui/host_probe_result_queue_test.cpp @@ -0,0 +1,111 @@ +/** + * @file tests/unit/ui/host_probe_result_queue_test.cpp + * @brief Verifies queued host probe results. + */ +// test header include +#include "src/ui/host_probe_result_queue.h" + +// standard includes +#include +#include +#include + +// lib includes +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + TEST(HostProbeResultQueueTest, DrainsPublishedResultsBeforeTheRoundCompletes) { + ui::HostProbeResultQueue queue {}; + ui::begin_host_probe_result_round(&queue, 3U); + + ui::publish_host_probe_result(&queue, { + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + test_support::kTestPorts[test_support::kPortDefaultHost], + true, + {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Host A"}, + }); + + std::vector drainedResults = ui::drain_host_probe_results(&queue); + ASSERT_EQ(drainedResults.size(), 1U); + EXPECT_EQ(drainedResults.front().address, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); + EXPECT_TRUE(drainedResults.front().success); + EXPECT_FALSE(ui::host_probe_result_round_complete(queue)); + + ui::publish_host_probe_result(&queue, { + test_support::kTestIpv4Addresses[test_support::kIpHostGridB], + test_support::kTestPorts[test_support::kPortDefaultHost], + false, + {}, + }); + ui::publish_host_probe_result(&queue, { + test_support::kTestIpv4Addresses[test_support::kIpHostGridC], + test_support::kTestPorts[test_support::kPortDefaultHost], + true, + {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Host C"}, + }); + + drainedResults = ui::drain_host_probe_results(&queue); + ASSERT_EQ(drainedResults.size(), 2U); + EXPECT_TRUE(ui::host_probe_result_round_complete(queue)); + } + + TEST(HostProbeResultQueueTest, SkipsExpectedTargetsThatNeverLaunchAWorker) { + ui::HostProbeResultQueue queue {}; + ui::begin_host_probe_result_round(&queue, 2U); + + ui::skip_host_probe_result_target(&queue); + EXPECT_FALSE(ui::host_probe_result_round_complete(queue)); + + ui::publish_host_probe_result(&queue, { + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + true, + {0, test_support::kTestPorts[test_support::kPortResolvedHttp], test_support::kTestPorts[test_support::kPortResolvedHttps], false, false, false, "Office PC"}, + }); + + const std::vector drainedResults = ui::drain_host_probe_results(&queue); + ASSERT_EQ(drainedResults.size(), 1U); + EXPECT_TRUE(ui::host_probe_result_round_complete(queue)); + } + + TEST(HostProbeResultQueueTest, AcceptsConcurrentProbePublications) { + ui::HostProbeResultQueue queue {}; + const std::vector addresses = { + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + test_support::kTestIpv4Addresses[test_support::kIpHostGridB], + test_support::kTestIpv4Addresses[test_support::kIpHostGridC], + test_support::kTestIpv4Addresses[test_support::kIpHostGridD], + }; + ui::begin_host_probe_result_round(&queue, addresses.size()); + + std::vector workers; + workers.reserve(addresses.size()); + for (std::size_t index = 0; index < addresses.size(); ++index) { + workers.emplace_back([&queue, &addresses, index]() { + ui::publish_host_probe_result(&queue, { + addresses[index], + test_support::kTestPorts[test_support::kPortDefaultHost], + (index % 2U) == 0U, + {}, + }); + }); + } + for (std::thread &worker : workers) { + worker.join(); + } + + const std::vector drainedResults = ui::drain_host_probe_results(&queue); + ASSERT_EQ(drainedResults.size(), addresses.size()); + EXPECT_TRUE(ui::host_probe_result_round_complete(queue)); + for (const std::string &address : addresses) { + EXPECT_TRUE(std::any_of(drainedResults.begin(), drainedResults.end(), [&address](const ui::HostProbeResult &result) { + return result.address == address; + })); + } + } + +} // namespace diff --git a/tests/unit/ui/menu_model_test.cpp b/tests/unit/ui/menu_model_test.cpp new file mode 100644 index 0000000..96fcbc5 --- /dev/null +++ b/tests/unit/ui/menu_model_test.cpp @@ -0,0 +1,102 @@ +/** + * @file tests/unit/ui/menu_model_test.cpp + * @brief Verifies menu model structures and helpers. + */ +#include "src/ui/menu_model.h" + +#include + +namespace { + + TEST(MenuModelTest, SelectsTheFirstEnabledItemWhenConstructed) { + const ui::MenuModel menu({ + {"disabled", "Disabled", {}, false}, + {"hosts", "Hosts", {}, true}, + {"settings", "Settings", {}, true}, + }); + + ASSERT_NE(menu.selected_item(), nullptr); + EXPECT_EQ(menu.selected_index(), 1U); + EXPECT_EQ(menu.selected_item()->id, "hosts"); + } + + TEST(MenuModelTest, MovesSelectionAndSkipsDisabledItems) { + ui::MenuModel menu({ + {"hosts", "Hosts", {}, true}, + {"disabled", "Disabled", {}, false}, + {"settings", "Settings", {}, true}, + }); + + const ui::MenuUpdate update = menu.handle_command(input::UiCommand::move_down); + + EXPECT_TRUE(update.selectionChanged); + ASSERT_NE(menu.selected_item(), nullptr); + EXPECT_EQ(menu.selected_item()->id, "settings"); + } + + TEST(MenuModelTest, WrapsAroundWhenMovingPastTheLastItem) { + ui::MenuModel menu({ + {"hosts", "Hosts", {}, true}, + {"settings", "Settings", {}, true}, + }); + + EXPECT_TRUE(menu.handle_command(input::UiCommand::move_up).selectionChanged); + ASSERT_NE(menu.selected_item(), nullptr); + EXPECT_EQ(menu.selected_item()->id, "settings"); + } + + TEST(MenuModelTest, ActivatesTheSelectedItem) { + ui::MenuModel menu({ + {"hosts", "Hosts", {}, true}, + {"settings", "Settings", {}, true}, + }); + + const ui::MenuUpdate update = menu.handle_command(input::UiCommand::activate); + + EXPECT_TRUE(update.activationRequested); + EXPECT_EQ(update.activatedItemId, "hosts"); + } + + TEST(MenuModelTest, CanSelectAnEnabledItemById) { + ui::MenuModel menu({ + {"hosts", "Hosts", {}, true}, + {"disabled", "Disabled", {}, false}, + {"settings", "Settings", {}, true}, + }); + + EXPECT_TRUE(menu.select_item_by_id("settings")); + ASSERT_NE(menu.selected_item(), nullptr); + EXPECT_EQ(menu.selected_item()->id, "settings"); + EXPECT_FALSE(menu.select_item_by_id("disabled")); + EXPECT_EQ(menu.selected_item()->id, "settings"); + } + + TEST(MenuModelTest, SurfacesBackAndOverlayActionsWithoutChangingSelection) { + ui::MenuModel menu({ + {"hosts", "Hosts", {}, true}, + }); + + const ui::MenuUpdate backUpdate = menu.handle_command(input::UiCommand::back); + const ui::MenuUpdate overlayUpdate = menu.handle_command(input::UiCommand::toggle_overlay); + + EXPECT_TRUE(backUpdate.backRequested); + EXPECT_TRUE(overlayUpdate.overlayToggleRequested); + ASSERT_NE(menu.selected_item(), nullptr); + EXPECT_EQ(menu.selected_item()->id, "hosts"); + } + + TEST(MenuModelTest, SurfacesFastPageActionsWithoutChangingSelection) { + ui::MenuModel menu({ + {"hosts", "Hosts", {}, true}, + }); + + const ui::MenuUpdate previousUpdate = menu.handle_command(input::UiCommand::fast_previous_page); + const ui::MenuUpdate nextUpdate = menu.handle_command(input::UiCommand::fast_next_page); + + EXPECT_TRUE(previousUpdate.previousPageRequested); + EXPECT_TRUE(nextUpdate.nextPageRequested); + ASSERT_NE(menu.selected_item(), nullptr); + EXPECT_EQ(menu.selected_item()->id, "hosts"); + } + +} // namespace diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp new file mode 100644 index 0000000..d2296d0 --- /dev/null +++ b/tests/unit/ui/shell_view_test.cpp @@ -0,0 +1,546 @@ +/** + * @file tests/unit/ui/shell_view_test.cpp + * @brief Verifies the shell view renderer and layout helpers. + */ +// test header include +#include "src/ui/shell_view.h" + +// standard includes +#include +#include + +// lib inclues +#include + +// test includes +#include "tests/support/network_test_constants.h" + +namespace { + + TEST(ShellViewTest, BuildsHostsScreenContentFromTheInitialState) { + const app::ClientState state = app::create_initial_state(); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.frame.title, "Moonlight"); + EXPECT_TRUE(viewModel.frame.pageTitle.empty()); + ASSERT_EQ(viewModel.content.toolbarButtons.size(), 3U); + EXPECT_TRUE(viewModel.content.toolbarButtons[2].selected); + EXPECT_EQ(viewModel.content.toolbarButtons[2].label, "Add Host"); + EXPECT_EQ(viewModel.content.toolbarButtons[2].iconAssetPath, "icons\\add-host.svg"); + ASSERT_FALSE(viewModel.content.bodyLines.empty()); + EXPECT_EQ(viewModel.content.bodyLines.front(), "No PCs have been added yet."); + ASSERT_EQ(viewModel.frame.footerActions.size(), 2U); + EXPECT_EQ(viewModel.frame.footerActions[0].label, "Select"); + EXPECT_EQ(viewModel.frame.footerActions[0].iconAssetPath, "icons\\button-a.svg"); + EXPECT_EQ(viewModel.frame.footerActions[1].label, "Exit"); + EXPECT_EQ(viewModel.frame.footerActions[1].iconAssetPath, "icons\\button-select.svg"); + EXPECT_EQ(viewModel.frame.footerActions[1].secondaryIconAssetPath, "icons\\button-start.svg"); + EXPECT_FALSE(viewModel.overlay.visible); + } + + TEST(ShellViewTest, ShowsSavedHostsAsTiles) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::not_paired, app::HostReachability::offline}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }, + "Loaded 2 saved host(s)"); + app::handle_command(state, input::UiCommand::move_right); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.frame.pageTitle.empty()); + EXPECT_EQ(viewModel.content.hostColumnCount, 3U); + ASSERT_EQ(viewModel.content.hostTiles.size(), 2U); + EXPECT_EQ(viewModel.content.hostTiles[0].displayName, "Living Room PC"); + EXPECT_EQ(viewModel.content.hostTiles[0].statusLabel, "Offline"); + EXPECT_EQ(viewModel.content.hostTiles[0].iconAssetPath, "icons\\host-monitor-offline.svg"); + EXPECT_EQ(viewModel.content.hostTiles[1].displayName, "Office PC"); + EXPECT_EQ(viewModel.content.hostTiles[1].statusLabel, "Online"); + EXPECT_EQ(viewModel.content.hostTiles[1].iconAssetPath, "icons\\host-monitor-online.svg"); + EXPECT_TRUE(viewModel.content.hostTiles[1].selected); + ASSERT_GE(viewModel.content.bodyLines.size(), 2U); + EXPECT_EQ(viewModel.content.bodyLines[1], "Press Y on a controller, or I on a keyboard, for host actions."); + } + + TEST(ShellViewTest, HidesHostMenuFooterActionWhenToolbarIsSelected) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + state.hosts.focusArea = app::HostsFocusArea::toolbar; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_EQ(viewModel.frame.footerActions.size(), 2U); + EXPECT_EQ(viewModel.frame.footerActions[0].label, "Select"); + EXPECT_EQ(viewModel.frame.footerActions[1].label, "Exit"); + } + + TEST(ShellViewTest, ShowsKeypadBasedHostEntryInstructionsAndValidation) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.activeField = app::AddHostField::port; + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; + state.addHostDraft.portInput = "48000"; + state.addHostDraft.validationMessage = "That host is already saved"; + state.addHostDraft.connectionMessage = + "Connected to " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + ":48000"; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.frame.pageTitle, "Add Host"); + ASSERT_GE(viewModel.content.bodyLines.size(), 5U); + EXPECT_EQ(viewModel.content.bodyLines[0], "Manual host entry with a keypad modal."); + EXPECT_EQ(viewModel.content.bodyLines[1], "Address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); + EXPECT_EQ(viewModel.content.bodyLines[2], "Port: 48000"); + EXPECT_EQ(viewModel.content.bodyLines[3], "Press A to edit either field with the keypad modal."); + ASSERT_EQ(viewModel.frame.footerActions.size(), 2U); + EXPECT_EQ(viewModel.frame.footerActions[0].label, "Select"); + EXPECT_EQ(viewModel.frame.footerActions[1].label, "Back"); + } + + TEST(ShellViewTest, ShowsLoggingDetailsOnTheSettingsScreen) { + app::ClientState state = app::create_initial_state(); + state.settings.loggingLevel = logging::LogLevel::none; + state.settings.xemuConsoleLoggingLevel = logging::LogLevel::warning; + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + app::set_log_file_path(state, R"(E:\UDATA\12345678\moonlight.log)"); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_GE(viewModel.content.bodyLines.size(), 7U); + EXPECT_EQ(viewModel.content.bodyLines[0], "Category: Logging"); + EXPECT_EQ(viewModel.content.bodyLines[1], "Runtime log file: reset on every startup"); + EXPECT_EQ(viewModel.content.bodyLines[2], "Log file path: E:\\UDATA\\12345678\\moonlight.log"); + EXPECT_EQ(viewModel.content.bodyLines[3], "File logging level: NONE"); + EXPECT_EQ(viewModel.content.bodyLines[4], "xemu console logging level: WARN"); + ASSERT_EQ(viewModel.content.detailMenuRows.size(), 3U); + EXPECT_EQ(viewModel.content.detailMenuRows[0].label, "View Log File"); + EXPECT_EQ(viewModel.content.detailMenuRows[1].label, "File Logging Level: NONE"); + EXPECT_EQ(viewModel.content.detailMenuRows[2].label, "xemu Console Level: WARN"); + EXPECT_EQ(viewModel.content.selectedMenuRowDescription, "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."); + EXPECT_TRUE(viewModel.content.leftPanelActive); + EXPECT_FALSE(viewModel.content.rightPanelActive); + } + + TEST(ShellViewTest, ExposesTheFullSelectedSettingsLabelBesideTheMenu) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + state.settings.selectedCategory = app::SettingsCategory::reset; + app::replace_saved_files(state, { + {R"(E:\UDATA\12345678\pairing\a-very-long-file-name-that-needs-the-detail-pane.bin)", R"(pairing\a-very-long-file-name-that-needs-the-detail-pane.bin)", 1536U}, + }); + state.settings.focusArea = app::SettingsFocusArea::options; + ASSERT_TRUE(state.detailMenu.select_item_by_id("delete-saved-file:E:\\UDATA\\12345678\\pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin")); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.content.selectedMenuRowLabel, "Delete pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin"); + EXPECT_EQ(viewModel.content.selectedMenuRowDescription, "Delete only this saved file from disk while leaving the rest of the Moonlight data intact."); + } + + TEST(ShellViewTest, ShowsSettingDescriptionsForFocusedOptions) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + state.settings.focusArea = app::SettingsFocusArea::options; + ASSERT_TRUE(state.detailMenu.select_item_by_id("cycle-xemu-console-log-level")); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.content.selectedMenuRowLabel, "xemu Console Level: NONE"); + EXPECT_EQ(viewModel.content.selectedMenuRowDescription, "Choose the minimum severity mirrored to xemu through DbgPrint() when you launch xemu with a serial console."); + } + + TEST(ShellViewTest, MarksTheFocusedSettingsPanelAsActive) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + state.settings.focusArea = app::SettingsFocusArea::options; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_FALSE(viewModel.content.leftPanelActive); + EXPECT_TRUE(viewModel.content.rightPanelActive); + } + + TEST(ShellViewTest, KeepsTheLeftPanelActiveOnTheAddHostScreen) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::add_host); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.content.leftPanelActive); + EXPECT_FALSE(viewModel.content.rightPanelActive); + } + + TEST(ShellViewTest, BuildsTheAddHostKeypadModalAsANumberPad) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput.clear(); + app::handle_command(state, input::UiCommand::activate); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.keypad.visible); + EXPECT_EQ(viewModel.keypad.title, "Address Keypad"); + ASSERT_GE(viewModel.keypad.lines.size(), 3U); + EXPECT_EQ(viewModel.keypad.lines[0], "Editing field: Address"); + ASSERT_EQ(viewModel.keypad.buttons.size(), 11U); + EXPECT_EQ(viewModel.keypad.columnCount, 3U); + EXPECT_EQ(viewModel.keypad.buttons[0].label, "1"); + EXPECT_TRUE(viewModel.keypad.buttons[0].selected); + EXPECT_EQ(viewModel.keypad.buttons[9].label, "."); + EXPECT_EQ(viewModel.keypad.buttons[10].label, "0"); + } + + TEST(ShellViewTest, BuildsTheAppsPageForASelectedPairedHost) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.active.runningGameId = 101; + app::apply_app_list_result(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], { + {"Steam", 101, true, false, false, "steam-cover", true, false}, + {"Desktop", 102, false, false, false, "desktop-cover", false, false}, + }, + 0x4242U, + true, + "Loaded 2 app(s)"); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.frame.screen, app::ScreenId::apps); + EXPECT_EQ(viewModel.frame.pageTitle, "Office PC"); + ASSERT_FALSE(viewModel.content.appTiles.empty()); + EXPECT_EQ(viewModel.content.appTiles[0].name, "Steam"); + EXPECT_TRUE(viewModel.content.appTiles[0].selected); + EXPECT_TRUE(viewModel.content.bodyLines.empty()); + EXPECT_EQ(viewModel.content.appTiles[0].detail, "Running now"); + EXPECT_EQ(viewModel.content.appTiles[0].badgeLabel, "HDR"); + EXPECT_TRUE(viewModel.content.appTiles[0].boxArtCached); + EXPECT_TRUE(viewModel.content.appTiles[1].detail.empty()); + ASSERT_EQ(viewModel.frame.footerActions.size(), 3U); + EXPECT_EQ(viewModel.frame.footerActions[0].label, "Launch"); + EXPECT_EQ(viewModel.frame.footerActions[1].label, "App Menu"); + EXPECT_EQ(viewModel.frame.footerActions[2].label, "Back"); + } + + TEST(ShellViewTest, HidesCachedAppTilesWhenTheSelectedHostIsNoLongerPaired) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::apps; + state.hosts.activeLoaded = true; + state.hosts.active = { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::not_paired, + app::HostReachability::online, + }; + state.hosts.active.apps = { + {"Steam", 101, false, false, false, "steam-cover", true, false}, + }; + state.hosts.active.appListState = app::HostAppListState::failed; + state.hosts.active.appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again."; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.content.appTiles.empty()); + ASSERT_FALSE(viewModel.content.bodyLines.empty()); + EXPECT_EQ(viewModel.content.bodyLines.front(), "This host is not paired yet. Return and select it to begin pairing."); + } + + TEST(ShellViewTest, SuppressesTransientAppsLoadingTextAndNotifications) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.shell.statusMessage = "Loading apps for Office PC..."; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.content.bodyLines.empty()); + EXPECT_FALSE(viewModel.notification.visible); + } + + TEST(ShellViewTest, ShowsOnlyBackOnAppsScreenWhenNoVisibleAppIsSelected) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, + }); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_EQ(viewModel.frame.footerActions.size(), 1U); + EXPECT_EQ(viewModel.frame.footerActions[0].label, "Back"); + } + + TEST(ShellViewTest, BuildsHostDetailsModalContent) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online, test_support::kTestIpv4Addresses[test_support::kIpHostGridA], "uuid-123", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestIpv4Addresses[test_support::kIpServerExternal], {}, test_support::kTestIpv4Addresses[test_support::kIpHostGridA], "00:11:22:33:44:55", test_support::kTestPorts[test_support::kPortResolvedHttps], 0}, + }); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::open_context_menu); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.modal.visible); + EXPECT_EQ(viewModel.modal.title, "Host Details"); + ASSERT_GE(viewModel.modal.lines.size(), 5U); + EXPECT_EQ(viewModel.modal.lines[0], "Name: Living Room PC"); + EXPECT_EQ(viewModel.modal.lines[1], "State: ONLINE"); + EXPECT_EQ(viewModel.modal.lines[2], "Active Address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); + } + + TEST(ShellViewTest, BuildsDedicatedLogViewerModalState) { + app::ClientState state = app::create_initial_state(); + app::set_log_file_path(state, R"(E:\UDATA\12345678\moonlight.log)"); + state.settings.logViewerPlacement = app::LogViewerPlacement::right; + app::apply_log_viewer_contents(state, { + "[000001] [INFO] app: Entered shell", + "[000002] [WARN] network: No active stream", + }, + "Loaded log file preview"); + state.settings.logViewerScrollOffset = 1; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.modal.visible); + EXPECT_TRUE(viewModel.logViewer.visible); + EXPECT_EQ(viewModel.modal.title, "Log File"); + EXPECT_EQ(viewModel.logViewer.path, "E:\\UDATA\\12345678\\moonlight.log"); + EXPECT_EQ(viewModel.logViewer.placement, app::LogViewerPlacement::right); + EXPECT_EQ(viewModel.logViewer.scrollOffset, 1U); + ASSERT_EQ(viewModel.logViewer.lines.size(), 2U); + EXPECT_EQ(viewModel.logViewer.lines[0], "[000001] [INFO] app: Entered shell"); + ASSERT_EQ(viewModel.modal.footerActions.size(), 6U); + EXPECT_EQ(viewModel.modal.footerActions[0].iconAssetPath, "icons\\button-lb.svg"); + EXPECT_EQ(viewModel.modal.footerActions[5].iconAssetPath, "icons\\button-b.svg"); + } + + TEST(ShellViewTest, RendersSavedFileSizesUsingByteKilobyteAndMegabyteLabels) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::settings; + state.settings.selectedCategory = app::SettingsCategory::reset; + state.settings.savedFiles = { + {"E:\\UDATA\\bytes.bin", "bytes.bin", 12U}, + {"E:\\UDATA\\kb.bin", "kb.bin", 1025U}, + {"E:\\UDATA\\mb.bin", "mb.bin", 1048577U}, + }; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_GE(viewModel.content.bodyLines.size(), 4U); + EXPECT_EQ(viewModel.content.bodyLines[1], "Saved files on disk:"); + EXPECT_EQ(viewModel.content.bodyLines[2], "- bytes.bin (12 B)"); + EXPECT_EQ(viewModel.content.bodyLines[3], "- kb.bin (2 KB)"); + EXPECT_EQ(viewModel.content.bodyLines[4], "- mb.bin (2 MB)"); + } + + TEST(ShellViewTest, ShowsHiddenAndFavoriteAppBadgesWhenHiddenAppsAreVisible) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::apps; + state.hosts.activeLoaded = true; + state.apps.showHiddenApps = true; + state.hosts.active = { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::paired, + app::HostReachability::online, + }; + state.hosts.active.apps = { + {"Favorite App", 101, true, false, true, "favorite-cover", true, false}, + {"Hidden App", 102, false, true, false, "hidden-cover", false, false}, + }; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_EQ(viewModel.content.appTiles.size(), 2U); + EXPECT_EQ(viewModel.content.appTiles[0].badgeLabel, "Favorite"); + EXPECT_EQ(viewModel.content.appTiles[1].badgeLabel, "Hidden"); + } + + TEST(ShellViewTest, BuildsThePortKeypadAndDefaultPortLabel) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.activeField = app::AddHostField::port; + state.addHostDraft.keypad.visible = true; + state.addHostDraft.keypad.selectedButtonIndex = 9U; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.keypad.visible); + EXPECT_EQ(viewModel.keypad.title, "Port Keypad"); + ASSERT_GE(viewModel.keypad.lines.size(), 4U); + EXPECT_EQ(viewModel.keypad.lines[0], "Editing field: Port"); + EXPECT_EQ(viewModel.keypad.lines[1], "Staged value: default (47989)"); + ASSERT_EQ(viewModel.keypad.buttons.size(), 10U); + EXPECT_EQ(viewModel.keypad.buttons.back().label, "0"); + EXPECT_TRUE(viewModel.keypad.buttons[9].selected); + } + + TEST(ShellViewTest, BuildsSupportModalFooterActionsWithControllerIcons) { + app::ClientState state = app::create_initial_state(); + state.modal.id = app::ModalId::support; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.modal.visible); + EXPECT_EQ(viewModel.modal.title, "Support"); + ASSERT_EQ(viewModel.modal.footerActions.size(), 2U); + EXPECT_EQ(viewModel.modal.footerActions[0].iconAssetPath, "icons\\button-a.svg"); + EXPECT_EQ(viewModel.modal.footerActions[0].secondaryIconAssetPath, "icons\\button-start.svg"); + EXPECT_EQ(viewModel.modal.footerActions[1].iconAssetPath, "icons\\button-b.svg"); + } + + TEST(ShellViewTest, BuildsConfirmationModalFooterActionsForResetDialogs) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::settings; + state.modal.id = app::ModalId::confirmation; + state.confirmation.action = app::ConfirmationAction::factory_reset; + state.confirmation.title = "Factory Reset"; + state.confirmation.lines = { + "Delete all Moonlight saved data?", + "This removes hosts, logs, pairing identity, and cached cover art.", + }; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.modal.visible); + EXPECT_EQ(viewModel.modal.title, "Factory Reset"); + ASSERT_EQ(viewModel.modal.footerActions.size(), 2U); + EXPECT_EQ(viewModel.modal.footerActions[0].label, "OK"); + EXPECT_EQ(viewModel.modal.footerActions[0].iconAssetPath, "icons\\button-a.svg"); + EXPECT_EQ(viewModel.modal.footerActions[1].label, "Cancel"); + EXPECT_EQ(viewModel.modal.footerActions[1].iconAssetPath, "icons\\button-b.svg"); + } + + TEST(ShellViewTest, ShowsNotificationsOnTheSettingsScreen) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::settings; + state.shell.statusMessage = "Deleted saved file moonlight.log"; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.notification.visible); + EXPECT_EQ(viewModel.notification.content.message, "Deleted saved file moonlight.log"); + } + + TEST(ShellViewTest, ShowsThePairingPinAsSoonAsItHasBeenGenerated) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::pair_host; + state.pairingDraft = { + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + test_support::kTestPorts[test_support::kPortPairing], + "1234", + app::PairingStage::pin_ready, + "Enter the PIN on the host. Pairing will continue automatically." + }; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_GE(viewModel.content.bodyLines.size(), 5U); + EXPECT_EQ(viewModel.content.bodyLines[0], "Target host: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); + EXPECT_EQ(viewModel.content.bodyLines[1], "Target port: " + std::to_string(test_support::kTestPorts[test_support::kPortPairing])); + EXPECT_EQ(viewModel.content.bodyLines[2], "PIN: 1234"); + EXPECT_EQ(viewModel.content.bodyLines[3], "Enter the PIN on the host."); + EXPECT_EQ(viewModel.content.bodyLines[4], "Status: Enter the PIN on the host. Pairing will continue automatically."); + EXPECT_FALSE(viewModel.notification.visible); + } + + TEST(ShellViewTest, DescribesTheInitialPairHostReachabilityCheckBeforeAPinExists) { + app::ClientState state = app::create_initial_state(); + state.shell.activeScreen = app::ScreenId::pair_host; + state.pairingDraft = { + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + test_support::kTestPorts[test_support::kPortPairing], + {}, + app::PairingStage::idle, + "Pairing is preparing the client identity", + }; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_GE(viewModel.content.bodyLines.size(), 3U); + EXPECT_EQ(viewModel.content.bodyLines[0], "Target host: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); + EXPECT_EQ(viewModel.content.bodyLines[1], "Checking whether the host is reachable before showing a PIN."); + EXPECT_EQ(viewModel.content.bodyLines[2], "Status: Pairing is preparing the client identity"); + } + + TEST(ShellViewTest, AddsStatsAndRecentLogsToTheOverlayWhenVisible) { + app::ClientState state = app::create_initial_state(); + state.shell.overlayVisible = true; + + const std::vector entries = { + {1, logging::LogLevel::info, "app", "Entered shell"}, + {2, logging::LogLevel::warning, "network", "No active stream"}, + }; + const std::vector statsLines = { + "Stream: 1280x720 @ 60 FPS", + "Connection: Okay", + }; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, entries, statsLines); + + EXPECT_TRUE(viewModel.overlay.visible); + EXPECT_EQ(viewModel.overlay.title, "Diagnostics"); + ASSERT_GE(viewModel.overlay.lines.size(), 4U); + EXPECT_EQ(viewModel.overlay.lines[0], "Stream: 1280x720 @ 60 FPS"); + EXPECT_EQ(viewModel.overlay.lines[1], "Connection: Okay"); + EXPECT_EQ(viewModel.overlay.lines[2], "[INFO] app: Entered shell"); + EXPECT_EQ(viewModel.overlay.lines[3], "[WARN] network: No active stream"); + } + + TEST(ShellViewTest, CanScrollBackToEarlierLogEntriesInTheOverlay) { + app::ClientState state = app::create_initial_state(); + state.shell.overlayVisible = true; + state.shell.overlayScrollOffset = 2; + + std::vector entries; + entries.reserve(14); + for (uint64_t index = 0; index < 14; ++index) { + entries.push_back({index + 1, logging::LogLevel::info, "app", "entry-" + std::to_string(index)}); + } + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, entries); + + ASSERT_FALSE(viewModel.overlay.lines.empty()); + EXPECT_EQ(viewModel.overlay.lines.front(), "Showing earlier log entries"); + } + + TEST(ShellViewTest, OverlayShowsAFallbackMessageWhenNoStreamStatsAreAvailable) { + app::ClientState state = app::create_initial_state(); + state.shell.overlayVisible = true; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_FALSE(viewModel.overlay.lines.empty()); + EXPECT_EQ(viewModel.overlay.lines.front(), "No active stream"); + } + +} // namespace diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index 63eb7a1..7a5bc83 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit 63eb7a1192d0c93b0f2f4d70d885351a46d757ba +Subproject commit 7a5bc83e4fd8bf41177f211e11f011ed9976b61f diff --git a/third-party/nxdk b/third-party/nxdk index fcb7124..acd2f9b 160000 --- a/third-party/nxdk +++ b/third-party/nxdk @@ -1 +1 @@ -Subproject commit fcb71243c2c35eaf5c52dadd896b2ffe6ab0fc76 +Subproject commit acd2f9bf2c04486501a2392153da1e1d650d4935 diff --git a/third-party/openssl b/third-party/openssl index cd1e967..e04bd34 160000 --- a/third-party/openssl +++ b/third-party/openssl @@ -1 +1 @@ -Subproject commit cd1e967483577ec4a42ebe75942894c10655e2b6 +Subproject commit e04bd3433fd84e1861bf258ea37928d9845e6a86 diff --git a/third-party/tomlplusplus b/third-party/tomlplusplus new file mode 160000 index 0000000..1c8b746 --- /dev/null +++ b/third-party/tomlplusplus @@ -0,0 +1 @@ +Subproject commit 1c8b7466e4946fcc3bf20484c0e1d001202cca5a diff --git a/xbe/assets/README.md b/xbe/assets/README.md new file mode 100644 index 0000000..3d07a36 --- /dev/null +++ b/xbe/assets/README.md @@ -0,0 +1,2 @@ +SDL_image does not accept "text" parameters in SVG images, due to a limitation of nanosvg. +We must convert images so they do not contain any text parameters. diff --git a/xbe/assets/icons/add-host.svg b/xbe/assets/icons/add-host.svg new file mode 100644 index 0000000..faf3c83 --- /dev/null +++ b/xbe/assets/icons/add-host.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xbe/assets/icons/button-a.svg b/xbe/assets/icons/button-a.svg new file mode 100644 index 0000000..09fd52e --- /dev/null +++ b/xbe/assets/icons/button-a.svg @@ -0,0 +1,4 @@ + + + + diff --git a/xbe/assets/icons/button-b.svg b/xbe/assets/icons/button-b.svg new file mode 100644 index 0000000..c0c941a --- /dev/null +++ b/xbe/assets/icons/button-b.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/xbe/assets/icons/button-lb.svg b/xbe/assets/icons/button-lb.svg new file mode 100644 index 0000000..f5850bf --- /dev/null +++ b/xbe/assets/icons/button-lb.svg @@ -0,0 +1,3 @@ + + + diff --git a/xbe/assets/icons/button-lt.svg b/xbe/assets/icons/button-lt.svg new file mode 100644 index 0000000..3acf5ff --- /dev/null +++ b/xbe/assets/icons/button-lt.svg @@ -0,0 +1,3 @@ + + + diff --git a/xbe/assets/icons/button-rb.svg b/xbe/assets/icons/button-rb.svg new file mode 100644 index 0000000..1fb2769 --- /dev/null +++ b/xbe/assets/icons/button-rb.svg @@ -0,0 +1,3 @@ + + + diff --git a/xbe/assets/icons/button-rt.svg b/xbe/assets/icons/button-rt.svg new file mode 100644 index 0000000..99183a6 --- /dev/null +++ b/xbe/assets/icons/button-rt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/xbe/assets/icons/button-select.svg b/xbe/assets/icons/button-select.svg new file mode 100644 index 0000000..f886b0c --- /dev/null +++ b/xbe/assets/icons/button-select.svg @@ -0,0 +1,7 @@ + + + diff --git a/xbe/assets/icons/button-start.svg b/xbe/assets/icons/button-start.svg new file mode 100644 index 0000000..6eda3a3 --- /dev/null +++ b/xbe/assets/icons/button-start.svg @@ -0,0 +1,7 @@ + + + diff --git a/xbe/assets/icons/button-x.svg b/xbe/assets/icons/button-x.svg new file mode 100644 index 0000000..6c1f577 --- /dev/null +++ b/xbe/assets/icons/button-x.svg @@ -0,0 +1,3 @@ + + + diff --git a/xbe/assets/icons/button-y.svg b/xbe/assets/icons/button-y.svg new file mode 100644 index 0000000..36a58f5 --- /dev/null +++ b/xbe/assets/icons/button-y.svg @@ -0,0 +1,3 @@ + + + diff --git a/xbe/assets/icons/gear.svg b/xbe/assets/icons/gear.svg new file mode 100644 index 0000000..8630d4e --- /dev/null +++ b/xbe/assets/icons/gear.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/xbe/assets/icons/host-monitor-offline.svg b/xbe/assets/icons/host-monitor-offline.svg new file mode 100644 index 0000000..d7c8537 --- /dev/null +++ b/xbe/assets/icons/host-monitor-offline.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/xbe/assets/icons/host-monitor-online.svg b/xbe/assets/icons/host-monitor-online.svg new file mode 100644 index 0000000..fe7924d --- /dev/null +++ b/xbe/assets/icons/host-monitor-online.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/xbe/assets/icons/host-monitor-pairing.svg b/xbe/assets/icons/host-monitor-pairing.svg new file mode 100644 index 0000000..24111cd --- /dev/null +++ b/xbe/assets/icons/host-monitor-pairing.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xbe/assets/icons/support.svg b/xbe/assets/icons/support.svg new file mode 100644 index 0000000..269c89d --- /dev/null +++ b/xbe/assets/icons/support.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/xbe/assets/moonlight-logo-wedges.svg b/xbe/assets/moonlight-logo-wedges.svg new file mode 100644 index 0000000..c45a922 --- /dev/null +++ b/xbe/assets/moonlight-logo-wedges.svg @@ -0,0 +1,4 @@ + + + + diff --git a/xbe/assets/moonlight-logo.svg b/xbe/assets/moonlight-logo.svg index 952d8a6..5af1bb1 100644 --- a/xbe/assets/moonlight-logo.svg +++ b/xbe/assets/moonlight-logo.svg @@ -1,5 +1,5 @@ - +