From c176ea4040a970ba94272977f5b3bac87d891eea Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 4 Apr 2026 09:30:21 -0400 Subject: [PATCH 01/35] Add client UI/state, tests and Xbox build support Introduce client-side UI and state management plus extensive build and platform adjustments for Xbox/nxdk. Key changes: - Add app UI/state implementation (src/app/*), pairing, host records, networking, startup, logging, input navigation, streaming overlay, and many UI components. - Add compatibility shims for OpenSSL and moonlight-common-c, plus new FindNXDK_SDL2_TTF CMake module and font/logo assets. - Add many unit tests covering app, input, logging, network, startup and UI components (tests/unit/*). - Update CMake build: enable export of compile commands, add xbox mingw64 CMake presets, register SDL2_TTF, include libxboxrt, and pass compat header into moonlight-common-c build. - Revise GetOpenSSL: pin OpenSSL to 1.1.1w, add platform-aware configure/build logic for HOST vs XBOX, set configure options and env vars, and link host system libs when appropriate. - Small cmake/source tweaks: exclude shell_screen from host tests, export compile commands for child builds, and link NXDK::Net into ws2_32 target. - Enhance run-xemu scripts to support network mode and TAP interface, to propagate XEMU-related environment paths, and to include network args when launching xemu. - Update .gitmodules to track OpenSSL OpenSSL_1_1_1-stable branch and update third-party/openssl submodule reference. These changes add core UI/state functionality and platform-specific build/tooling to support building and running on Xbox (nxdk) and improve host-side OpenSSL vendoring. Some new files are large and introduce the initial app logic and tests. --- .gitmodules | 2 +- CMakeLists.txt | 1 + CMakePresets.json | 42 + README.md | 6 +- cmake/modules/FindNXDK.cmake | 1 + cmake/modules/FindNXDK_SDL2_TTF.cmake | 29 + cmake/modules/GetOpenSSL.cmake | 144 +- cmake/nxdk.cmake | 1 + cmake/run-child-build.cmake | 1 + cmake/sources.cmake | 1 + cmake/xbox-build.cmake | 12 +- scripts/run-xemu.cmd | 12 + scripts/run-xemu.sh | 87 +- src/app/client_state.cpp | 869 +++++++++ src/app/client_state.h | 179 ++ src/app/host_records.cpp | 266 +++ src/app/host_records.h | 124 ++ src/app/pairing_flow.cpp | 32 + src/app/pairing_flow.h | 50 + .../moonlight_common_c_compat.h | 10 + src/compat/openssl/conio.h | 32 + src/compat/openssl/fd_compat.cpp | 21 + src/compat/openssl/openssl_apps_compat.h | 162 ++ src/compat/openssl/stat_compat.cpp | 20 + src/compat/openssl/sys/resource.h | 36 + src/input/navigation_input.cpp | 69 + src/input/navigation_input.h | 78 + src/logging/logger.cpp | 113 ++ src/logging/logger.h | 127 ++ src/main.cpp | 87 +- src/network/host_pairing.cpp | 1650 +++++++++++++++++ src/network/host_pairing.h | 47 + src/network/runtime_network.cpp | 119 ++ src/network/runtime_network.h | 31 + src/splash/splash_layout.cpp | 2 + src/splash/splash_screen.cpp | 69 +- src/splash/splash_screen.h | 4 +- src/startup/client_identity_storage.cpp | 233 +++ src/startup/client_identity_storage.h | 32 + src/startup/host_storage.cpp | 188 ++ src/startup/host_storage.h | 32 + src/streaming/stats_overlay.cpp | 84 + src/streaming/stats_overlay.h | 38 + src/ui/menu_model.cpp | 119 ++ src/ui/menu_model.h | 97 + src/ui/shell_screen.cpp | 864 +++++++++ src/ui/shell_screen.h | 25 + src/ui/shell_view.cpp | 256 +++ src/ui/shell_view.h | 65 + tests/CMakeLists.txt | 28 +- tests/support/hal/debug.h | 1 + tests/unit/app/client_state_test.cpp | 262 +++ tests/unit/app/host_records_test.cpp | 106 ++ tests/unit/app/pairing_flow_test.cpp | 28 + tests/unit/input/navigation_input_test.cpp | 30 + tests/unit/logging/logger_test.cpp | 68 + tests/unit/network/host_pairing_test.cpp | 47 + tests/unit/network/runtime_network_test.cpp | 71 + .../startup/client_identity_storage_test.cpp | 78 + tests/unit/startup/host_storage_test.cpp | 93 + tests/unit/streaming/stats_overlay_test.cpp | 59 + tests/unit/ui/menu_model_test.cpp | 85 + tests/unit/ui/shell_view_test.cpp | 173 ++ third-party/openssl | 2 +- xbe/assets/fonts/vegur-regular.ttf | Bin 0 -> 21116 bytes xbe/assets/moonlight-logo-wedges.svg | 4 + xbe/assets/moonlight-logo.svg | 2 +- 67 files changed, 7597 insertions(+), 109 deletions(-) create mode 100644 cmake/modules/FindNXDK_SDL2_TTF.cmake create mode 100644 src/app/client_state.cpp create mode 100644 src/app/client_state.h create mode 100644 src/app/host_records.cpp create mode 100644 src/app/host_records.h create mode 100644 src/app/pairing_flow.cpp create mode 100644 src/app/pairing_flow.h create mode 100644 src/compat/moonlight-common-c/moonlight_common_c_compat.h create mode 100644 src/compat/openssl/conio.h create mode 100644 src/compat/openssl/fd_compat.cpp create mode 100644 src/compat/openssl/openssl_apps_compat.h create mode 100644 src/compat/openssl/stat_compat.cpp create mode 100644 src/compat/openssl/sys/resource.h create mode 100644 src/input/navigation_input.cpp create mode 100644 src/input/navigation_input.h create mode 100644 src/logging/logger.cpp create mode 100644 src/logging/logger.h create mode 100644 src/network/host_pairing.cpp create mode 100644 src/network/host_pairing.h create mode 100644 src/network/runtime_network.cpp create mode 100644 src/network/runtime_network.h create mode 100644 src/startup/client_identity_storage.cpp create mode 100644 src/startup/client_identity_storage.h create mode 100644 src/startup/host_storage.cpp create mode 100644 src/startup/host_storage.h create mode 100644 src/streaming/stats_overlay.cpp create mode 100644 src/streaming/stats_overlay.h create mode 100644 src/ui/menu_model.cpp create mode 100644 src/ui/menu_model.h create mode 100644 src/ui/shell_screen.cpp create mode 100644 src/ui/shell_screen.h create mode 100644 src/ui/shell_view.cpp create mode 100644 src/ui/shell_view.h create mode 100644 tests/unit/app/client_state_test.cpp create mode 100644 tests/unit/app/host_records_test.cpp create mode 100644 tests/unit/app/pairing_flow_test.cpp create mode 100644 tests/unit/input/navigation_input_test.cpp create mode 100644 tests/unit/logging/logger_test.cpp create mode 100644 tests/unit/network/host_pairing_test.cpp create mode 100644 tests/unit/network/runtime_network_test.cpp create mode 100644 tests/unit/startup/client_identity_storage_test.cpp create mode 100644 tests/unit/startup/host_storage_test.cpp create mode 100644 tests/unit/streaming/stats_overlay_test.cpp create mode 100644 tests/unit/ui/menu_model_test.cpp create mode 100644 tests/unit/ui/shell_view_test.cpp create mode 100644 xbe/assets/fonts/vegur-regular.ttf create mode 100644 xbe/assets/moonlight-logo-wedges.svg diff --git a/.gitmodules b/.gitmodules index ee65c5b..219ff13 100644 --- a/.gitmodules +++ b/.gitmodules @@ -16,4 +16,4 @@ [submodule "third-party/openssl"] path = third-party/openssl url = https://github.com/openssl/openssl.git - branch = master + branch = OpenSSL_1_1_1-stable diff --git a/CMakeLists.txt b/CMakeLists.txt index 6811f1d..2d860a2 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) 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..e59c3a4 100644 --- a/README.md +++ b/README.md @@ -230,10 +230,10 @@ 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 - - [ ] Possibly, GPU overclocking, see https://github.com/GXTX/XboxOverclock + - [x] Save config and pairing states + - [x] Host pairing - [x] Docs via doxygen + - [ ] Possibly, GPU overclocking, see https://github.com/GXTX/XboxOverclock
diff --git a/cmake/modules/FindNXDK.cmake b/cmake/modules/FindNXDK.cmake index 444552b..3aafed0 100644 --- a/cmake/modules/FindNXDK.cmake +++ b/cmake/modules/FindNXDK.cmake @@ -136,6 +136,7 @@ if (NOT TARGET NXDK::ws2_32) target_link_libraries( NXDK::ws2_32 INTERFACE + NXDK::Net ws2_32 ) target_include_directories( 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..5cf0715 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -2,12 +2,18 @@ include_guard(GLOBAL) include(ExternalProject) -set(OPENSSL_VERSION 3.2.2) +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") +if(MOONLIGHT_BUILD_KIND STREQUAL "XBOX") + set(MOONLIGHT_OPENSSL_PLATFORM "XBOX") +else() + set(MOONLIGHT_OPENSSL_PLATFORM "HOST") +endif() + file(MAKE_DIRECTORY "${OPENSSL_BUILD_DIR}") file(MAKE_DIRECTORY "${OPENSSL_INSTALL_DIR}/include") file(MAKE_DIRECTORY "${OPENSSL_INSTALL_DIR}/lib") @@ -18,33 +24,86 @@ 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) + +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 + --with-rand-seed=none) -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}") + 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/compat/openssl/openssl_apps_compat.h" + "-I${CMAKE_SOURCE_DIR}/src/compat/openssl" + "-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) + + 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}") +else() + if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(OPENSSL_CONFIGURE_TARGET mingw64) + else() + set(OPENSSL_CONFIGURE_TARGET mingw) + endif() + + if(NOT DEFINED CMAKE_MAKE_PROGRAM OR CMAKE_MAKE_PROGRAM STREQUAL "") + message(FATAL_ERROR "CMAKE_MAKE_PROGRAM must be defined for the host vendored OpenSSL build") + endif() + set(OPENSSL_MAKE_EXECUTABLE "${CMAKE_MAKE_PROGRAM}") + + 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() +endif() ExternalProject_Add(openssl_external SOURCE_DIR "${OPENSSL_SOURCE_DIR}" @@ -52,34 +111,16 @@ ExternalProject_Add(openssl_external CONFIGURE_COMMAND ${OPENSSL_ENV} "${PERL_EXECUTABLE}" "${OPENSSL_SOURCE_DIR}/Configure" - linux-x86 - no-shared - no-tests - no-asm - no-apps - no-comp - 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 + ${OPENSSL_CONFIGURE_TARGET} + ${OPENSSL_CONFIGURE_OPTIONS} "--prefix=${OPENSSL_INSTALL_DIR}" "--openssldir=${OPENSSL_INSTALL_DIR}/ssl" BUILD_COMMAND ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} + ${OPENSSL_MAKE_EXECUTABLE} build_libs INSTALL_COMMAND ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} install_sw + ${OPENSSL_MAKE_EXECUTABLE} install_dev BUILD_BYPRODUCTS "${OPENSSL_INSTALL_DIR}/lib/libcrypto.a" "${OPENSSL_INSTALL_DIR}/lib/libssl.a" @@ -104,6 +145,9 @@ if(NOT TARGET OpenSSL::Crypto) set_target_properties(OpenSSL::Crypto PROPERTIES IMPORTED_LOCATION "${OPENSSL_CRYPTO_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${OPENSSL_INCLUDE_DIR}") + if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "HOST" AND (WIN32 OR MINGW OR CMAKE_HOST_WIN32)) + target_link_libraries(OpenSSL::Crypto INTERFACE ws2_32 crypt32 gdi32 advapi32 user32) + endif() add_dependencies(OpenSSL::Crypto openssl_external) endif() @@ -116,5 +160,7 @@ if(NOT TARGET OpenSSL::SSL) add_dependencies(OpenSSL::SSL openssl_external) endif() +message(STATUS "OpenSSL version: ${OPENSSL_VERSION}") +message(STATUS "OpenSSL platform: ${MOONLIGHT_OPENSSL_PLATFORM}") message(STATUS "OpenSSL source dir: ${OPENSSL_SOURCE_DIR}") message(STATUS "OpenSSL include dir: ${OPENSSL_INCLUDE_DIR}") 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..0474c57 100644 --- a/cmake/sources.cmake +++ b/cmake/sources.cmake @@ -13,6 +13,7 @@ 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..77e8e54 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -22,6 +22,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 @@ -46,6 +47,8 @@ set(CMAKE_C_FLAGS_RELEASE "-O2") # moonlight-common-c submodule include(GetOpenSSL REQUIRED) +set(MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_HEADER + "${CMAKE_SOURCE_DIR}/src/compat/moonlight-common-c/moonlight_common_c_compat.h") 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") @@ -55,7 +58,10 @@ 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_compile_options(moonlight-common-c PRIVATE + -include "${MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_HEADER}" + -Wno-unused-function + -Wno-error=unused-function) target_link_libraries(moonlight-common-c PRIVATE NXDK::ws2_32) endif() @@ -70,8 +76,12 @@ target_link_libraries(${CMAKE_PROJECT_NAME} PUBLIC NXDK::NXDK NXDK::NXDK_CXX + NXDK::ws2_32 NXDK::SDL2 NXDK::SDL2_Image + NXDK::SDL2_TTF + OpenSSL::Crypto + OpenSSL::SSL ) target_compile_options(${CMAKE_PROJECT_NAME} PRIVATE 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..fc5bcb4 100644 --- a/scripts/run-xemu.sh +++ b/scripts/run-xemu.sh @@ -7,12 +7,14 @@ 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 EOF return 0 } @@ -69,6 +71,8 @@ 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[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 +83,60 @@ write_xemu_config() { return 0 } +prepare_xemu_runtime_environment() { + if is_windows; then + if [[ -n "${XEMU_APPDATA:-}" ]]; then + export APPDATA="$(to_native_path "$XEMU_APPDATA")" + fi + if [[ -n "${XEMU_LOCALAPPDATA:-}" ]]; then + export LOCALAPPDATA="$(to_native_path "$XEMU_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 +265,9 @@ 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=() if [[ -n "${MOONLIGHT_XEMU_BUILD_DIR:-}" ]]; then build_dir="$(resolve_build_dir "$MOONLIGHT_XEMU_BUILD_DIR")" @@ -243,6 +304,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 +399,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 +413,11 @@ 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 exit 0 fi -exec "$xemu_exe" -config_path "$xemu_config_path" -dvd_path "$iso_path" -no-user-config +exec "$xemu_exe" -config_path "$xemu_config_path" -dvd_path "$iso_path" -no-user-config "${xemu_network_args[@]}" diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp new file mode 100644 index 0000000..b16d0ad --- /dev/null +++ b/src/app/client_state.cpp @@ -0,0 +1,869 @@ +// class header include +#include "src/app/client_state.h" + +// standard includes +#include +#include +#include +#include +#include + +namespace { + + constexpr const char *HOST_MENU_ID_PREFIX = "host:"; + constexpr std::size_t OVERLAY_SCROLL_STEP = 4; + constexpr std::size_t ADD_HOST_KEYPAD_COLUMN_COUNT = 3; + + struct AddHostKeypadButton { + const char *id; + char character; + }; + + std::string generate_pairing_pin() { + static std::mt19937 generator(std::random_device {}()); + std::uniform_int_distribution distribution(0, 9999); + + std::string pin = std::to_string(distribution(generator)); + while (pin.size() < 4U) { + pin.insert(pin.begin(), '0'); + } + + return pin; + } + + std::string build_host_menu_id(const std::string &address, uint16_t port) { + return std::string(HOST_MENU_ID_PREFIX) + address + ":" + std::to_string(app::effective_host_port(port)); + } + + bool parse_host_menu_id(const std::string &itemId, std::string *address, uint16_t *port) { + if (itemId.rfind(HOST_MENU_ID_PREFIX, 0) != 0) { + return false; + } + + const std::string endpoint = itemId.substr(std::char_traits::length(HOST_MENU_ID_PREFIX)); + const std::size_t separatorIndex = endpoint.find_last_of(':'); + if (separatorIndex == std::string::npos) { + return false; + } + + uint16_t parsedPort = 0; + if (!app::try_parse_host_port(endpoint.substr(separatorIndex + 1), &parsedPort)) { + return false; + } + + if (address != nullptr) { + *address = endpoint.substr(0, separatorIndex); + } + if (port != nullptr) { + *port = parsedPort; + } + return true; + } + + std::string build_endpoint_label(const std::string &address, uint16_t port) { + if (app::effective_host_port(port) == app::DEFAULT_HOST_PORT) { + return address; + } + + return address + ":" + std::to_string(app::effective_host_port(port)); + } + + std::string default_add_host_address() { + return "192.168.0.10"; + } + + const app::HostRecord *find_host_by_endpoint(const std::vector &hosts, const std::string &address, uint16_t port) { + const uint16_t effectivePort = app::effective_host_port(port); + const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, effectivePort](const app::HostRecord &host) { + return host.address == address && app::effective_host_port(host.port) == effectivePort; + }); + if (iterator == hosts.end()) { + return nullptr; + } + + return &(*iterator); + } + + app::HostRecord *find_host_by_endpoint(std::vector &hosts, const std::string &address, uint16_t port) { + const uint16_t effectivePort = app::effective_host_port(port); + const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, effectivePort](const app::HostRecord &host) { + return host.address == address && app::effective_host_port(host.port) == effectivePort; + }); + if (iterator == hosts.end()) { + return nullptr; + } + + return &(*iterator); + } + + const app::HostRecord *selected_host_for_menu(const app::ClientState &state) { + if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) { + std::string address; + uint16_t port = 0; + if (parse_host_menu_id(selectedItem->id, &address, &port)) { + return find_host_by_endpoint(state.hosts, address, port); + } + } + + if (!state.hosts.empty()) { + return &state.hosts.front(); + } + + return nullptr; + } + + std::vector build_add_host_keypad_buttons(const app::ClientState &state) { + if (state.addHostDraft.activeField == app::AddHostField::address) { + return { + {"keypad-1", '1'}, + {"keypad-2", '2'}, + {"keypad-3", '3'}, + {"keypad-4", '4'}, + {"keypad-5", '5'}, + {"keypad-6", '6'}, + {"keypad-7", '7'}, + {"keypad-8", '8'}, + {"keypad-9", '9'}, + {"keypad-dot", '.'}, + {"keypad-0", '0'}, + }; + } + + return { + {"keypad-1", '1'}, + {"keypad-2", '2'}, + {"keypad-3", '3'}, + {"keypad-4", '4'}, + {"keypad-5", '5'}, + {"keypad-6", '6'}, + {"keypad-7", '7'}, + {"keypad-8", '8'}, + {"keypad-9", '9'}, + {"keypad-0", '0'}, + }; + } + + std::string add_host_field_menu_id(app::AddHostField field) { + return field == app::AddHostField::address ? "edit-address" : "edit-port"; + } + + void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen) { + state.addHostDraft = { + default_add_host_address(), + {}, + app::AddHostField::address, + {false, 0U, {}}, + returnScreen, + {}, + {}, + false, + }; + } + + std::vector build_menu_for_state(const app::ClientState &state) { + switch (state.activeScreen) { + case app::ScreenId::home: + return { + {"hosts", "Hosts", true}, + {"add-host", "Add Host", true}, + {"settings", "Settings", true}, + {"exit", "Exit", true}, + }; + case app::ScreenId::hosts: + { + const app::HostRecord *selectedHost = selected_host_for_menu(state); + std::vector items; + items.reserve(state.hosts.size() + 6); + + for (const app::HostRecord &host : state.hosts) { + items.push_back({ + build_host_menu_id(host.address, host.port), + host.displayName + (host.pairingState == app::PairingState::paired ? " [paired]" : " [not paired]"), + true, + }); + } + + items.push_back({"add-host", "Add Host", true}); + items.push_back({"test-connection", "Test Selected Host", selectedHost != nullptr}); + items.push_back({"pair-host", "Pair Selected Host", selectedHost != nullptr}); + items.push_back({"delete-host", "Delete Selected Host", selectedHost != nullptr}); + items.push_back({"discover-hosts", "Discover Hosts (soon)", false}); + items.push_back({"back-home", "Back", true}); + return items; + } + case app::ScreenId::add_host: + return { + {"edit-address", "Host Address: " + state.addHostDraft.addressInput, true}, + {"edit-port", std::string("Port: ") + (state.addHostDraft.portInput.empty() ? "default (47984)" : state.addHostDraft.portInput), true}, + {"clear-field", "Clear Current Field", state.addHostDraft.activeField == app::AddHostField::address + ? !state.addHostDraft.addressInput.empty() + : !state.addHostDraft.portInput.empty()}, + {"use-default-port", "Use Default Port", state.addHostDraft.activeField == app::AddHostField::port && !state.addHostDraft.portInput.empty()}, + {"test-connection", "Test Connection", true}, + {"start-pairing", "Start Pairing", true}, + {"save-host", "Save Host", true}, + {"cancel-add-host", "Cancel", true}, + }; + case app::ScreenId::pair_host: + return { + {"cancel-pairing", "Cancel", true}, + }; + case app::ScreenId::settings: + return { + {"display-settings", "Display", true}, + {"input-settings", "Input", true}, + {"logging-settings", "Logging", true}, + {"back-home", "Back", true}, + }; + } + + return {}; + } + + void rebuild_menu(app::ClientState &state, const std::string &preferredItemId = {}, bool preserveCurrentSelection = true) { + const std::string currentSelection = preserveCurrentSelection && 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); + if (state.menu.selected_item() != nullptr && state.menu.selected_item()->id == preferredItemId) { + return; + } + } + + if (!currentSelection.empty()) { + state.menu.select_item_by_id(currentSelection); + } + } + + void set_screen(app::ClientState &state, app::ScreenId screen, const std::string &preferredItemId = {}) { + state.activeScreen = screen; + rebuild_menu(state, preferredItemId, false); + } + + void open_add_host_keypad(app::ClientState &state, app::AddHostField field) { + state.addHostDraft.activeField = field; + state.addHostDraft.keypad.visible = true; + state.addHostDraft.keypad.selectedButtonIndex = 0; + state.addHostDraft.keypad.stagedInput = field == app::AddHostField::address + ? state.addHostDraft.addressInput + : state.addHostDraft.portInput; + state.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.statusMessage = "Updated host address"; + } + else { + state.addHostDraft.portInput = state.addHostDraft.keypad.stagedInput; + state.statusMessage = state.addHostDraft.portInput.empty() + ? "Using default Moonlight host port 47984" + : "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.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 std::vector buttons = build_add_host_keypad_buttons(state); + if (buttons.empty()) { + return false; + } + + const int rowCount = static_cast((buttons.size() + ADD_HOST_KEYPAD_COLUMN_COUNT - 1) / ADD_HOST_KEYPAD_COLUMN_COUNT); + const std::size_t currentIndex = state.addHostDraft.keypad.selectedButtonIndex % buttons.size(); + const int currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT); + const int currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT); + const int nextRow = (currentRow + rowCount + rowDelta) % rowCount; + int nextColumn = (currentColumn + static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT) + columnDelta) % static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT); + int nextIndex = (nextRow * static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT)) + nextColumn; + while (nextIndex >= static_cast(buttons.size()) && nextColumn > 0) { + --nextColumn; + nextIndex = (nextRow * static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT)) + nextColumn; + } + + state.addHostDraft.keypad.selectedButtonIndex = nextIndex >= 0 && nextIndex < static_cast(buttons.size()) + ? static_cast(nextIndex) + : currentIndex; + return state.addHostDraft.keypad.selectedButtonIndex != currentIndex; + } + + std::string *active_add_host_input_buffer(app::ClientState &state) { + if (state.addHostDraft.keypad.visible) { + return &state.addHostDraft.keypad.stagedInput; + } + + return state.addHostDraft.activeField == app::AddHostField::address + ? &state.addHostDraft.addressInput + : &state.addHostDraft.portInput; + } + + bool append_to_active_add_host_field(app::ClientState &state, char character) { + std::string *target = active_add_host_input_buffer(state); + + if (state.addHostDraft.activeField == app::AddHostField::address) { + if ((character < '0' || character > '9') && character != '.') { + return false; + } + if (target->size() >= 15) { + return false; + } + } + else { + if (character < '0' || character > '9') { + return false; + } + if (target->size() >= 5) { + return false; + } + } + + target->push_back(character); + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + state.statusMessage = state.addHostDraft.activeField == app::AddHostField::address + ? "Editing host address" + : "Editing host port"; + return true; + } + + bool backspace_active_add_host_field(app::ClientState &state) { + std::string *target = active_add_host_input_buffer(state); + if (target->empty()) { + return false; + } + + target->pop_back(); + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + return true; + } + + void clear_active_add_host_field(app::ClientState &state) { + if (state.addHostDraft.activeField == app::AddHostField::address) { + state.addHostDraft.addressInput.clear(); + } + else { + state.addHostDraft.portInput.clear(); + } + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + } + + 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 address"; + } + return false; + } + + uint16_t port = 0; + if (!app::try_parse_host_port(state.addHostDraft.portInput, &port)) { + if (errorMessage != nullptr) { + *errorMessage = "Enter a valid TCP port or leave it empty for the default"; + } + return false; + } + + if (normalizedAddress != nullptr) { + *normalizedAddress = address; + } + if (parsedPort != nullptr) { + *parsedPort = port; + } + return true; + } + + void enter_add_host_screen(app::ClientState &state) { + reset_add_host_draft(state, state.activeScreen); + set_screen(state, app::ScreenId::add_host, "edit-address"); + } + + void enter_pair_host_screen(app::ClientState &state, const std::string &targetAddress, uint16_t targetPort) { + state.pairingDraft = app::create_pairing_draft(targetAddress, app::effective_host_port(targetPort), generate_pairing_pin()); + state.pairingDraft.stage = app::PairingStage::in_progress; + set_screen(state, app::ScreenId::pair_host, "cancel-pairing"); + } + +} // namespace + +namespace app { + + ClientState create_initial_state() { + ClientState state { + ScreenId::home, + false, + false, + false, + 0U, + ui::MenuModel(), + {}, + {default_add_host_address(), {}, AddHostField::address, {false, 0U, {}}, ScreenId::home, {}, {}, false}, + {{}, DEFAULT_HOST_PORT, {}, PairingStage::idle, {}}, + {}, + }; + rebuild_menu(state); + return state; + } + + const char *to_string(ScreenId screen) { + switch (screen) { + case ScreenId::home: + return "home"; + case ScreenId::hosts: + return "hosts"; + 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 = std::move(hosts); + state.hostsDirty = false; + state.statusMessage = std::move(statusMessage); + + if (state.activeScreen == ScreenId::hosts || state.activeScreen == ScreenId::home) { + rebuild_menu(state); + } + else if (state.activeScreen == ScreenId::pair_host) { + if (find_host_by_endpoint(state.hosts, state.pairingDraft.targetAddress, state.pairingDraft.targetPort) == nullptr) { + set_screen(state, ScreenId::hosts); + } + else { + rebuild_menu(state); + } + } + } + + 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; + if (!try_parse_host_port(state.addHostDraft.portInput, &port)) { + return DEFAULT_HOST_PORT; + } + + return effective_host_port(port); + } + + 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.activeScreen == ScreenId::add_host) { + state.addHostDraft.connectionMessage = message; + state.addHostDraft.lastConnectionSucceeded = success; + } + state.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; + state.pairingDraft.statusMessage = message; + state.statusMessage = std::move(message); + + if (!success) { + return false; + } + + HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + if (host == nullptr || host->pairingState == PairingState::paired) { + return false; + } + + host->pairingState = PairingState::paired; + state.hostsDirty = true; + return true; + } + + const HostRecord *selected_host(const ClientState &state) { + if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) { + std::string address; + uint16_t port = 0; + if (parse_host_menu_id(selectedItem->id, &address, &port)) { + return find_host_by_endpoint(state.hosts, address, port); + } + } + + return nullptr; + } + + AppUpdate handle_command(ClientState &state, input::UiCommand command) { + AppUpdate update {}; + update.connectionTestPort = 0; + update.pairingPort = 0; + + if (command == input::UiCommand::toggle_overlay) { + state.overlayVisible = !state.overlayVisible; + if (!state.overlayVisible) { + state.overlayScrollOffset = 0; + } + update.overlayChanged = true; + update.overlayVisibilityChanged = true; + return update; + } + + if (state.overlayVisible) { + if (command == input::UiCommand::previous_page) { + state.overlayScrollOffset += OVERLAY_SCROLL_STEP; + update.overlayChanged = true; + return update; + } + + if (command == input::UiCommand::next_page) { + state.overlayScrollOffset = state.overlayScrollOffset > OVERLAY_SCROLL_STEP + ? state.overlayScrollOffset - OVERLAY_SCROLL_STEP + : 0; + update.overlayChanged = true; + return update; + } + } + + if (state.activeScreen == ScreenId::add_host && state.addHostDraft.keypad.visible) { + switch (command) { + case input::UiCommand::move_up: + move_add_host_keypad_selection(state, -1, 0); + return update; + case input::UiCommand::move_down: + move_add_host_keypad_selection(state, 1, 0); + return update; + case input::UiCommand::move_left: + move_add_host_keypad_selection(state, 0, -1); + return update; + case input::UiCommand::move_right: + move_add_host_keypad_selection(state, 0, 1); + return update; + case input::UiCommand::back: + cancel_add_host_keypad(state); + return update; + case input::UiCommand::activate: + { + const std::vector buttons = build_add_host_keypad_buttons(state); + if (buttons.empty()) { + return update; + } + + const AddHostKeypadButton &button = buttons[state.addHostDraft.keypad.selectedButtonIndex % buttons.size()]; + append_to_active_add_host_field(state, button.character); + + rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField)); + return update; + } + case input::UiCommand::confirm: + accept_add_host_keypad(state); + return update; + case input::UiCommand::delete_character: + backspace_active_add_host_field(state); + rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField)); + return update; + case input::UiCommand::none: + case input::UiCommand::previous_page: + case input::UiCommand::next_page: + case input::UiCommand::toggle_overlay: + break; + } + } + + const ui::MenuUpdate menuUpdate = state.menu.handle_command(command); + if (state.activeScreen == ScreenId::hosts && menuUpdate.selectionChanged) { + rebuild_menu(state); + } + + if (menuUpdate.overlayToggleRequested) { + state.overlayVisible = !state.overlayVisible; + if (!state.overlayVisible) { + state.overlayScrollOffset = 0; + } + update.overlayChanged = true; + update.overlayVisibilityChanged = true; + } + + if (menuUpdate.backRequested && state.activeScreen != ScreenId::home) { + if (state.activeScreen == ScreenId::add_host) { + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + set_screen(state, state.addHostDraft.returnScreen == ScreenId::add_host ? ScreenId::home : state.addHostDraft.returnScreen, state.addHostDraft.returnScreen == ScreenId::hosts ? "add-host" : std::string {}); + } + else if (state.activeScreen == ScreenId::pair_host) { + set_screen(state, ScreenId::hosts, build_host_menu_id(state.pairingDraft.targetAddress, state.pairingDraft.targetPort)); + } + else { + set_screen(state, ScreenId::home); + } + update.screenChanged = true; + return update; + } + + if (!menuUpdate.activationRequested) { + return update; + } + + update.activatedItemId = menuUpdate.activatedItemId; + + if (menuUpdate.activatedItemId == "hosts") { + set_screen(state, ScreenId::hosts); + update.screenChanged = true; + return update; + } + + if (menuUpdate.activatedItemId == "add-host") { + enter_add_host_screen(state); + update.screenChanged = true; + return update; + } + + if (menuUpdate.activatedItemId == "settings") { + set_screen(state, ScreenId::settings); + update.screenChanged = true; + return update; + } + + if (menuUpdate.activatedItemId == "back-home") { + set_screen(state, ScreenId::home); + update.screenChanged = true; + return update; + } + + if (state.activeScreen == ScreenId::add_host) { + if (menuUpdate.activatedItemId == "edit-address") { + open_add_host_keypad(state, AddHostField::address); + return update; + } + + if (menuUpdate.activatedItemId == "edit-port") { + open_add_host_keypad(state, AddHostField::port); + return update; + } + + if (menuUpdate.activatedItemId == "clear-field") { + clear_active_add_host_field(state); + rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField)); + return update; + } + + if (menuUpdate.activatedItemId == "use-default-port") { + state.addHostDraft.portInput.clear(); + state.statusMessage = "Using default Moonlight host port 47984"; + rebuild_menu(state, "edit-port"); + return update; + } + + std::string normalizedAddress; + uint16_t parsedPort = 0; + std::string errorMessage; + const bool draftIsValid = normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &errorMessage); + + if (menuUpdate.activatedItemId == "test-connection") { + if (!draftIsValid) { + state.addHostDraft.validationMessage = errorMessage; + state.statusMessage = errorMessage; + rebuild_menu(state, "test-connection"); + return update; + } + + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage = "Testing connection to " + build_endpoint_label(normalizedAddress, parsedPort) + "..."; + state.statusMessage = state.addHostDraft.connectionMessage; + update.connectionTestRequested = true; + update.connectionTestAddress = normalizedAddress; + update.connectionTestPort = effective_host_port(parsedPort); + return update; + } + + if (menuUpdate.activatedItemId == "save-host") { + if (!draftIsValid) { + state.addHostDraft.validationMessage = errorMessage; + state.statusMessage = errorMessage; + rebuild_menu(state, menuUpdate.activatedItemId); + return update; + } + + HostRecord *existingHost = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); + if (existingHost == nullptr) { + state.hosts.push_back({ + build_default_host_display_name(normalizedAddress), + normalizedAddress, + parsedPort, + PairingState::not_paired, + }); + state.hostsDirty = true; + update.hostsChanged = true; + existingHost = &state.hosts.back(); + } + else if (menuUpdate.activatedItemId == "save-host") { + state.addHostDraft.validationMessage = "That host is already saved"; + state.statusMessage = state.addHostDraft.validationMessage; + rebuild_menu(state, menuUpdate.activatedItemId); + return update; + } + + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + + if (menuUpdate.activatedItemId == "save-host") { + state.statusMessage = "Saved host " + build_endpoint_label(existingHost->address, existingHost->port); + set_screen(state, ScreenId::hosts, build_host_menu_id(existingHost->address, existingHost->port)); + update.screenChanged = true; + update.activatedItemId = build_host_menu_id(existingHost->address, existingHost->port); + return update; + } + + } + + if (menuUpdate.activatedItemId == "cancel-add-host") { + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + set_screen(state, state.addHostDraft.returnScreen == ScreenId::add_host ? ScreenId::home : state.addHostDraft.returnScreen, state.addHostDraft.returnScreen == ScreenId::hosts ? "add-host" : std::string {}); + update.screenChanged = true; + return update; + } + } + + if (menuUpdate.activatedItemId == "test-connection") { + if (state.activeScreen == ScreenId::hosts) { + if (const HostRecord *host = selected_host_for_menu(state); host != nullptr) { + state.statusMessage = "Testing connection to " + build_endpoint_label(host->address, host->port) + "..."; + update.connectionTestAddress = host->address; + update.connectionTestPort = effective_host_port(host->port); + update.connectionTestRequested = true; + } + } + return update; + } + + if (menuUpdate.activatedItemId == "pair-host") { + if (const HostRecord *host = selected_host_for_menu(state); host != nullptr) { + enter_pair_host_screen(state, host->address, host->port); + update.screenChanged = true; + update.pairingRequested = true; + update.pairingAddress = state.pairingDraft.targetAddress; + update.pairingPort = state.pairingDraft.targetPort; + update.pairingPin = state.pairingDraft.generatedPin; + } + return update; + } + + if (menuUpdate.activatedItemId == "start-pairing") { + std::string normalizedAddress; + uint16_t parsedPort = 0; + std::string errorMessage; + if (!normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &errorMessage)) { + state.addHostDraft.validationMessage = errorMessage; + state.statusMessage = errorMessage; + rebuild_menu(state, menuUpdate.activatedItemId); + return update; + } + + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + + HostRecord *host = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); + if (host == nullptr) { + state.hosts.push_back({ + build_default_host_display_name(normalizedAddress), + normalizedAddress, + parsedPort, + PairingState::not_paired, + }); + state.hostsDirty = true; + update.hostsChanged = true; + host = &state.hosts.back(); + } + + enter_pair_host_screen(state, host->address, host->port); + update.screenChanged = true; + update.pairingRequested = true; + update.pairingAddress = state.pairingDraft.targetAddress; + update.pairingPort = state.pairingDraft.targetPort; + update.pairingPin = state.pairingDraft.generatedPin; + return update; + } + + if (state.activeScreen == ScreenId::pair_host) { + + if (menuUpdate.activatedItemId == "cancel-pairing") { + set_screen(state, ScreenId::hosts, build_host_menu_id(state.pairingDraft.targetAddress, state.pairingDraft.targetPort)); + update.screenChanged = true; + return update; + } + } + + if (menuUpdate.activatedItemId == "delete-host") { + if (const HostRecord *host = selected_host_for_menu(state); host != nullptr) { + const std::string deletedAddress = host->address; + const uint16_t deletedPort = host->port; + const std::string deletedName = host->displayName; + const auto iterator = std::find_if(state.hosts.begin(), state.hosts.end(), [&deletedAddress, deletedPort](const HostRecord &candidate) { + return candidate.address == deletedAddress && effective_host_port(candidate.port) == effective_host_port(deletedPort); + }); + const auto removedIndex = static_cast(std::distance(state.hosts.begin(), iterator)); + + if (iterator != state.hosts.end()) { + state.hosts.erase(iterator); + state.hostsDirty = true; + state.statusMessage = "Deleted host " + deletedName; + + std::string preferredItemId = "add-host"; + if (!state.hosts.empty()) { + const std::size_t preferredIndex = std::min(removedIndex, state.hosts.size() - 1); + preferredItemId = build_host_menu_id(state.hosts[preferredIndex].address, state.hosts[preferredIndex].port); + } + + set_screen(state, ScreenId::hosts, preferredItemId); + update.hostsChanged = true; + } + } + return update; + } + + if (const HostRecord *host = selected_host(state); host != nullptr) { + state.statusMessage = "Selected " + host->displayName + " at " + build_endpoint_label(host->address, host->port) + "."; + rebuild_menu(state, build_host_menu_id(host->address, host->port)); + return update; + } + + if (menuUpdate.activatedItemId == "exit") { + state.shouldExit = true; + update.exitRequested = true; + } + + return update; + } + +} // namespace app diff --git a/src/app/client_state.h b/src/app/client_state.h new file mode 100644 index 0000000..7701601 --- /dev/null +++ b/src/app/client_state.h @@ -0,0 +1,179 @@ +#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/ui/menu_model.h" + +namespace app { + + /** + * @brief Top-level screens used by the Moonlight client shell. + */ + enum class ScreenId { + home, + hosts, + add_host, + pair_host, + settings, + }; + + /** + * @brief Active field for keypad-based host entry. + */ + enum class AddHostField { + address, + port, + }; + + /** + * @brief Controller selection state for the add-host keypad modal. + */ + struct AddHostKeypadState { + bool visible; + std::size_t selectedButtonIndex; + std::string stagedInput; + }; + + /** + * @brief Controller-friendly draft state for manual host entry. + */ + struct AddHostDraft { + std::string addressInput; + std::string portInput; + AddHostField activeField; + AddHostKeypadState keypad; + ScreenId returnScreen; + std::string validationMessage; + std::string connectionMessage; + bool lastConnectionSucceeded; + }; + + /** + * @brief Serializable app state for the menu-driven client shell. + */ + struct ClientState { + ScreenId activeScreen; + bool overlayVisible; + bool shouldExit; + bool hostsDirty; + std::size_t overlayScrollOffset; + ui::MenuModel menu; + std::vector hosts; + AddHostDraft addHostDraft; + PairingDraft pairingDraft; + std::string statusMessage; + }; + + /** + * @brief Result of updating the client shell with a UI command. + */ + struct AppUpdate { + bool screenChanged; + bool overlayChanged; + bool overlayVisibilityChanged; + bool exitRequested; + bool hostsChanged; + bool connectionTestRequested; + bool pairingRequested; + std::string activatedItemId; + std::string connectionTestAddress; + uint16_t connectionTestPort; + std::string pairingAddress; + uint16_t pairingPort; + std::string pairingPin; + }; + + /** + * @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 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 Return the currently selected saved host on the Hosts screen. + * + * @param state App state containing the hosts list and menu selection. + * @return Selected host record, or nullptr when no saved host is selected. + */ + const HostRecord *selected_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..8ce1513 --- /dev/null +++ b/src/app/host_records.cpp @@ -0,0 +1,266 @@ +// class header include +#include "src/app/host_records.h" + +// standard includes +#include +#include + +namespace { + + bool append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + + return false; + } + + 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; + } + +} // 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"; + } + + 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::size_t index = 0; index < segments.size(); ++index) { + int octetValue = 0; + if (!parse_ipv4_octet(segments[index], &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); + for (const HostRecord &record : records) { + if (record.address == normalizedAddress && effective_host_port(record.port) == 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; + } + + std::string serialize_host_records(const std::vector &records) { + std::string serializedRecords; + + for (const HostRecord &record : records) { + std::string errorMessage; + if (!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 += '\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()) { + const std::vector fields = split_string_view(line, '\t'); + if (fields.size() != 3 && fields.size() != 4) { + result.errors.push_back("Line " + std::to_string(lineNumber) + " must contain three or four tab-separated fields"); + } + else { + uint16_t port = 0; + PairingState pairingState = PairingState::not_paired; + const std::string_view pairingField = fields.size() == 4 ? fields[3] : fields[2]; + if (fields.size() == 4 && !try_parse_host_port(fields[2], &port)) { + result.errors.push_back("Line " + std::to_string(lineNumber) + " uses an invalid TCP port"); + port = 0; + } + + if (pairingField == "not_paired") { + pairingState = PairingState::not_paired; + } + else if (pairingField == "paired") { + pairingState = PairingState::paired; + } + else { + result.errors.push_back("Line " + std::to_string(lineNumber) + " uses an unknown pairing state"); + pairingState = PairingState::not_paired; + } + + HostRecord record { + std::string(fields[0]), + std::string(fields[1]), + port, + pairingState, + }; + + std::string errorMessage; + if (validate_host_record(record, &errorMessage)) { + result.records.push_back(std::move(record)); + } + else { + result.errors.push_back("Line " + std::to_string(lineNumber) + ": " + errorMessage); + } + } + } + + 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..6f02ff3 --- /dev/null +++ b/src/app/host_records.h @@ -0,0 +1,124 @@ +#pragma once + +// standard includes +#include +#include +#include +#include + +namespace app { + + inline constexpr uint16_t DEFAULT_HOST_PORT = 47984; + + /** + * @brief Pairing state tracked for a saved host record. + */ + enum class PairingState { + not_paired, + paired, + }; + + /** + * @brief Manual host record shown in the shell. + */ + struct HostRecord { + std::string displayName; + std::string address; + uint16_t port; + PairingState pairingState; + }; + + /** + * @brief Result of parsing a serialized host record list. + */ + struct ParseHostRecordsResult { + std::vector records; + std::vector 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 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 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. + * + * @param records Host records to serialize. + * @return Serialized text suitable for a future persistence layer. + */ + 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..b6dfcb5 --- /dev/null +++ b/src/app/pairing_flow.cpp @@ -0,0 +1,32 @@ +// class header include +#include "src/app/pairing_flow.h" + +namespace app { + + PairingDraft create_pairing_draft(const std::string &targetAddress, uint16_t targetPort, std::string generatedPin) { + PairingDraft draft { + targetAddress, + targetPort, + std::move(generatedPin), + PairingStage::pin_ready, + "Pairing request sent. Enter the PIN on the host if prompted.", + }; + return draft; + } + + bool is_valid_pairing_pin(const std::string &pin) { + if (pin.size() != 4) { + return false; + } + + for (char digit : pin) { + if (digit < '0' || digit > '9') { + return false; + } + } + + return true; + } + +} // namespace app + diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h new file mode 100644 index 0000000..f253d36 --- /dev/null +++ b/src/app/pairing_flow.h @@ -0,0 +1,50 @@ +#pragma once + +// standard includes +#include +#include + +namespace app { + + /** + * @brief Reducer-driven stages for the manual pairing shell flow. + */ + enum class PairingStage { + idle, + pin_ready, + in_progress, + paired, + failed, + }; + + /** + * @brief Controller-friendly state for a client-generated pairing PIN. + */ + struct PairingDraft { + std::string targetAddress; + uint16_t targetPort; + std::string generatedPin; + PairingStage stage; + std::string statusMessage; + }; + + /** + * @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(const std::string &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(const std::string &pin); + +} // namespace app + diff --git a/src/compat/moonlight-common-c/moonlight_common_c_compat.h b/src/compat/moonlight-common-c/moonlight_common_c_compat.h new file mode 100644 index 0000000..7da5e32 --- /dev/null +++ b/src/compat/moonlight-common-c/moonlight_common_c_compat.h @@ -0,0 +1,10 @@ +#ifndef MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_H +#define MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_H + +#ifdef NXDK +#ifndef __analysis_assume +#define __analysis_assume(expression) ((void) (expression)) +#endif +#endif + +#endif diff --git a/src/compat/openssl/conio.h b/src/compat/openssl/conio.h new file mode 100644 index 0000000..e669b23 --- /dev/null +++ b/src/compat/openssl/conio.h @@ -0,0 +1,32 @@ +#ifndef MOONLIGHT_OPENSSL_CONIO_H +#define MOONLIGHT_OPENSSL_CONIO_H + +#ifdef __cplusplus +extern "C" { +#endif + +static inline int _kbhit(void) +{ + return 0; +} + +static inline int _getch(void) +{ + return -1; +} + +static inline int kbhit(void) +{ + return _kbhit(); +} + +static inline int getch(void) +{ + return _getch(); +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/compat/openssl/fd_compat.cpp b/src/compat/openssl/fd_compat.cpp new file mode 100644 index 0000000..57cb279 --- /dev/null +++ b/src/compat/openssl/fd_compat.cpp @@ -0,0 +1,21 @@ +#ifdef NXDK + +extern "C" { + +int close(int fd) +{ + (void) fd; + return 0; +} + +long lseek(int fd, long offset, int whence) +{ + (void) fd; + (void) offset; + (void) whence; + return -1; +} + +} + +#endif diff --git a/src/compat/openssl/openssl_apps_compat.h b/src/compat/openssl/openssl_apps_compat.h new file mode 100644 index 0000000..daae450 --- /dev/null +++ b/src/compat/openssl/openssl_apps_compat.h @@ -0,0 +1,162 @@ +#ifndef MOONLIGHT_OPENSSL_APPS_COMPAT_H +#define MOONLIGHT_OPENSSL_APPS_COMPAT_H + +// platform includes +#include +#include +#include +#include +#include +#include + +#ifdef accept +#undef accept +#endif +#ifdef bind +#undef bind +#endif +#ifdef close +#undef close +#endif +#ifdef connect +#undef connect +#endif +#ifdef getpeername +#undef getpeername +#endif +#ifdef getsockname +#undef getsockname +#endif +#ifdef getsockopt +#undef getsockopt +#endif +#ifdef ioctl +#undef ioctl +#endif +#ifdef ioctlsocket +#undef ioctlsocket +#endif +#ifdef listen +#undef listen +#endif +#ifdef recv +#undef recv +#endif +#ifdef recvfrom +#undef recvfrom +#endif +#ifdef recvmsg +#undef recvmsg +#endif +#ifdef send +#undef send +#endif +#ifdef sendmsg +#undef sendmsg +#endif +#ifdef sendto +#undef sendto +#endif +#ifdef setsockopt +#undef setsockopt +#endif +#ifdef shutdown +#undef shutdown +#endif +#ifdef socket +#undef socket +#endif +#ifdef select +#undef select +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#ifndef F_OK +#define F_OK 0 +#endif + +#ifndef AF_UNIX +#define AF_UNIX (-1) +#endif + +static inline int access(const char *path, int mode) +{ + (void) path; + (void) mode; + return -1; +} + +static inline int fileno(FILE *stream) +{ + (void) stream; + return -1; +} + +static inline int open(const char *path, int flags, ...) +{ + (void) path; + (void) flags; + return -1; +} + +static inline int _open(const char *path, int flags, ...) +{ + (void) path; + (void) flags; + return -1; +} + +static inline FILE *fdopen(int fd, const char *mode) +{ + (void) fd; + (void) mode; + return NULL; +} + +static inline FILE *_fdopen(int fd, const char *mode) +{ + return fdopen(fd, mode); +} + +static inline int _unlink(const char *path) +{ + (void) path; + return -1; +} + +static inline int chmod(const char *path, int mode) +{ + (void) path; + (void) mode; + return 0; +} + +static inline int gmtime_s(struct tm *result, const time_t *timer) +{ + if (result == NULL || timer == NULL) { + return -1; + } + + struct tm *temporary = gmtime(timer); + if (temporary == NULL) { + memset(result, 0, sizeof(*result)); + return -1; + } + + *result = *temporary; + return 0; +} + +static inline int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout) +{ + return lwip_select(maxfdp1, readset, writeset, exceptset, timeout); +} + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/src/compat/openssl/stat_compat.cpp b/src/compat/openssl/stat_compat.cpp new file mode 100644 index 0000000..4311dc8 --- /dev/null +++ b/src/compat/openssl/stat_compat.cpp @@ -0,0 +1,20 @@ +#ifdef NXDK + +// platform includes +#include + +extern "C" { + +int _stat(const char *path, struct stat *buffer) +{ + return stat(path, buffer); +} + +int _fstat(int fd, struct stat *buffer) +{ + return fstat(fd, buffer); +} + +} // extern "C" + +#endif diff --git a/src/compat/openssl/sys/resource.h b/src/compat/openssl/sys/resource.h new file mode 100644 index 0000000..5fbb249 --- /dev/null +++ b/src/compat/openssl/sys/resource.h @@ -0,0 +1,36 @@ +#ifndef MOONLIGHT_OPENSSL_COMPAT_SYS_RESOURCE_H +#define MOONLIGHT_OPENSSL_COMPAT_SYS_RESOURCE_H + +// platform includes +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define RUSAGE_SELF 0 + +struct rusage { + struct timeval ru_utime; + struct timeval ru_stime; +}; + +static inline int getrusage(int who, struct rusage *usage) +{ + (void) who; + if (usage != NULL) { + usage->ru_utime.tv_sec = 0; + usage->ru_utime.tv_usec = 0; + usage->ru_stime.tv_sec = 0; + usage->ru_stime.tv_usec = 0; + } + return -1; +} + +#ifdef __cplusplus +} +#endif + +#endif + diff --git a/src/input/navigation_input.cpp b/src/input/navigation_input.cpp new file mode 100644 index 0000000..ae81f70 --- /dev/null +++ b/src/input/navigation_input.cpp @@ -0,0 +1,69 @@ +// 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::left_shoulder: + return UiCommand::previous_page; + case GamepadButton::right_shoulder: + return UiCommand::next_page; + case GamepadButton::y: + return UiCommand::toggle_overlay; + } + + 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::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..4a05ae5 --- /dev/null +++ b/src/input/navigation_input.h @@ -0,0 +1,78 @@ +#pragma once + +namespace input { + + /** + * @brief Abstract UI command emitted by controller or keyboard input. + */ + enum class UiCommand { + none, + move_up, + move_down, + move_left, + move_right, + activate, + confirm, + back, + delete_character, + previous_page, + next_page, + toggle_overlay, + }; + + /** + * @brief Controller buttons used by the Moonlight client UI. + */ + enum class GamepadButton { + dpad_up, + dpad_down, + dpad_left, + dpad_right, + a, + b, + x, + y, + left_shoulder, + right_shoulder, + start, + back, + }; + + /** + * @brief Keyboard keys mapped onto the same abstract UI commands. + */ + enum class KeyboardKey { + up, + down, + left, + right, + enter, + escape, + backspace, + delete_key, + space, + tab, + page_up, + page_down, + i, + f3, + }; + + /** + * @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 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/logger.cpp b/src/logging/logger.cpp new file mode 100644 index 0000000..8773745 --- /dev/null +++ b/src/logging/logger.cpp @@ -0,0 +1,113 @@ +// class header include +#include "src/logging/logger.h" + +// standard includes +#include + +namespace { + + bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { + return static_cast(candidateLevel) >= static_cast(minimumLevel); + } + +} // 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"; + } + + return "UNKNOWN"; + } + + std::string format_entry(const LogEntry &entry) { + if (entry.category.empty()) { + return std::string("[") + to_string(entry.level) + "] " + entry.message; + } + + return std::string("[") + to_string(entry.level) + "] " + entry.category + ": " + entry.message; + } + + Logger::Logger(std::size_t capacity) + : capacity_(capacity == 0 ? 1 : capacity), + minimumLevel_(LogLevel::info), + nextSequence_(1) {} + + std::size_t Logger::capacity() const { + return capacity_; + } + + void Logger::set_minimum_level(LogLevel minimumLevel) { + minimumLevel_ = minimumLevel; + } + + LogLevel Logger::minimum_level() const { + return minimumLevel_; + } + + bool Logger::should_log(LogLevel level) const { + return is_enabled(level, minimumLevel_); + } + + bool Logger::log(LogLevel level, std::string category, std::string message) { + if (!should_log(level)) { + return false; + } + + LogEntry entry { + nextSequence_, + level, + std::move(category), + std::move(message), + }; + ++nextSequence_; + + if (entries_.size() == capacity_) { + entries_.pop_front(); + } + + entries_.push_back(entry); + + for (const LogSink &sink : sinks_) { + if (sink) { + sink(entries_.back()); + } + } + + return true; + } + + void Logger::add_sink(LogSink sink) { + if (sink) { + sinks_.push_back(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..9b40a92 --- /dev/null +++ b/src/logging/logger.h @@ -0,0 +1,127 @@ +#pragma once + +// standard includes +#include +#include +#include +#include +#include +#include + +namespace logging { + + /** + * @brief Severity levels used by the Moonlight client logger. + */ + enum class LogLevel { + trace = 0, + debug = 1, + info = 2, + warning = 3, + error = 4, + }; + + /** + * @brief Structured log entry stored by the in-memory logger. + */ + struct LogEntry { + uint64_t sequence; + LogLevel level; + std::string category; + std::string message; + }; + + using LogSink = std::function; + + /** + * @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 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 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. + */ + explicit Logger(std::size_t capacity = 256); + + /** + * @brief Return the maximum number of retained entries. + */ + std::size_t capacity() const; + + /** + * @brief Set the minimum accepted log level. + * + * @param minimumLevel Entries below this level are ignored. + */ + void set_minimum_level(LogLevel minimumLevel); + + /** + * @brief Return the minimum accepted log level. + */ + LogLevel minimum_level() const; + + /** + * @brief Return whether a log level would be recorded. + * + * @param level The candidate level. + * @return true if the entry would be stored and 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. + * @return true if the entry was accepted. + */ + bool log(LogLevel level, std::string category, std::string message); + + /** + * @brief Register an observer that receives accepted entries. + * + * @param sink Callback invoked synchronously during logging. + */ + void add_sink(LogSink sink); + + /** + * @brief Return the retained entries. + */ + 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: + std::size_t capacity_; + LogLevel minimumLevel_; + uint64_t nextSequence_; + std::deque entries_; + std::vector sinks_; + }; + +} // namespace logging diff --git a/src/main.cpp b/src/main.cpp index b9adef7..be8792f 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,12 +1,50 @@ // nxdk includes +#include +#include #include // local includes +#include "src/app/client_state.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 { + + int report_startup_failure(logging::Logger &logger, const char *category, const std::string &message) { + logger.log(logging::LogLevel::error, category, message); + debugPrint("%s\n", message.c_str()); + debugPrint("Holding failure screen for 5 seconds before exit.\n"); + Sleep(5000); + return 1; + } + +} // namespace int main() { + logging::Logger logger; + logger.add_sink([](const logging::LogEntry &entry) { + const std::string formattedEntry = logging::format_entry(entry); + debugPrint("%s\n", formattedEntry.c_str()); + }); + + app::ClientState clientState = app::create_initial_state(); + + const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); + for (const std::string &warning : loadedHosts.warnings) { + logger.log(logging::LogLevel::warning, "hosts", warning); + } + if (loadedHosts.fileFound) { + app::replace_hosts(clientState, loadedHosts.hosts, "Loaded " + std::to_string(loadedHosts.hosts.size()) + " saved host(s)"); + logger.log(logging::LogLevel::info, "hosts", "Loaded " + std::to_string(loadedHosts.hosts.size()) + " saved host record(s)"); + } + + logger.log(logging::LogLevel::info, "app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); + const startup::VideoModeSelection videoModeSelection = startup::select_best_video_mode(); const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode; @@ -14,11 +52,54 @@ int main() { startup::log_video_modes(videoModeSelection); startup::log_memory_statistics(); + const network::RuntimeNetworkStatus runtimeNetworkStatus = network::initialize_runtime_networking(); + for (const std::string &line : network::format_runtime_network_status_lines(runtimeNetworkStatus)) { + logger.log(runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); + } + if (!runtimeNetworkStatus.ready) { + clientState.statusMessage = runtimeNetworkStatus.summary; + } - Sleep(4000); + Sleep(3000); XVideoSetMode(bestVideoMode.width, bestVideoMode.height, bestVideoMode.bpp, bestVideoMode.refresh); - splash::show_splash_screen(bestVideoMode); - return 0; + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { + return report_startup_failure(logger, "sdl", std::string("SDL_Init failed: ") + SDL_GetError()); + } + + 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(logger, "sdl", std::string("SDL_CreateWindow failed: ") + SDL_GetError()); + SDL_Quit(); + return exitCode; + } + + logger.log(logging::LogLevel::info, "app", "Showing splash screen"); + splash::show_splash_screen(window, bestVideoMode, 2500U); + + logger.log(logging::LogLevel::info, "app", "Starting interactive shell"); + const int exitCode = ui::run_shell(window, bestVideoMode, clientState, logger); + + if (clientState.hostsDirty) { + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(clientState.hosts); + if (saveResult.success) { + logger.log(logging::LogLevel::info, "hosts", "Saved host records before exit"); + clientState.hostsDirty = false; + } + else { + logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); + } + } + + 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..d2d4888 --- /dev/null +++ b/src/network/host_pairing.cpp @@ -0,0 +1,1650 @@ +// class header include +#include "src/network/host_pairing.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include +#include + +// nxdk includes +#ifdef NXDK +#include +#endif + +#define OPENSSL_SUPPRESS_DEPRECATED + +// lib includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/network/runtime_network.h" + +#ifdef NXDK +#define _CRT_RAND_S +#endif + +#ifdef NXDK +extern "C" int rand_s(unsigned int *randomValue); +#endif + +namespace { + + void trace_pairing_phase(const char *message) { +#ifdef NXDK + if (message != nullptr) { + debugPrint("[PAIRING] %s\n", message); + } +#else + (void) message; +#endif + } + + void trace_pairing_detail(const std::string &message) { +#ifdef NXDK + debugPrint("[PAIRING] %s\n", message.c_str()); +#else + (void) message; +#endif + } + + 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; + + struct WsaGuard { + WsaGuard() + : initialized(false) { + WSADATA wsaData {}; + initialized = WSAStartup(MAKEWORD(2, 2), &wsaData) == 0; + } + + ~WsaGuard() { + if (initialized) { + WSACleanup(); + } + } + + bool initialized; + }; + + struct SocketGuard { + SocketGuard() + : handle(INVALID_SOCKET) { + } + + ~SocketGuard() { + if (handle != INVALID_SOCKET) { + closesocket(handle); + } + } + + SOCKET handle; + }; + + 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 append_error(std::string *errorMessage, std::string message); + 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 + ); + + 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 unsigned char 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 { + char buffer[5] = {}; + std::snprintf(buffer, sizeof(buffer), "\\x%02X", character); + preview += buffer; + } + } + + 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_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); + const std::string_view item = trim_ascii_whitespace(value.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start)); + if (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); + const std::size_t chunkExtensionSeparator = chunkSizeText.find(';'); + if (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 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); + const std::size_t separator = headerLine.find(':'); + if (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 (ascii_iequals(headerName, "Content-Length")) { + hasContentLength = try_parse_decimal_size(headerValue, &contentLength); + if (!hasContentLength) { + return append_error(errorMessage, "Received an invalid Content-Length header while pairing"); + } + } + else if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked")) { + isChunked = true; + } + } + + 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; + } + + bool append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + + return false; + } + + std::string take_openssl_error_queue() { + std::string details; + unsigned long errorCode = 0; + while ((errorCode = ERR_get_error()) != 0) { + char errorBuffer[256] = {}; + ERR_error_string_n(errorCode, errorBuffer, sizeof(errorBuffer)); + if (!details.empty()) { + details += "; "; + } + details += errorBuffer; + } + return details; + } + + bool append_openssl_error(std::string *errorMessage, std::string message) { + const std::string details = take_openssl_error_queue(); + if (!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) { + 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() { + } + + int nxdk_rand_add(const void *, int, double) { + return 1; + } + + int nxdk_rand_status() { + return 1; + } + + 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]; + output[(index * 2) + 1] = HEX_DIGITS[data[index] & 0x0F]; + } + + 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)); + } + + 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); + bytes[8] = static_cast((bytes[8] & 0x3F) | 0x80); + + 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(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()); + + X509_NAME *subject = X509_get_subject_name(certificate.get()); + if (subject == nullptr) { + return append_openssl_error(errorMessage, "Failed to populate the client certificate subject for pairing"); + } + + X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC, reinterpret_cast("NVIDIA GameStream Client"), -1, -1, 0); + X509_set_issuer_name(certificate.get(), subject); + + 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; + } + + 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 connect_socket(const std::string &address, uint16_t port, SocketGuard *socketGuard, std::string *errorMessage) { + if (socketGuard == nullptr) { + return append_error(errorMessage, "Internal pairing error while preparing the host connection"); + } + + 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"); + + trace_pairing_phase("preparing IPv4 socket address"); + sockaddr_in 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"); + + u_long nonBlockingMode = 1; + trace_pairing_phase("setting non-blocking connect mode"); + if (ioctlsocket(socketGuard->handle, FIONBIO, &nonBlockingMode) != 0) { + return append_error(errorMessage, "Failed to configure the host pairing socket for a timed connect (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + } + + trace_pairing_detail("connecting to " + address + ":" + std::to_string(port)); + const int connectResult = connect(socketGuard->handle, reinterpret_cast(&socketAddress), sizeof(socketAddress)); + if (connectResult == SOCKET_ERROR) { + const int connectError = WSAGetLastError(); + if (connectError != WSAEWOULDBLOCK && connectError != WSAEINPROGRESS && connectError != WSAEALREADY) { + return append_error(errorMessage, "Failed to connect to the host pairing endpoint at " + address + ":" + std::to_string(port) + " (Winsock error " + std::to_string(connectError) + ")"); + } + + fd_set writeSet; + FD_ZERO(&writeSet); + FD_SET(socketGuard->handle, &writeSet); + timeval timeout { + SOCKET_TIMEOUT_MILLISECONDS / 1000, + (SOCKET_TIMEOUT_MILLISECONDS % 1000) * 1000, + }; + + trace_pairing_phase("waiting for timed connect completion"); + const int selectResult = select(static_cast(socketGuard->handle) + 1, nullptr, &writeSet, nullptr, &timeout); + if (selectResult <= 0) { + 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; +#ifdef NXDK + socklen_t socketErrorLength = sizeof(socketError); +#else + int socketErrorLength = sizeof(socketError); +#endif + if (getsockopt(socketGuard->handle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { + return append_error(errorMessage, "Failed to query the host pairing socket status after connect (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + } + if (socketError != 0) { + return append_error(errorMessage, "Host refused the pairing connection on " + address + ":" + std::to_string(port) + " (Winsock error " + std::to_string(socketError) + ")"); + } + } + + nonBlockingMode = 0; + trace_pairing_phase("restoring blocking mode after connect"); + if (ioctlsocket(socketGuard->handle, FIONBIO, &nonBlockingMode) != 0) { + return append_error(errorMessage, "Failed to restore the host pairing socket to blocking mode after connect (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + } + + const DWORD timeoutMilliseconds = SOCKET_TIMEOUT_MILLISECONDS; + setsockopt(socketGuard->handle, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); + setsockopt(socketGuard->handle, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); + trace_pairing_phase("socket connected"); + + return true; + } + + bool recv_all_plain(SOCKET socketHandle, std::string *response, std::string *errorMessage) { + std::string received; + char buffer[4096] = {}; + std::size_t completeLength = 0; + + while (true) { + const int bytesRead = recv(socketHandle, buffer, sizeof(buffer), 0); + if (bytesRead == 0) { + break; + } + if (bytesRead < 0) { + const int socketError = WSAGetLastError(); + return append_error(errorMessage, socketError == WSAETIMEDOUT + ? "Timed out while reading the host pairing response" + : "Failed while reading the host pairing response (Winsock error " + std::to_string(socketError) + ")"); + } + received.append(buffer, buffer + 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) { + std::string received; + char buffer[4096] = {}; + std::size_t completeLength = 0; + + while (true) { + const int bytesRead = SSL_read(ssl, buffer, sizeof(buffer)); + 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 && WSAGetLastError() == WSAETIMEDOUT + ? "Timed out while reading the encrypted host pairing response" + : "Failed while reading the encrypted host pairing response"); + } + received.append(buffer, buffer + 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) { + std::size_t sent = 0; + while (sent < request.size()) { + 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 (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + } + sent += static_cast(bytesSent); + } + + return true; + } + + bool send_all_ssl(SSL *ssl, std::string_view request, std::string *errorMessage) { + std::size_t sent = 0; + while (sent < request.size()) { + 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, HttpResponse *response, network::HostPairingServerInfo *serverInfo, std::string *errorMessage) { + if (address.empty()) { + return append_error(errorMessage, "Pairing requires a valid host address"); + } + + const std::vector candidatePorts = build_serverinfo_port_candidates(preferredHttpPort); + const std::string serverInfoPath = "/serverinfo?uniqueid=0123456789ABCDEF&uuid=11111111-2222-3333-4444-555555555555"; + 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)) { + 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)); + } + + 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 + ) { + trace_pairing_phase("http_get: WSAStartup"); + WsaGuard wsaGuard; + if (!wsaGuard.initialized) { + return append_error(errorMessage, "Failed to initialize Winsock for host pairing"); + } + + SocketGuard socketGuard; + trace_pairing_phase("http_get: connect_socket"); + if (!connect_socket(address, port, &socketGuard, errorMessage)) { + return false; + } + + const std::string request = + "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"; + + std::string rawResponse; + if (!useTls) { + trace_pairing_phase("http_get: sending plain request"); + if (!send_all_plain(socketGuard.handle, request, errorMessage) || !recv_all_plain(socketGuard.handle, &rawResponse, errorMessage)) { + return false; + } + } + else { + trace_pairing_phase("http_get: preparing TLS"); + initialize_openssl(); + 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); + if (tlsClientIdentity != nullptr && !configure_tls_pairing_identity(context.get(), *tlsClientIdentity, errorMessage)) { + return false; + } + + 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(static_cast(socketGuard.handle), 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(socketGuard.handle)) != 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 (SSL_connect(ssl.get()) != 1) { + return append_openssl_error(errorMessage, "Failed to establish the encrypted host pairing session"); + } + 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) || !recv_all_ssl(ssl.get(), &rawResponse, errorMessage)) { + return false; + } + } + + trace_pairing_phase("http_get: parsing HTTP response"); + return parse_http_response(rawResponse, response, errorMessage); + } + + const EVP_MD *pairing_digest(int serverMajorVersion) { + return serverMajorVersion >= 7 ? EVP_sha256() : EVP_sha1(); + } + + std::size_t pairing_hash_length(int serverMajorVersion) { + return serverMajorVersion >= 7 ? 32U : 20U; + } + + bool compute_digest(const unsigned char *data, std::size_t size, int serverMajorVersion, 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(serverMajorVersion), 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) { + 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) { + 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, int serverMajorVersion, 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(), serverMajorVersion, 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(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; + } + +} // 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 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); + } + + 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"; + } + return true; + } + + bool query_server_info(const std::string &address, uint16_t preferredHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { + return query_server_info_internal(address, preferredHttpPort, nullptr, serverInfo, errorMessage); + } + + HostPairingResult pair_host(const HostPairingRequest &request) { + trace_pairing_phase("pair_host entered"); + HostPairingResult result {false, false, "Pairing failed"}; + auto fail_with_phase = [&result](std::string_view phase, const std::string &message) { + result.message = "Pairing failed during " + std::string(phase) + ": " + message; + trace_pairing_detail(result.message); + return result; + }; + auto next_pairing_uuid = [&result](std::string *uuid, std::string *errorMessage) { + if (generate_uuid(uuid, errorMessage)) { + return true; + } + + result.message = errorMessage != nullptr && !errorMessage->empty() + ? *errorMessage + : "Failed to generate the UUID used for pairing"; + return false; + }; + + if (request.address.empty()) { + result.message = "Pairing requires a valid host address"; + return result; + } + if (request.pin.size() != 4U) { + result.message = "Pairing requires a four-digit PIN"; + return result; + } + if (!is_valid_pairing_identity(request.identity)) { + result.message = "Client pairing identity is missing or invalid"; + return result; + } + + std::unique_ptr clientCertificate = load_certificate(request.identity.certificatePem); + std::unique_ptr clientPrivateKey = load_private_key(request.identity.privateKeyPem); + if (clientCertificate == nullptr || clientPrivateKey == nullptr) { + result.message = "Client pairing credentials could not be loaded"; + return result; + } + + const uint16_t httpPort = request.httpPort == 0 ? DEFAULT_SERVERINFO_HTTP_PORT : request.httpPort; + const std::string uniqueId = request.identity.uniqueId; + const std::string deviceName = request.deviceName.empty() ? "MoonlightXboxOG" : request.deviceName; + + std::string errorMessage; + HttpResponse response {}; + std::string requestUuid; + if (!next_pairing_uuid(&requestUuid, &errorMessage)) { + return result; + } + + trace_pairing_phase("requesting /serverinfo"); + HostPairingServerInfo serverInfo {}; + if (!query_server_info_internal(request.address, httpPort, &response, &serverInfo, &errorMessage)) { + return fail_with_phase("serverinfo", errorMessage); + } + + if (serverInfo.paired) { + result.success = true; + result.alreadyPaired = true; + result.message = "The host already reports this client as paired"; + return result; + } + + std::array saltBytes {}; + if (!fill_random_bytes(saltBytes.data(), saltBytes.size(), &errorMessage)) { + result.message = errorMessage; + return result; + } + const std::string saltHex = hex_encode(saltBytes.data(), saltBytes.size()); + const std::string certHex = certificate_hex(request.identity.certificatePem); + + std::string phaseValue; + if (!next_pairing_uuid(&requestUuid, &errorMessage)) { + return result; + } + const std::string phase1Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&phrase=getservercert&salt=" + saltHex + "&clientcert=" + certHex; + trace_pairing_phase("phase 1 getservercert request"); + if (!http_get(request.address, serverInfo.httpPort, phase1Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + return fail_with_phase("phase 1 (getservercert)", errorMessage); + } + if (phaseValue != "1") { + result.message = "The host rejected the initial pairing request"; + return result; + } + + std::string plainCertHex; + if (!parse_pairing_tag(response, "plaincert", &plainCertHex, &errorMessage)) { + return fail_with_phase("phase 1 (getservercert)", errorMessage); + } + + std::vector plainCertBytes; + if (!hex_decode(plainCertHex, &plainCertBytes, &errorMessage)) { + return fail_with_phase("phase 1 (getservercert)", errorMessage); + } + const std::string plainCertPem(plainCertBytes.begin(), plainCertBytes.end()); + + std::vector aesKey; + trace_pairing_phase("deriving AES key"); + if (!derive_aes_key(saltHex, request.pin, serverInfo.serverMajorVersion, &aesKey, &errorMessage)) { + return fail_with_phase("phase 1 (derive AES key)", errorMessage); + } + + std::array clientChallengeBytes {}; + if (!fill_random_bytes(clientChallengeBytes.data(), clientChallengeBytes.size(), &errorMessage)) { + return fail_with_phase("phase 2 (client challenge random)", errorMessage); + } + + std::vector encryptedClientChallenge; + if (!aes_ecb_encrypt(clientChallengeBytes.data(), clientChallengeBytes.size(), aesKey, &encryptedClientChallenge, &errorMessage)) { + return fail_with_phase("phase 2 (client challenge encrypt)", errorMessage); + } + + if (!next_pairing_uuid(&requestUuid, &errorMessage)) { + return result; + } + const std::string phase2Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&clientchallenge=" + hex_encode(encryptedClientChallenge.data(), encryptedClientChallenge.size()); + trace_pairing_phase("phase 2 clientchallenge request"); + if (!http_get(request.address, serverInfo.httpPort, phase2Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + return fail_with_phase("phase 2 (client challenge)", errorMessage); + } + if (phaseValue != "1") { + result.message = "The host rejected the client challenge during pairing"; + return result; + } + + std::string challengeResponseHex; + if (!parse_pairing_tag(response, "challengeresponse", &challengeResponseHex, &errorMessage)) { + return fail_with_phase("phase 2 (client challenge)", errorMessage); + } + + std::vector challengeResponseEncrypted; + std::vector challengeResponsePlaintext; + if (!hex_decode(challengeResponseHex, &challengeResponseEncrypted, &errorMessage) || !aes_ecb_decrypt(challengeResponseEncrypted, aesKey, &challengeResponsePlaintext, &errorMessage)) { + return fail_with_phase("phase 2 (client challenge)", errorMessage); + } + + const std::size_t hashLength = pairing_hash_length(serverInfo.serverMajorVersion); + if (challengeResponsePlaintext.size() < hashLength + 16U) { + result.message = "The host returned an incomplete challenge response during pairing"; + return result; + } + + std::vector certificateSignature; + if (!load_certificate_signature(clientCertificate.get(), &certificateSignature, &errorMessage)) { + return fail_with_phase("phase 3 (server challenge response)", errorMessage); + } + + std::array clientSecretBytes {}; + if (!fill_random_bytes(clientSecretBytes.data(), clientSecretBytes.size(), &errorMessage)) { + return fail_with_phase("phase 3 (client secret random)", errorMessage); + } + + 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(), clientSecretBytes.begin(), clientSecretBytes.end()); + + std::vector clientHash; + if (!compute_digest(clientHashSource.data(), clientHashSource.size(), serverInfo.serverMajorVersion, &clientHash, &errorMessage)) { + return fail_with_phase("phase 3 (server challenge response)", errorMessage); + } + + std::vector encryptedClientHash; + if (!aes_ecb_encrypt(clientHash.data(), clientHash.size(), aesKey, &encryptedClientHash, &errorMessage)) { + return fail_with_phase("phase 3 (server challenge response)", errorMessage); + } + + if (!next_pairing_uuid(&requestUuid, &errorMessage)) { + return result; + } + const std::string phase3Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&serverchallengeresp=" + hex_encode(encryptedClientHash.data(), encryptedClientHash.size()); + trace_pairing_phase("phase 3 serverchallengeresp request"); + if (!http_get(request.address, serverInfo.httpPort, phase3Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + return fail_with_phase("phase 3 (server challenge response)", errorMessage); + } + if (phaseValue != "1") { + result.message = "The host rejected the server challenge response during pairing"; + return result; + } + + std::string pairingSecretHex; + if (!parse_pairing_tag(response, "pairingsecret", &pairingSecretHex, &errorMessage)) { + return fail_with_phase("phase 3 (server challenge response)", errorMessage); + } + + std::vector pairingSecretBytes; + if (!hex_decode(pairingSecretHex, &pairingSecretBytes, &errorMessage) || pairingSecretBytes.size() <= 16U) { + return fail_with_phase("phase 4 (pairing secret)", "The host returned an invalid pairing secret"); + } + + std::unique_ptr plainCertificate = load_certificate(plainCertPem); + if (plainCertificate == nullptr) { + return fail_with_phase("phase 4 (pairing secret)", "The host returned an invalid server certificate during pairing"); + } + + std::vector serverSecret(pairingSecretBytes.begin(), pairingSecretBytes.begin() + 16); + std::vector serverSignature(pairingSecretBytes.begin() + 16, pairingSecretBytes.end()); + if (!verify_sha256_signature(serverSecret, serverSignature, plainCertificate.get(), &errorMessage)) { + return fail_with_phase("phase 4 (pairing secret)", errorMessage); + } + + std::vector clientSecretVector(clientSecretBytes.begin(), clientSecretBytes.end()); + std::vector clientPairingSignature; + if (!sign_sha256(clientSecretVector, clientPrivateKey.get(), &clientPairingSignature, &errorMessage)) { + return fail_with_phase("phase 4 (client pairing secret)", errorMessage); + } + + std::vector clientPairingSecret; + clientPairingSecret.insert(clientPairingSecret.end(), clientSecretBytes.begin(), clientSecretBytes.end()); + clientPairingSecret.insert(clientPairingSecret.end(), clientPairingSignature.begin(), clientPairingSignature.end()); + + if (!next_pairing_uuid(&requestUuid, &errorMessage)) { + return result; + } + const std::string phase4Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&clientpairingsecret=" + hex_encode(clientPairingSecret.data(), clientPairingSecret.size()); + trace_pairing_phase("phase 4 clientpairingsecret request"); + if (!http_get(request.address, serverInfo.httpPort, phase4Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + return fail_with_phase("phase 4 (client pairing secret)", errorMessage); + } + if (phaseValue != "1") { + result.message = "The host rejected the client pairing secret"; + return result; + } + + if (!next_pairing_uuid(&requestUuid, &errorMessage)) { + return result; + } + const std::string phase5Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&phrase=pairchallenge"; + trace_pairing_phase("phase 5 pairchallenge request"); + if (!http_get(request.address, serverInfo.httpsPort, phase5Path, true, &request.identity, plainCertPem, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + return fail_with_phase("phase 5 (pairchallenge)", errorMessage); + } + if (phaseValue != "1") { + result.message = "The host rejected the final encrypted pairing challenge"; + return result; + } + + result.success = true; + result.message = "Paired successfully with " + request.address; + trace_pairing_phase("pair_host succeeded"); + return result; + } + +} // namespace network diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h new file mode 100644 index 0000000..a97ecf1 --- /dev/null +++ b/src/network/host_pairing.h @@ -0,0 +1,47 @@ +#pragma once + +// standard includes +#include +#include +#include + +namespace network { + + struct PairingIdentity { + std::string uniqueId; + std::string certificatePem; + std::string privateKeyPem; + }; + + struct HostPairingServerInfo { + int serverMajorVersion; + uint16_t httpPort; + uint16_t httpsPort; + bool paired; + }; + + struct HostPairingRequest { + std::string address; + uint16_t httpPort; + std::string pin; + std::string deviceName; + PairingIdentity identity; + }; + + struct HostPairingResult { + bool success; + bool alreadyPaired; + std::string message; + }; + + bool is_valid_pairing_identity(const PairingIdentity &identity); + + PairingIdentity create_pairing_identity(std::string *errorMessage = nullptr); + + bool parse_server_info_response(std::string_view xml, uint16_t fallbackHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage = nullptr); + + bool query_server_info(const std::string &address, uint16_t preferredHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage = nullptr); + + HostPairingResult pair_host(const HostPairingRequest &request); + +} // namespace network diff --git a/src/network/runtime_network.cpp b/src/network/runtime_network.cpp new file mode 100644 index 0000000..adb293e --- /dev/null +++ b/src/network/runtime_network.cpp @@ -0,0 +1,119 @@ +// class header include +#include "src/network/runtime_network.h" + + +// nxdk includes +#ifdef NXDK +#include +#include +#include + +extern "C" struct netif *g_pnetif; +#endif + +namespace { + + network::RuntimeNetworkStatus g_runtimeNetworkStatus {}; + +#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 + + 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..9c4622b --- /dev/null +++ b/src/network/runtime_network.h @@ -0,0 +1,31 @@ +#pragma once + +// standard includes +#include +#include +#include + +namespace network { + + struct RuntimeNetworkStatus { + bool initializationAttempted; + bool ready; + int initializationCode; + std::string summary; + std::string ipAddress; + std::string subnetMask; + std::string gateway; + }; + + RuntimeNetworkStatus initialize_runtime_networking(); + + const RuntimeNetworkStatus &runtime_network_status(); + + bool runtime_network_ready(); + + + std::string describe_runtime_network_initialization_code(int initializationCode); + + std::vector format_runtime_network_status_lines(const RuntimeNetworkStatus &status); + +} // namespace network diff --git a/src/splash/splash_layout.cpp b/src/splash/splash_layout.cpp index 0a76e4d..338506f 100644 --- a/src/splash/splash_layout.cpp +++ b/src/splash/splash_layout.cpp @@ -1,5 +1,7 @@ +// class header include #include "src/splash/splash_layout.h" +// standard includes #include #include diff --git a/src/splash/splash_screen.cpp b/src/splash/splash_screen.cpp index 453aa37..3d56764 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -3,7 +3,6 @@ // standard includes #include -#include #include #include #include @@ -21,9 +20,9 @@ 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()); @@ -206,65 +205,44 @@ namespace { } 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()); + debugPrint("Failed to prepare splash asset %s for rendering.\n", assetPath.c_str()); } + debugPrint("Failed to load splash asset %s: %s\n", assetPath.c_str(), 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 show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, unsigned int durationMilliseconds) { 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; } @@ -275,26 +253,27 @@ namespace splash { 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; } SDL_Rect logoDestination = createCenteredRect(screenSurface, imageSurface->w, imageSurface->h); + const Uint32 startTicks = SDL_GetTicks(); while (!done) { while (SDL_PollEvent(&event)) { @@ -304,27 +283,31 @@ 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 (SDL_GetTicks() - startTicks >= durationMilliseconds) { + done = 1; + } + + SDL_Delay(16); } - cleanupSplashScreen(window, imageSurface); + cleanupSplashScreen(imageSurface); } } // namespace splash diff --git a/src/splash/splash_screen.h b/src/splash/splash_screen.h index 524318a..3b6a154 100644 --- a/src/splash/splash_screen.h +++ b/src/splash/splash_screen.h @@ -3,8 +3,10 @@ // nxdk includes #include +struct SDL_Window; + namespace splash { - void show_splash_screen(const VIDEO_MODE &videoMode); + void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, unsigned int durationMilliseconds = 1500U); } diff --git a/src/startup/client_identity_storage.cpp b/src/startup/client_identity_storage.cpp new file mode 100644 index 0000000..b999110 --- /dev/null +++ b/src/startup/client_identity_storage.cpp @@ -0,0 +1,233 @@ +// class header include +#include "src/startup/client_identity_storage.h" + +// standard includes +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +// local includes +#include "src/startup/host_storage.h" + +namespace { + + 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; + }; + + std::string parent_directory(const std::string &filePath) { + const std::size_t separatorIndex = filePath.find_last_of("\\/"); + if (separatorIndex == std::string::npos) { + return {}; + } + + return filePath.substr(0, separatorIndex); + } + + std::string join_path(const std::string &left, const std::string &right) { + if (left.empty()) { + return right; + } + if (left.back() == '\\' || left.back() == '/') { + return left + right; + } + return left + "\\" + right; + } + + std::string normalize_directory_component(std::string path) { + while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { + path.pop_back(); + } + return path; + } + + bool is_drive_root_path(const std::string &path) { + return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; + } + + 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { + partialPath = directoryPath.substr(0, 2); + startIndex = 2; + } + + for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { + partialPath.push_back(directoryPath[index]); + const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; + const bool atPathEnd = index + 1 == directoryPath.size(); + if (!atSeparator && !atPathEnd) { + continue; + } + + if (is_drive_root_path(partialPath)) { + continue; + } + + const std::string normalizedPath = normalize_directory_component(partialPath); + if (normalizedPath.empty()) { + continue; + } + + if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { + if (errorMessage != nullptr) { + *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); + } + return false; + } + } + + return true; + } + + 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, const std::string &content, std::string *errorMessage) { + FILE *file = std::fopen(filePath.c_str(), "wb"); + if (file == nullptr) { + if (errorMessage != nullptr) { + *errorMessage = std::strerror(errno); + } + return false; + } + + const std::size_t bytesWritten = std::fwrite(content.data(), 1, content.size(), file); + if (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; + } + +} // namespace + +namespace startup { + + std::string default_client_identity_directory() { + const std::string hostStorageDirectory = parent_directory(default_host_storage_path()); + if (hostStorageDirectory.empty()) { + return "pairing"; + } + + return join_path(hostStorageDirectory, "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; + } + + SaveClientIdentityResult save_client_identity(const network::PairingIdentity &identity, const std::string &directoryPath) { + std::string errorMessage; + if (!ensure_directory_exists(directoryPath, &errorMessage)) { + return {false, errorMessage}; + } + + const std::string uniqueIdPath = join_path(directoryPath, UNIQUE_ID_FILE_NAME); + if (!write_file_text(uniqueIdPath, identity.uniqueId, &errorMessage)) { + return {false, "Failed to save pairing unique ID to '" + uniqueIdPath + "': " + errorMessage}; + } + + const std::string certificatePath = join_path(directoryPath, CERTIFICATE_FILE_NAME); + if (!write_file_text(certificatePath, identity.certificatePem, &errorMessage)) { + return {false, "Failed to save pairing certificate to '" + certificatePath + "': " + errorMessage}; + } + + const std::string privateKeyPath = join_path(directoryPath, PRIVATE_KEY_FILE_NAME); + if (!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..ddd0fa6 --- /dev/null +++ b/src/startup/client_identity_storage.h @@ -0,0 +1,32 @@ +#pragma once + +// standard includes +#include +#include + +// local includes +#include "src/network/host_pairing.h" + +namespace startup { + + struct LoadClientIdentityResult { + network::PairingIdentity identity; + std::vector warnings; + bool fileFound; + }; + + struct SaveClientIdentityResult { + bool success; + std::string errorMessage; + }; + + std::string default_client_identity_directory(); + + LoadClientIdentityResult load_client_identity(const std::string &directoryPath = default_client_identity_directory()); + + SaveClientIdentityResult save_client_identity( + const network::PairingIdentity &identity, + const std::string &directoryPath = default_client_identity_directory() + ); + +} // namespace startup diff --git a/src/startup/host_storage.cpp b/src/startup/host_storage.cpp new file mode 100644 index 0000000..671c84c --- /dev/null +++ b/src/startup/host_storage.cpp @@ -0,0 +1,188 @@ +// class header include +#include "src/startup/host_storage.h" + +// standard includes +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +// nxdk includes +#if defined(__has_include) +#if __has_include() +#include +#include +#define MOONLIGHT_HAS_NXDK_XBE 1 +#endif +#if __has_include() +#include +#define MOONLIGHT_HAS_NXDK_MOUNT 1 +#endif +#endif + +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; + } + + std::string normalize_directory_component(std::string path) { + while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { + path.pop_back(); + } + return path; + } + + bool is_drive_root_path(const std::string &path) { + return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; + } + + 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { + partialPath = directoryPath.substr(0, 2); + startIndex = 2; + } + + for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { + partialPath.push_back(directoryPath[index]); + const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; + const bool atPathEnd = index + 1 == directoryPath.size(); + if (!atSeparator && !atPathEnd) { + continue; + } + + if (is_drive_root_path(partialPath)) { + continue; + } + + const std::string normalizedPath = normalize_directory_component(partialPath); + if (normalizedPath.empty()) { + continue; + } + + if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { + if (errorMessage != nullptr) { + *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); + } + return false; + } + } + + return true; + } + + std::string parent_directory(const std::string &filePath) { + const std::size_t separatorIndex = filePath.find_last_of("\\/"); + if (separatorIndex == std::string::npos) { + return {}; + } + + return filePath.substr(0, separatorIndex); + } + + std::string title_scoped_storage_root() { +#ifdef MOONLIGHT_HAS_NXDK_XBE +#ifdef MOONLIGHT_HAS_NXDK_MOUNT + if (!nxIsDriveMounted('E') && !nxMountDrive('E', "\\Device\\Harddisk0\\Partition1\\")) { + return {}; + } +#endif + + char titleIdBuffer[9] = {}; + std::snprintf(titleIdBuffer, sizeof(titleIdBuffer), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); + return std::string("E:\\UDATA\\") + titleIdBuffer + "\\"; +#else + return {}; +#endif + } + +} // namespace + +namespace startup { + + std::string default_host_storage_path() { + const std::string titleScopedRoot = title_scoped_storage_root(); + if (!titleScopedRoot.empty()) { + return titleScopedRoot + "moonlight-hosts.tsv"; + } + + return {"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) { + std::string errorMessage; + if (!ensure_directory_exists(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); + const std::size_t bytesWritten = std::fwrite(serializedHosts.data(), 1, serializedHosts.size(), file); + if (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..0cc232a --- /dev/null +++ b/src/startup/host_storage.h @@ -0,0 +1,32 @@ +#pragma once + +// standard includes +#include +#include + +// local includes +#include "src/app/host_records.h" + +namespace startup { + + struct LoadSavedHostsResult { + std::vector hosts; + std::vector warnings; + bool fileFound; + }; + + struct SaveSavedHostsResult { + bool success; + std::string errorMessage; + }; + + std::string default_host_storage_path(); + + LoadSavedHostsResult load_saved_hosts(const std::string &filePath = default_host_storage_path()); + + SaveSavedHostsResult save_saved_hosts( + const std::vector &hosts, + const std::string &filePath = default_host_storage_path() + ); + +} // namespace startup diff --git a/src/streaming/stats_overlay.cpp b/src/streaming/stats_overlay.cpp new file mode 100644 index 0000000..b31f4ed --- /dev/null +++ b/src/streaming/stats_overlay.cpp @@ -0,0 +1,84 @@ +// 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..0fef954 --- /dev/null +++ b/src/streaming/stats_overlay.h @@ -0,0 +1,38 @@ +#pragma once + +// standard includes +#include +#include + +namespace streaming { + + /** + * @brief Snapshot of stream telemetry shown in the on-screen stats overlay. + */ + struct StreamStatisticsSnapshot { + int width; + int height; + int fps; + int roundTripTimeMs; + int hostLatencyMs; + int decoderLatencyMs; + int videoQueueDepth; + int audioQueueDurationMs; + int videoPacketsReceived; + int videoPacketsRecovered; + int videoPacketsLost; + bool poorConnection; + }; + + /** + * @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/menu_model.cpp b/src/ui/menu_model.cpp new file mode 100644 index 0000000..ea9c7aa --- /dev/null +++ b/src/ui/menu_model.cpp @@ -0,0 +1,119 @@ +// class header include +#include "src/ui/menu_model.h" + +// standard includes +#include + +namespace ui { + + MenuModel::MenuModel(std::vector items) + : selectedIndex_(npos) { + 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(const std::string &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: + update.previousPageRequested = true; + break; + case input::UiCommand::next_page: + update.nextPageRequested = true; + break; + case input::UiCommand::toggle_overlay: + update.overlayToggleRequested = true; + break; + case input::UiCommand::none: + 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..e70b38d --- /dev/null +++ b/src/ui/menu_model.h @@ -0,0 +1,97 @@ +#pragma once + +// standard includes +#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; + std::string label; + bool enabled; + }; + + /** + * @brief Result of applying a UI command to a menu. + */ + struct MenuUpdate { + bool selectionChanged; + bool activationRequested; + bool backRequested; + bool previousPageRequested; + bool nextPageRequested; + bool overlayToggleRequested; + std::string activatedItemId; + }; + + /** + * @brief Menu state that supports controller and keyboard navigation. + */ + class MenuModel { + public: + 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. + */ + const std::vector &items() const; + + /** + * @brief Return the selected item index or npos when none is selectable. + */ + std::size_t selected_index() const; + + /** + * @brief Return the selected item or nullptr when none is selectable. + */ + 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(const std::string &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); + std::size_t find_first_enabled_index() const; + + std::vector items_; + std::size_t selectedIndex_; + }; + +} // namespace ui + diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp new file mode 100644 index 0000000..1298bd3 --- /dev/null +++ b/src/ui/shell_screen.cpp @@ -0,0 +1,864 @@ +// class header include +#include "src/ui/shell_screen.h" + +// standard includes +#include +#include +#include +#include + +// nxdk includes +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/input/navigation_input.h" +#include "src/network/host_pairing.h" +#include "src/network/runtime_network.h" +#include "src/os.h" +#include "src/startup/client_identity_storage.h" +#include "src/startup/host_storage.h" +#include "src/ui/shell_view.h" + +namespace { + + constexpr std::size_t PAIRING_THREAD_STACK_SIZE = 1024U * 1024U; + + constexpr Uint8 BACKGROUND_RED = 0x10; + constexpr Uint8 BACKGROUND_GREEN = 0x12; + constexpr Uint8 BACKGROUND_BLUE = 0x16; + constexpr Uint8 PANEL_RED = 0x1A; + constexpr Uint8 PANEL_GREEN = 0x1F; + constexpr Uint8 PANEL_BLUE = 0x25; + constexpr Uint8 ACCENT_RED = 0x76; + constexpr Uint8 ACCENT_GREEN = 0xB9; + constexpr Uint8 ACCENT_BLUE = 0xFF; + 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; + + std::string build_asset_path(const char *relativePath) { + return std::string(DATA_PATH) + "assets" + PATH_SEP + relativePath; + } + + int report_shell_failure(logging::Logger &logger, const char *category, const std::string &message) { + logger.log(logging::LogLevel::error, category, message); + debugPrint("%s\n", message.c_str()); + debugPrint("Holding failure screen for 5 seconds before exit.\n"); + Sleep(5000); + return 1; + } + + void destroy_texture(SDL_Texture *texture) { + if (texture != nullptr) { + SDL_DestroyTexture(texture); + } + } + + SDL_Texture *load_texture_from_asset(SDL_Renderer *renderer, const char *relativePath) { + if (renderer == nullptr || relativePath == nullptr) { + return nullptr; + } + + const std::string assetPath = build_asset_path(relativePath); + SDL_Surface *surface = IMG_Load(assetPath.c_str()); + if (surface == nullptr) { + return nullptr; + } + + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + SDL_FreeSurface(surface); + 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( + SDL_Renderer *renderer, + TTF_Font *font, + const std::string &text, + SDL_Color color, + int x, + int y, + int maxWidth, + int *drawnHeight = nullptr + ) { + if (text.empty()) { + if (drawnHeight != nullptr) { + *drawnHeight = TTF_FontLineSkip(font); + } + return true; + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text.c_str(), color, static_cast(maxWidth)); + if (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; + } + + int measure_wrapped_text_height(TTF_Font *font, const std::string &text, int maxWidth) { + if (text.empty()) { + return TTF_FontLineSkip(font); + } + + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, text.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; + } + + 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); + } + + 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_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::previous_page; + } + if (!thresholdCrossed) { + *leftTriggerPressed = false; + } + break; + case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: + if (thresholdCrossed && !*rightTriggerPressed) { + *rightTriggerPressed = true; + return input::UiCommand::next_page; + } + if (!thresholdCrossed) { + *rightTriggerPressed = false; + } + break; + default: + break; + } + + return input::UiCommand::none; + } + + void log_app_update(logging::Logger &logger, const app::ClientState &state, const app::AppUpdate &update) { + if (!update.activatedItemId.empty()) { + logger.log(logging::LogLevel::info, "ui", "Activated menu item: " + update.activatedItemId); + } + if (update.screenChanged) { + logger.log(logging::LogLevel::info, "ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); + } + if (update.overlayVisibilityChanged) { + logger.log(logging::LogLevel::info, "overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + } + if (update.exitRequested) { + logger.log(logging::LogLevel::info, "app", "Exit requested from shell"); + } + } + + bool persist_hosts(logging::Logger &logger, app::ClientState &state) { + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(state.hosts); + if (saveResult.success) { + state.hostsDirty = false; + logger.log(logging::LogLevel::info, "hosts", "Saved host records"); + return true; + } + + logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); + return false; + } + + void persist_hosts_if_needed(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + if (!update.hostsChanged) { + return; + } + + persist_hosts(logger, state); + } + + bool test_tcp_host_connection(const std::string &address, uint16_t port, std::string *message) { + if (!network::runtime_network_ready()) { + if (message != nullptr) { + *message = network::runtime_network_status().summary; + } + return false; + } + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + if (!network::query_server_info(address, port, &serverInfo, &errorMessage)) { + if (message != nullptr) { + *message = std::move(errorMessage); + } + return false; + } + + if (message != nullptr) { + *message = "Received /serverinfo from " + address + ":" + std::to_string(serverInfo.httpPort) + + " and discovered HTTPS pairing on port " + std::to_string(serverInfo.httpsPort); + } + return true; + } + + struct PairingTaskState { + SDL_Thread *thread; + std::atomic completed; + std::atomic discardResult; + network::HostPairingRequest request; + network::HostPairingResult result; + struct DeferredLogEntry { + logging::LogLevel level; + std::string message; + }; + std::vector deferredLogs; + }; + + void reset_pairing_task(PairingTaskState *task) { + if (task == nullptr) { + return; + } + + task->thread = nullptr; + task->completed.store(false); + task->discardResult.store(false); + task->request = {}; + task->result = {false, false, {}}; + task->deferredLogs.clear(); + } + + bool pairing_task_is_active(const PairingTaskState &task) { + return task.thread != nullptr && !task.completed.load(); + } + + int run_pairing_task(void *context) { + PairingTaskState *task = static_cast(context); + if (task == nullptr) { + return -1; + } + + debugPrint("[PAIRING] worker entered\n"); + + const startup::LoadClientIdentityResult loadedIdentity = startup::load_client_identity(); + debugPrint("[PAIRING] client identity load completed\n"); + 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; + debugPrint("[PAIRING] generating client identity\n"); + 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, std::memory_order_release); + return 0; + } + + const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity); + debugPrint("[PAIRING] client identity save completed\n"); + if (!saveResult.success) { + task->result = {false, false, saveResult.errorMessage}; + task->completed.store(true, std::memory_order_release); + return 0; + } + + task->deferredLogs.push_back({logging::LogLevel::info, "Saved pairing identity"}); + } + + task->request.identity = std::move(identity); + debugPrint("[PAIRING] invoking network::pair_host\n"); + task->result = network::pair_host(task->request); + debugPrint("[PAIRING] network::pair_host returned\n"); + task->completed.store(true, std::memory_order_release); + return 0; + } + + void finish_pairing_task_if_ready(logging::Logger &logger, app::ClientState &state, PairingTaskState *task) { + if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { + return; + } + + SDL_Thread *thread = task->thread; + task->thread = nullptr; + int threadResult = 0; + SDL_WaitThread(thread, &threadResult); + (void) threadResult; + + const network::HostPairingRequest request = task->request; + const network::HostPairingResult result = task->result; + const bool discardResult = task->discardResult.load(); + const std::vector deferredLogs = task->deferredLogs; + reset_pairing_task(task); + + for (const PairingTaskState::DeferredLogEntry &entry : deferredLogs) { + logger.log(entry.level, "pairing", entry.message); + } + + if (discardResult) { + logger.log(logging::LogLevel::info, "pairing", "Ignored a completed pairing result after leaving the pairing screen"); + return; + } + + const bool hostsChanged = app::apply_pairing_result( + state, + request.address, + request.httpPort, + result.success || result.alreadyPaired, + result.message + ); + + logger.log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); + if (hostsChanged) { + persist_hosts(logger, state); + } + } + + void ignore_pairing_result_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + if (task == nullptr || update.activatedItemId != "cancel-pairing" || task->thread == nullptr) { + return; + } + + task->discardResult.store(true); + state.statusMessage = "Left the pairing screen. The active network attempt will finish in the background and its result will be ignored."; + logger.log(logging::LogLevel::info, "pairing", state.statusMessage); + } + + void test_host_connection_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + if (!update.connectionTestRequested) { + return; + } + + const std::string address = update.connectionTestAddress; + const uint16_t port = update.connectionTestPort == 0 ? app::DEFAULT_HOST_PORT : update.connectionTestPort; + + if (address.empty()) { + app::apply_connection_test_result(state, false, "Connection test failed because the host address is invalid"); + logger.log(logging::LogLevel::warning, "hosts", state.statusMessage); + return; + } + + std::string resultMessage; + const bool success = test_tcp_host_connection(address, port, &resultMessage); + app::apply_connection_test_result(state, success, resultMessage); + logger.log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); + } + + void pair_host_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + if (!update.pairingRequested || task == nullptr) { + return; + } + + if (pairing_task_is_active(*task)) { + const std::string busyMessage = "A pairing attempt is already running in the background"; + app::apply_pairing_result(state, update.pairingAddress, update.pairingPort, false, busyMessage); + logger.log(logging::LogLevel::warning, "pairing", busyMessage); + return; + } + + reset_pairing_task(task); + task->request = { + update.pairingAddress, + update.pairingPort, + update.pairingPin, + "MoonlightXboxOG", + {}, + }; + + task->thread = SDL_CreateThreadWithStackSize(run_pairing_task, "pair-host", PAIRING_THREAD_STACK_SIZE, task); + if (task->thread == nullptr) { + reset_pairing_task(task); + const std::string createThreadError = std::string("Failed to start the background pairing task: ") + SDL_GetError(); + app::apply_pairing_result(state, update.pairingAddress, update.pairingPort, false, createThreadError); + logger.log(logging::LogLevel::error, "pairing", createThreadError); + return; + } + + state.pairingDraft.statusMessage = "Pairing is preparing the client identity and contacting the host in the background. Enter the PIN on the host if prompted and keep this screen open for the result."; + state.statusMessage = state.pairingDraft.statusMessage; + logger.log(logging::LogLevel::info, "pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); + } + + bool draw_shell(SDL_Renderer *renderer, SDL_Texture *titleLogoTexture, TTF_Font *titleFont, TTF_Font *bodyFont, TTF_Font *smallFont, const ui::ShellViewModel &viewModel) { + int screenWidth = 0; + int screenHeight = 0; + if (SDL_GetRendererOutputSize(renderer, &screenWidth, &screenHeight) != 0 || screenWidth <= 0 || screenHeight <= 0) { + return false; + } + + const int outerMargin = std::max(18, screenHeight / 24); + const int panelGap = std::max(14, screenWidth / 48); + const int menuPanelWidth = std::max(228, (screenWidth * 34) / 100); + const SDL_Rect menuPanel {outerMargin, outerMargin, menuPanelWidth, screenHeight - (outerMargin * 2)}; + const SDL_Rect contentPanel { + outerMargin + menuPanelWidth + panelGap, + outerMargin, + screenWidth - ((outerMargin * 2) + menuPanelWidth + panelGap), + screenHeight - (outerMargin * 2) + }; + + SDL_SetRenderDrawColor(renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + if (SDL_RenderClear(renderer) != 0) { + return false; + } + + fill_rect(renderer, menuPanel, PANEL_RED, PANEL_GREEN, PANEL_BLUE); + fill_rect(renderer, contentPanel, PANEL_RED, PANEL_GREEN, PANEL_BLUE); + draw_rect(renderer, menuPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + draw_rect(renderer, contentPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + + int titleTextX = menuPanel.x + 16; + const int titleTextY = menuPanel.y + 12; + int titleTextWidth = menuPanel.w - 32; + + if (titleLogoTexture != nullptr) { + int logoWidth = 0; + int logoHeight = 0; + if (SDL_QueryTexture(titleLogoTexture, nullptr, nullptr, &logoWidth, &logoHeight) == 0 && logoWidth > 0 && logoHeight > 0) { + const int targetLogoHeight = std::max(32, TTF_FontLineSkip(titleFont)); + const int targetLogoWidth = std::max(32, (logoWidth * targetLogoHeight) / logoHeight); + const SDL_Rect logoRect { + menuPanel.x + 16, + menuPanel.y + 10, + targetLogoWidth, + targetLogoHeight, + }; + if (SDL_RenderCopy(renderer, titleLogoTexture, nullptr, &logoRect) != 0) { + return false; + } + + titleTextX = logoRect.x + logoRect.w + 12; + titleTextWidth = menuPanel.x + menuPanel.w - 16 - titleTextX; + } + } + + if (!render_text_line(renderer, titleFont, viewModel.title, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, titleTextX, titleTextY, titleTextWidth)) { + return false; + } + + int menuY = menuPanel.y + std::max(70, screenHeight / 6); + for (const ui::ShellMenuRow &row : viewModel.menuRows) { + const SDL_Rect rowRect {menuPanel.x + 12, menuY - 6, menuPanel.w - 24, std::max(36, screenHeight / 13)}; + 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 SDL_Color rowColor = row.enabled + ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} + : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; + if (!render_text_line(renderer, bodyFont, row.label, rowColor, menuPanel.x + 24, menuY, menuPanel.w - 48)) { + return false; + } + + menuY += rowRect.h + 4; + } + + int bodyY = contentPanel.y + 16; + for (const std::string &line : viewModel.bodyLines) { + int drawnHeight = 0; + if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentPanel.x + 20, bodyY, contentPanel.w - 40, &drawnHeight)) { + return false; + } + bodyY += drawnHeight + 10; + } + + int footerY = contentPanel.y + contentPanel.h - std::max(104, screenHeight / 5); + for (const std::string &line : viewModel.footerLines) { + int drawnHeight = 0; + if (!render_text_line(renderer, smallFont, line, {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, contentPanel.x + 20, footerY, contentPanel.w - 40, &drawnHeight)) { + return false; + } + footerY += drawnHeight + 4; + } + + if (viewModel.overlayVisible) { + 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.overlayTitle, {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.overlayLines) { + 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.keypadModalVisible) { + 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.keypadModalColumnCount)); + const int buttonRowCount = std::max(1, static_cast((viewModel.keypadModalButtons.size() + viewModel.keypadModalColumnCount - 1) / viewModel.keypadModalColumnCount)); + const int preferredButtonHeight = std::max(40, TTF_FontLineSkip(bodyFont) + 16); + const int modalInnerWidth = modalWidth - 32; + int modalTextHeight = 0; + for (const std::string &line : viewModel.keypadModalLines) { + modalTextHeight += measure_wrapped_text_height(smallFont, line, modalInnerWidth) + 6; + } + 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 (!render_text_line(renderer, bodyFont, viewModel.keypadModalTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 16, modalRect.w - 32)) { + return false; + } + + int modalY = modalRect.y + 52; + for (const std::string &line : viewModel.keypadModalLines) { + 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; + } + + 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); + + for (std::size_t index = 0; index < viewModel.keypadModalButtons.size(); ++index) { + const int row = static_cast(index / viewModel.keypadModalColumnCount); + const int column = static_cast(index % viewModel.keypadModalColumnCount); + const SDL_Rect buttonRect { + modalRect.x + 16 + (column * (buttonWidth + buttonGap)), + buttonAreaTop + (row * (buttonHeight + buttonGap)), + buttonWidth, + buttonHeight, + }; + const ui::ShellModalButton &button = viewModel.keypadModalButtons[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 (!render_text_centered(renderer, bodyFont, button.label, buttonColor, buttonRect)) { + return false; + } + } + } + + SDL_RenderPresent(renderer); + return true; + } + + void close_controller(SDL_GameController *controller) { + if (controller != nullptr) { + SDL_GameControllerClose(controller); + } + } + +} // namespace + +namespace ui { + + int run_shell(SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state, logging::Logger &logger) { + if (window == nullptr) { + return report_shell_failure(logger, "sdl", "Shell requires a valid SDL window"); + } + + if (TTF_Init() != 0) { + return report_shell_failure(logger, "ttf", std::string("TTF_Init failed: ") + TTF_GetError()); + } + + IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG); + + SDL_Renderer *renderer = SDL_CreateRenderer(window, -1, 0); + if (renderer == nullptr) { + IMG_Quit(); + TTF_Quit(); + return report_shell_failure(logger, "sdl", std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); + } + + const std::string fontPath = build_asset_path("fonts\\vegur-regular.ttf"); + TTF_Font *titleFont = TTF_OpenFont(fontPath.c_str(), std::max(24, videoMode.height / 16)); + TTF_Font *bodyFont = TTF_OpenFont(fontPath.c_str(), std::max(18, videoMode.height / 24)); + TTF_Font *smallFont = TTF_OpenFont(fontPath.c_str(), std::max(14, videoMode.height / 34)); + if (titleFont == nullptr || bodyFont == nullptr || smallFont == nullptr) { + if (titleFont != nullptr) { + TTF_CloseFont(titleFont); + } + if (bodyFont != nullptr) { + TTF_CloseFont(bodyFont); + } + if (smallFont != nullptr) { + TTF_CloseFont(smallFont); + } + SDL_DestroyRenderer(renderer); + IMG_Quit(); + TTF_Quit(); + return report_shell_failure(logger, "ttf", std::string("Failed to load shell font from ") + fontPath + ": " + TTF_GetError()); + } + + SDL_Texture *titleLogoTexture = load_texture_from_asset(renderer, "moonlight-logo.svg"); + + SDL_GameController *controller = nullptr; + for (int joystickIndex = 0; joystickIndex < SDL_NumJoysticks(); ++joystickIndex) { + if (SDL_IsGameController(joystickIndex)) { + controller = SDL_GameControllerOpen(joystickIndex); + if (controller != nullptr) { + logger.log(logging::LogLevel::info, "input", "Opened primary controller"); + break; + } + } + } + + bool running = true; + bool leftTriggerPressed = false; + bool rightTriggerPressed = false; + PairingTaskState pairingTask {}; + reset_pairing_task(&pairingTask); + logger.log(logging::LogLevel::info, "app", "Entered interactive shell"); + + while (running && !state.shouldExit) { + finish_pairing_task_if_ready(logger, state, &pairingTask); + + SDL_Event event; + while (SDL_PollEvent(&event)) { + input::UiCommand command = input::UiCommand::none; + + switch (event.type) { + case SDL_QUIT: + state.shouldExit = true; + break; + case SDL_CONTROLLERDEVICEADDED: + if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { + controller = SDL_GameControllerOpen(event.cdevice.which); + if (controller != nullptr) { + logger.log(logging::LogLevel::info, "input", "Controller connected"); + } + } + break; + case SDL_CONTROLLERDEVICEREMOVED: + if (controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.cdevice.which)) { + close_controller(controller); + controller = nullptr; + leftTriggerPressed = false; + rightTriggerPressed = false; + logger.log(logging::LogLevel::warning, "input", "Controller disconnected"); + } + break; + case SDL_CONTROLLERBUTTONDOWN: + command = translate_controller_button(event.cbutton.button); + break; + case SDL_CONTROLLERAXISMOTION: + command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); + break; + case SDL_KEYDOWN: + if (event.key.repeat == 0) { + command = translate_keyboard_key(event.key.keysym.sym, event.key.keysym.mod); + } + break; + default: + break; + } + + if (command != input::UiCommand::none) { + const app::AppUpdate update = app::handle_command(state, command); + log_app_update(logger, state, update); + ignore_pairing_result_if_requested(logger, state, update, &pairingTask); + test_host_connection_if_requested(logger, state, update); + pair_host_if_requested(logger, state, update, &pairingTask); + persist_hosts_if_needed(logger, state, update); + } + } + + finish_pairing_task_if_ready(logger, state, &pairingTask); + + const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); + if (!draw_shell(renderer, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel)) { + report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); + running = false; + break; + } + + SDL_Delay(16); + } + + if (pairingTask.thread != nullptr) { + pairingTask.discardResult.store(true); + int threadResult = 0; + SDL_WaitThread(pairingTask.thread, &threadResult); + (void) threadResult; + } + + close_controller(controller); + destroy_texture(titleLogoTexture); + TTF_CloseFont(smallFont); + TTF_CloseFont(bodyFont); + TTF_CloseFont(titleFont); + SDL_DestroyRenderer(renderer); + IMG_Quit(); + TTF_Quit(); + return 0; + } + +} // namespace ui diff --git a/src/ui/shell_screen.h b/src/ui/shell_screen.h new file mode 100644 index 0000000..e138f72 --- /dev/null +++ b/src/ui/shell_screen.h @@ -0,0 +1,25 @@ +#pragma once + +// nxdk includes +#include + +// local includes +#include "src/app/client_state.h" +#include "src/logging/logger.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. + * @param logger Structured logger used for diagnostics and overlay content. + * @return 0 on normal exit, non-zero if initialization failed. + */ + int run_shell(SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state, logging::Logger &logger); + +} // namespace ui diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp new file mode 100644 index 0000000..a922e21 --- /dev/null +++ b/src/ui/shell_view.cpp @@ -0,0 +1,256 @@ +// class header include +#include "src/ui/shell_view.h" + +// standard includes +#include +#include + +namespace { + + std::string screen_title(app::ScreenId screen) { + switch (screen) { + case app::ScreenId::home: + return "Xbox"; + case app::ScreenId::hosts: + return "Hosts"; + case app::ScreenId::add_host: + return "Add Host"; + case app::ScreenId::pair_host: + return "Pair Host"; + case app::ScreenId::settings: + return "Settings"; + } + + return "Xbox"; + } + + 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 (47984)" + : state.addHostDraft.keypad.stagedInput; + } + + return state.addHostDraft.activeField == app::AddHostField::address + ? state.addHostDraft.addressInput + : (state.addHostDraft.portInput.empty() ? "default (47984)" : state.addHostDraft.portInput); + } + + 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.push_back({ + 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 to choose a key, A to enter it, X to delete, Start to accept, and B to cancel.", + }; + + 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."); + } + + return lines; + } + + std::vector screen_body_lines(const app::ClientState &state) { + switch (state.activeScreen) { + case app::ScreenId::home: + { + std::vector lines = { + "Controller-first Moonlight shell prototype.", + "Use Hosts to review saved PCs and future pairing work.", + "Use Add Host to create a manual host entry with keypad editing.", + "Use Settings for display, input, and logging options.", + "Saved hosts available: " + std::to_string(state.hosts.size()), + }; + + if (!state.statusMessage.empty()) { + lines.push_back("Status: " + state.statusMessage); + } + + return lines; + } + case app::ScreenId::hosts: + { + std::vector lines; + if (state.hosts.empty()) { + lines = { + "No saved hosts yet.", + "Select Add Host to create a manual IPv4 entry.", + "Discovery and real host-driven pairing will build on this saved-host list next.", + }; + } + else { + lines.push_back("Saved hosts: " + std::to_string(state.hosts.size())); + if (const app::HostRecord *host = app::selected_host(state); host != nullptr) { + lines.push_back("Selected host: " + host->displayName); + lines.push_back("Address: " + host->address); + lines.push_back("Port: " + std::to_string(app::effective_host_port(host->port))); + lines.push_back(std::string("Pairing: ") + (host->pairingState == app::PairingState::paired ? "Paired" : "Not paired yet")); + lines.emplace_back(host->pairingState == app::PairingState::paired + ? "This host is already paired." + : "Select Pair Selected Host to start the Sunshine pairing handshake in the background."); + } + else { + lines.emplace_back("Select a saved host to inspect its address and pairing state."); + } + } + + if (!state.statusMessage.empty()) { + lines.push_back("Status: " + state.statusMessage); + } + + return lines; + } + case app::ScreenId::add_host: + { + std::vector lines = { + "Manual host entry with a popup keypad.", + std::string("Current address: ") + app::current_add_host_address(state), + std::string("Current port: ") + std::to_string(app::current_add_host_port(state)), + std::string("Selected field: ") + active_add_host_field_label(state), + "Select Host Address or Port to open the keypad modal.", + "Use Clear Current Field to erase the selected field.", + "Test Connection checks reachability before you save the host.", + }; + + if (!state.addHostDraft.validationMessage.empty()) { + lines.push_back("Validation: " + state.addHostDraft.validationMessage); + } + + if (!state.addHostDraft.connectionMessage.empty()) { + lines.push_back("Connection: " + state.addHostDraft.connectionMessage); + } + + return lines; + } + case app::ScreenId::pair_host: + { + std::vector lines = { + "Pairing now prepares the client identity and runs the network handshake in the background so the shell stays responsive.", + std::string("Target host: ") + state.pairingDraft.targetAddress, + std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort), + std::string("Last generated PIN: ") + app::current_pairing_pin(state), + "Enter the PIN on the host if prompted and wait for the status below.", + "Cancel leaves this screen, but the active network request may need a few seconds to unwind.", + }; + + if (!state.pairingDraft.statusMessage.empty()) { + lines.push_back("Status: " + state.pairingDraft.statusMessage); + } + + return lines; + } + case app::ScreenId::settings: + return { + "Display, input, overlay, and logging settings land here next.", + "The renderer already supports a log overlay toggle.", + "Use Back to return to the home screen.", + }; + } + + return {}; + } + + std::vector footer_lines(const app::ClientState &state) { + std::vector lines = { + "D-pad / Arrows: move", + "A / Enter: select", + "B / Esc: back", + "Y / F3: toggle overlay", + "Black/White/LT/RT / PgUp/PgDn: scroll logs", + }; + + if (state.activeScreen == app::ScreenId::add_host) { + lines.insert(lines.begin() + 1, state.addHostDraft.keypad.visible + ? "Keypad open: D-pad moves, A enters, X deletes, Start accepts, B cancels" + : "Select Address or Port to open the keypad modal"); + } + + return lines; + } + +} // namespace + +namespace ui { + + ShellViewModel build_shell_view_model( + const app::ClientState &state, + const std::vector &logEntries, + const std::vector &statsLines + ) { + ShellViewModel viewModel {}; + viewModel.title = screen_title(state.activeScreen); + viewModel.bodyLines = screen_body_lines(state); + viewModel.footerLines = footer_lines(state); + viewModel.overlayVisible = state.overlayVisible; + viewModel.overlayTitle = "Diagnostics"; + viewModel.keypadModalVisible = state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible; + viewModel.keypadModalTitle = state.addHostDraft.activeField == app::AddHostField::address ? "Address Keypad" : "Port Keypad"; + viewModel.keypadModalColumnCount = 3; + + const ui::MenuItem *selectedItem = state.menu.selected_item(); + for (const MenuItem &item : state.menu.items()) { + viewModel.menuRows.push_back({ + item.id, + item.label, + item.enabled, + selectedItem != nullptr && item.id == selectedItem->id, + }); + } + + if (viewModel.keypadModalVisible) { + viewModel.keypadModalLines = keypad_modal_lines(state); + viewModel.keypadModalButtons = keypad_buttons(state); + } + + if (viewModel.overlayVisible) { + if (!statsLines.empty()) { + viewModel.overlayLines.insert(viewModel.overlayLines.end(), statsLines.begin(), statsLines.end()); + } + else { + viewModel.overlayLines.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.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.overlayLines.push_back(logging::format_entry(logEntries[index])); + } + + if (clampedOffset > 0) { + viewModel.overlayLines.insert(viewModel.overlayLines.begin(), "Showing earlier log entries"); + } + } + + return viewModel; + } + +} // namespace ui diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h new file mode 100644 index 0000000..6a335f7 --- /dev/null +++ b/src/ui/shell_view.h @@ -0,0 +1,65 @@ +#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 menu row for the SDL shell. + */ + struct ShellMenuRow { + std::string id; + std::string label; + bool enabled; + bool selected; + }; + + /** + * @brief Render-ready button in the add-host keypad modal. + */ + struct ShellModalButton { + std::string label; + bool enabled; + bool selected; + }; + + /** + * @brief Render-ready shell state derived from the app model. + */ + struct ShellViewModel { + std::string title; + std::vector bodyLines; + std::vector menuRows; + std::vector footerLines; + bool overlayVisible; + std::string overlayTitle; + std::vector overlayLines; + bool keypadModalVisible; + std::string keypadModalTitle; + std::vector keypadModalLines; + std::vector keypadModalButtons; + std::size_t keypadModalColumnCount; + }; + + /** + * @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..b5d4f05 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,6 +15,8 @@ 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) +include(GetOpenSSL REQUIRED) + set(TEST_COVERAGE_COMPILE_OPTIONS) set(TEST_COVERAGE_LINK_OPTIONS) if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" @@ -23,9 +25,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,6 +38,18 @@ add_executable(${PROJECT_NAME} ${MOONLIGHT_HOST_TESTABLE_SOURCES}) set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) +set(MOONLIGHT_TEST_OPENSSL_LIBRARIES OpenSSL::SSL OpenSSL::Crypto) +if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" + OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + set(MOONLIGHT_TEST_OPENSSL_LIBRARIES + -Wl,--start-group + -Wl,--whole-archive + OpenSSL::SSL + OpenSSL::Crypto + -Wl,--no-whole-archive + -Wl,--end-group) +endif() + target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/tests/support @@ -44,7 +58,13 @@ target_include_directories(${PROJECT_NAME} ${GTEST_SOURCE_DIR}/googlemock/include) target_link_libraries(${PROJECT_NAME} PRIVATE - gtest_main) + gtest_main + ${MOONLIGHT_TEST_OPENSSL_LIBRARIES} + ws2_32 + crypt32 + gdi32 + advapi32 + user32) target_compile_options(${PROJECT_NAME} PRIVATE ${TEST_COVERAGE_COMPILE_OPTIONS}) @@ -56,7 +76,7 @@ add_dependencies(${PROJECT_NAME} gtest gtest_main gmock gmock_main) 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/hal/debug.h b/tests/support/hal/debug.h index e4d4757..2d8d15f 100644 --- a/tests/support/hal/debug.h +++ b/tests/support/hal/debug.h @@ -1,5 +1,6 @@ #pragma once +// standard includes #include #define debugPrint(...) std::printf(__VA_ARGS__) diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp new file mode 100644 index 0000000..1c08b06 --- /dev/null +++ b/tests/unit/app/client_state_test.cpp @@ -0,0 +1,262 @@ +// class header include +#include "src/app/client_state.h" + +// standard includes +#include + +// lib includes +#include + +namespace { + + TEST(ClientStateTest, StartsOnTheHomeScreenWithTheHostsEntrySelected) { + const app::ClientState state = app::create_initial_state(); + + EXPECT_EQ(state.activeScreen, app::ScreenId::home); + EXPECT_FALSE(state.overlayVisible); + EXPECT_FALSE(state.shouldExit); + EXPECT_FALSE(state.hostsDirty); + ASSERT_NE(state.menu.selected_item(), nullptr); + EXPECT_EQ(state.menu.selected_item()->id, "hosts"); + } + + TEST(ClientStateTest, ReplacesHostsFromPersistenceWithoutMarkingThemDirty) { + app::ClientState state = app::create_initial_state(); + + app::replace_hosts(state, { + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + }, "Loaded 2 saved host(s)"); + + ASSERT_EQ(state.hosts.size(), 2U); + EXPECT_FALSE(state.hostsDirty); + EXPECT_EQ(state.statusMessage, "Loaded 2 saved host(s)"); + } + + TEST(ClientStateTest, ActivatingHomeEntriesTransitionsBetweenTopLevelScreens) { + app::ClientState state = app::create_initial_state(); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(update.activatedItemId, "hosts"); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + + update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::home); + + state.menu.handle_command(input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(update.activatedItemId, "add-host"); + EXPECT_EQ(state.activeScreen, app::ScreenId::add_host); + + update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::home); + + state.menu.handle_command(input::UiCommand::move_down); + state.menu.handle_command(input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(update.activatedItemId, "settings"); + EXPECT_EQ(state.activeScreen, app::ScreenId::settings); + } + + TEST(ClientStateTest, TogglingAndScrollingTheOverlayUpdatesTheVisibleState) { + app::ClientState state = app::create_initial_state(); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::toggle_overlay); + EXPECT_TRUE(update.overlayChanged); + EXPECT_TRUE(update.overlayVisibilityChanged); + EXPECT_TRUE(state.overlayVisible); + EXPECT_EQ(state.overlayScrollOffset, 0U); + + update = app::handle_command(state, input::UiCommand::previous_page); + EXPECT_TRUE(update.overlayChanged); + EXPECT_FALSE(update.overlayVisibilityChanged); + EXPECT_GT(state.overlayScrollOffset, 0U); + + update = app::handle_command(state, input::UiCommand::next_page); + EXPECT_TRUE(update.overlayChanged); + EXPECT_FALSE(update.overlayVisibilityChanged); + EXPECT_EQ(state.overlayScrollOffset, 0U); + } + + TEST(ClientStateTest, ActivatingExitMarksTheClientForShutdown) { + app::ClientState state = app::create_initial_state(); + + state.menu.handle_command(input::UiCommand::move_down); + state.menu.handle_command(input::UiCommand::move_down); + state.menu.handle_command(input::UiCommand::move_down); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.exitRequested); + EXPECT_EQ(update.activatedItemId, "exit"); + EXPECT_TRUE(state.shouldExit); + } + + TEST(ClientStateTest, CanSaveAManualHostEntryWithACustomPort) { + app::ClientState state = app::create_initial_state(); + + state.menu.handle_command(input::UiCommand::move_down); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); + state.addHostDraft.addressInput = "193.168.1.10"; + state.addHostDraft.portInput = "48000"; + state.menu.select_item_by_id("save-host"); + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_EQ(state.hosts.front().address, "193.168.1.10"); + EXPECT_EQ(state.hosts.front().port, 48000); + EXPECT_EQ(state.hosts.front().displayName, "Host 193.168.1.10"); + ASSERT_NE(state.menu.selected_item(), nullptr); + EXPECT_EQ(state.menu.selected_item()->id, "host:193.168.1.10:48000"); + } + + TEST(ClientStateTest, RejectsDuplicateHostEntriesAndAllowsCancellationBackToHosts) { + app::ClientState state = app::create_initial_state(); + + state.menu.handle_command(input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = "192.168.0.10"; + state.menu.select_item_by_id("save-host"); + app::handle_command(state, input::UiCommand::activate); + + ASSERT_EQ(state.hosts.size(), 1U); + ASSERT_EQ(state.activeScreen, app::ScreenId::hosts); + + state.menu.handle_command(input::UiCommand::move_down); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.screenChanged); + ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); + + state.addHostDraft.addressInput = "192.168.0.10"; + state.menu.select_item_by_id("save-host"); + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_FALSE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::add_host); + 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.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + ASSERT_NE(state.menu.selected_item(), nullptr); + EXPECT_EQ(state.menu.selected_item()->id, "add-host"); + } + + TEST(ClientStateTest, StartsPairingForTheSelectedUnpairedHost) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + }); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + + const auto pairItem = std::find_if(state.menu.items().begin(), state.menu.items().end(), [](const ui::MenuItem &item) { + return item.id == "pair-host"; + }); + ASSERT_NE(pairItem, state.menu.items().end()); + EXPECT_TRUE(pairItem->enabled); + EXPECT_TRUE(state.menu.select_item_by_id("pair-host")); + + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.screenChanged); + EXPECT_FALSE(update.hostsChanged); + EXPECT_TRUE(update.pairingRequested); + EXPECT_EQ(update.pairingAddress, "192.168.1.20"); + EXPECT_EQ(update.pairingPort, app::DEFAULT_HOST_PORT); + EXPECT_TRUE(app::is_valid_pairing_pin(update.pairingPin)); + EXPECT_EQ(state.activeScreen, app::ScreenId::pair_host); + EXPECT_EQ(state.pairingDraft.targetAddress, "192.168.1.20"); + EXPECT_EQ(state.pairingDraft.targetPort, app::DEFAULT_HOST_PORT); + EXPECT_EQ(state.pairingDraft.generatedPin, update.pairingPin); + EXPECT_EQ(state.pairingDraft.stage, app::PairingStage::in_progress); + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::not_paired); + EXPECT_FALSE(state.hostsDirty); + } + + TEST(ClientStateTest, CanDeleteTheSelectedHostAndKeepFocusOnTheRemainingList) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + }); + + app::handle_command(state, input::UiCommand::activate); + state.menu.select_item_by_id("delete-host"); + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_FALSE(update.screenChanged); + EXPECT_TRUE(update.hostsChanged); + ASSERT_EQ(state.hosts.size(), 1U); + EXPECT_EQ(state.hosts.front().address, "10.0.0.25"); + ASSERT_NE(state.menu.selected_item(), nullptr); + EXPECT_EQ(state.menu.selected_item()->id, "host:10.0.0.25:48000"); + EXPECT_TRUE(state.hostsDirty); + } + + TEST(ClientStateTest, RequestsAConnectionTestFromTheAddHostScreen) { + app::ClientState state = app::create_initial_state(); + + state.menu.handle_command(input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = "192.168.0.10"; + 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.connectionTestRequested); + EXPECT_EQ(update.connectionTestAddress, "192.168.0.10"); + EXPECT_EQ(update.connectionTestPort, 48000); + EXPECT_EQ(state.statusMessage, "Testing connection to 192.168.0.10:48000..."); + } + + TEST(ClientStateTest, StagesCancelsDeletesAndAcceptsAddHostKeypadEdits) { + app::ClientState state = app::create_initial_state(); + + state.menu.handle_command(input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.addressInput = "192.168.0.10"; + + app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + EXPECT_FALSE(update.screenChanged); + EXPECT_TRUE(state.addHostDraft.keypad.visible); + EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 0U); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, "192.168.0.10"); + + app::handle_command(state, input::UiCommand::activate); + EXPECT_EQ(state.addHostDraft.addressInput, "192.168.0.10"); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, "192.168.0.101"); + + app::handle_command(state, input::UiCommand::delete_character); + EXPECT_EQ(state.addHostDraft.keypad.stagedInput, "192.168.0.10"); + + app::handle_command(state, input::UiCommand::back); + EXPECT_FALSE(state.addHostDraft.keypad.visible); + EXPECT_EQ(state.addHostDraft.addressInput, "192.168.0.10"); + ASSERT_NE(state.menu.selected_item(), nullptr); + EXPECT_EQ(state.menu.selected_item()->id, "edit-address"); + + 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, "192.168.0.101"); + app::handle_command(state, input::UiCommand::confirm); + EXPECT_FALSE(state.addHostDraft.keypad.visible); + EXPECT_EQ(state.addHostDraft.addressInput, "192.168.0.101"); + } + +} // 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..e8b4c94 --- /dev/null +++ b/tests/unit/app/host_records_test.cpp @@ -0,0 +1,106 @@ +// class header include +#include "src/app/host_records.h" + +// standard includes +#include + +// lib includes +#include + +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", + "192.168.1.20", + 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, SerializesAndParsesRoundTripHostLists) { + const std::vector records = { + {"Living Room PC", "192.168.1.20", 0, app::PairingState::paired}, + {"Steam Deck Dock", "10.0.0.15", 48000, 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, "192.168.1.20"); + 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, "10.0.0.15"); + EXPECT_EQ(parsedRecords.records[1].port, 48000); + EXPECT_EQ(parsedRecords.records[1].pairingState, app::PairingState::not_paired); + } + + TEST(HostRecordsTest, ReportsMalformedSerializedLinesWithoutDroppingValidRecords) { + const std::string serializedRecords = + "Living Room PC\t192.168.1.20\t\tpaired\n" + "Broken Host\tnot-an-ip\t\tnot_paired\n" + "Bad Format\n" + "Office PC\t10.0.0.25\t48000\tnot_paired\n"; + + const app::ParseHostRecordsResult parsedRecords = app::parse_host_records(serializedRecords); + + ASSERT_EQ(parsedRecords.records.size(), 2U); + EXPECT_EQ(parsedRecords.records[0].address, "192.168.1.20"); + EXPECT_EQ(parsedRecords.records[0].port, 0); + EXPECT_EQ(parsedRecords.records[1].address, "10.0.0.25"); + EXPECT_EQ(parsedRecords.records[1].port, 48000); + 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", "192.168.1.20", 0, app::PairingState::paired}, + }; + + EXPECT_TRUE(app::contains_host_address(records, "192.168.1.20")); + EXPECT_FALSE(app::contains_host_address(records, "192.168.1.21")); + EXPECT_FALSE(app::contains_host_address(records, "192.168.1.20", 48000)); + } + + 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, 48000); + EXPECT_EQ(app::effective_host_port(parsedPort), 48000); + + EXPECT_FALSE(app::try_parse_host_port("0", &parsedPort)); + EXPECT_FALSE(app::try_parse_host_port("70000", &parsedPort)); + } + +} // 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..2d4fe6a --- /dev/null +++ b/tests/unit/app/pairing_flow_test.cpp @@ -0,0 +1,28 @@ +// class header include +#include "src/app/pairing_flow.h" + +// lib includes +#include + +namespace { + + TEST(PairingFlowTest, CreatesAFreshPairingDraftWithTheDefaultPin) { + const app::PairingDraft draft = app::create_pairing_draft("192.168.1.20", 47984, "4821"); + + EXPECT_EQ(draft.targetAddress, "192.168.1.20"); + EXPECT_EQ(draft.targetPort, 47984); + EXPECT_EQ(draft.stage, app::PairingStage::pin_ready); + EXPECT_EQ(draft.generatedPin, "4821"); + EXPECT_FALSE(draft.statusMessage.empty()); + } + + 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/input/navigation_input_test.cpp b/tests/unit/input/navigation_input_test.cpp new file mode 100644 index 0000000..d033a44 --- /dev/null +++ b/tests/unit/input/navigation_input_test.cpp @@ -0,0 +1,30 @@ +// 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::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::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::y), input::UiCommand::toggle_overlay); + } + + TEST(NavigationInputTest, MapsKeyboardKeysToNavigationCommands) { + 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::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::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::f3), input::UiCommand::toggle_overlay); + } + +} // namespace diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp new file mode 100644 index 0000000..8d71904 --- /dev/null +++ b/tests/unit/logging/logger_test.cpp @@ -0,0 +1,68 @@ +// class header include +#include "src/logging/logger.h" + +// standard includes +#include +#include + +// lib includes +#include + +namespace { + + TEST(LoggerTest, StoresEntriesAboveTheConfiguredMinimumLevel) { + logging::Logger logger(4); + logger.set_minimum_level(logging::LogLevel::debug); + + 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"); + } + + TEST(LoggerTest, DropsTheOldestEntriesWhenCapacityIsReached) { + logging::Logger logger(2); + + 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.add_sink([&seenMessages](const logging::LogEntry &entry) { + seenMessages.push_back(logging::format_entry(entry)); + }); + + EXPECT_TRUE(logger.log(logging::LogLevel::info, "ui", "opened")); + + ASSERT_EQ(seenMessages.size(), 1U); + EXPECT_EQ(seenMessages.front(), "[INFO] ui: opened"); + } + + TEST(LoggerTest, SnapshotFiltersByMinimumLevel) { + logging::Logger logger; + + 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); + } + +} // 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..a93425d --- /dev/null +++ b/tests/unit/network/host_pairing_test.cpp @@ -0,0 +1,47 @@ +#include "src/network/host_pairing.h" + +#include + +namespace { + + 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, ParsesServerInfoResponsesForPairing) { + const std::string xml = + "" + "7.1.431.0" + "47989" + "47990" + "1" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, 47984, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.serverMajorVersion, 7); + EXPECT_EQ(serverInfo.httpPort, 47989); + EXPECT_EQ(serverInfo.httpsPort, 47990); + EXPECT_TRUE(serverInfo.paired); + } + + TEST(HostPairingTest, RejectsServerInfoResponsesMissingRequiredFields) { + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + EXPECT_FALSE(network::parse_server_info_response("47990", 47984, &serverInfo, &errorMessage)); + EXPECT_FALSE(errorMessage.empty()); + } + +} // 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..fbd0458 --- /dev/null +++ b/tests/unit/network/runtime_network_test.cpp @@ -0,0 +1,71 @@ +#include "src/network/runtime_network.h" + +#include + +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", + "192.168.0.42", + "255.255.255.0", + "192.168.0.1", + }; + + 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: 192.168.0.42"); + EXPECT_EQ(lines[2], "Subnet mask: 255.255.255.0"); + EXPECT_EQ(lines[3], "Gateway: 192.168.0.1"); + 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", + "10.0.2.15", + "255.255.255.0", + "10.0.2.2", + }; + + const std::vector lines = network::format_runtime_network_status_lines(status); + + ASSERT_EQ(lines.size(), 5U); + EXPECT_EQ(lines[3], "Gateway: 10.0.2.2"); + EXPECT_EQ(lines[4], "Initialization code: 0"); + } + +} // namespace 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..f6667e8 --- /dev/null +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -0,0 +1,78 @@ +#include "src/startup/client_identity_storage.h" + +#include + +extern "C" { +#include +} + +#include + +namespace { + + void remove_if_present(const std::string &path) { + std::remove(path.c_str()); + } + + void remove_directory_if_present(const std::string &path) { + _rmdir(path.c_str()); + } + + class ClientIdentityStorageTest : public ::testing::Test { + protected: + void TearDown() override { + remove_if_present((nestedIdentityDirectory + "\\uniqueid.dat")); + remove_if_present((nestedIdentityDirectory + "\\client.pem")); + remove_if_present((nestedIdentityDirectory + "\\key.pem")); + remove_directory_if_present(nestedIdentityDirectory); + remove_directory_if_present(testDirectory + "\\nested"); + + remove_if_present((testDirectory + "\\uniqueid.dat")); + remove_if_present((testDirectory + "\\client.pem")); + remove_if_present((testDirectory + "\\key.pem")); + remove_directory_if_present(testDirectory); + } + + std::string testDirectory = "pairing-storage-test"; + std::string nestedIdentityDirectory = 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); + } + +} // 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..4b774d1 --- /dev/null +++ b/tests/unit/startup/host_storage_test.cpp @@ -0,0 +1,93 @@ +#include "src/startup/host_storage.h" + +#include +#include + +extern "C" { +#include +} + +#include + +namespace { + + void remove_if_present(const std::string &path) { + std::remove(path.c_str()); + } + + void remove_directory_if_present(const std::string &path) { + _rmdir(path.c_str()); + } + + class HostStorageTest : public ::testing::Test { + protected: + void TearDown() override { + remove_if_present(nestedFilePath); + remove_directory_if_present(testDirectory + "\\nested"); + remove_if_present(testFilePath); + remove_directory_if_present(testDirectory); + } + + std::string testDirectory = "host-storage-test"; + std::string testFilePath = "host-storage-test.tsv"; + std::string nestedFilePath = 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", "192.168.1.20", 0, app::PairingState::paired}, + {"Office PC", "10.0.0.25", 48000, 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, "10.0.0.25"); + EXPECT_EQ(loadResult.hosts[1].port, 48000); + } + + TEST_F(HostStorageTest, CreatesNestedDirectoriesWhenSavingHosts) { + const std::vector hosts = { + {"Living Room PC", "192.168.1.20", 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, SurfacesParseWarningsButKeepsValidHosts) { + FILE *file = std::fopen(testFilePath.c_str(), "wb"); + ASSERT_NE(file, nullptr); + const char fileContent[] = + "Living Room PC\t192.168.1.20\t\tpaired\n" + "Broken Host\tnot-an-ip\t\tnot_paired\n"; + ASSERT_EQ(std::fwrite(fileContent, 1, sizeof(fileContent) - 1, file), sizeof(fileContent) - 1); + 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, "192.168.1.20"); + ASSERT_EQ(loadResult.warnings.size(), 1U); + EXPECT_NE(loadResult.warnings[0].find("Line 2"), std::string::npos); + } + +} // 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..c2bcb31 --- /dev/null +++ b/tests/unit/streaming/stats_overlay_test.cpp @@ -0,0 +1,59 @@ +#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/menu_model_test.cpp b/tests/unit/ui/menu_model_test.cpp new file mode 100644 index 0000000..f48b7da --- /dev/null +++ b/tests/unit/ui/menu_model_test.cpp @@ -0,0 +1,85 @@ +#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"); + } + +} // 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..7e9fffe --- /dev/null +++ b/tests/unit/ui/shell_view_test.cpp @@ -0,0 +1,173 @@ +#include "src/ui/shell_view.h" + +#include + +#include + +namespace { + + TEST(ShellViewTest, BuildsHomeScreenContentFromTheInitialState) { + const app::ClientState state = app::create_initial_state(); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.title, "Xbox"); + ASSERT_FALSE(viewModel.bodyLines.empty()); + EXPECT_EQ(viewModel.bodyLines.front(), "Controller-first Moonlight shell prototype."); + EXPECT_EQ(viewModel.bodyLines.back(), "Saved hosts available: 0"); + ASSERT_EQ(viewModel.menuRows.size(), 4U); + EXPECT_TRUE(viewModel.menuRows.front().selected); + EXPECT_EQ(viewModel.menuRows.front().label, "Hosts"); + EXPECT_FALSE(viewModel.overlayVisible); + } + + TEST(ShellViewTest, ShowsSavedHostDetailsOnTheHostsScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired}, + }, "Loaded 1 saved host(s)"); + app::handle_command(state, input::UiCommand::activate); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.title, "Hosts"); + ASSERT_GE(viewModel.bodyLines.size(), 6U); + EXPECT_EQ(viewModel.bodyLines[0], "Saved hosts: 1"); + EXPECT_EQ(viewModel.bodyLines[1], "Selected host: Living Room PC"); + EXPECT_EQ(viewModel.bodyLines[2], "Address: 192.168.0.10"); + EXPECT_EQ(viewModel.bodyLines[3], "Port: 48000"); + EXPECT_EQ(viewModel.bodyLines[4], "Pairing: Not paired yet"); + EXPECT_NE(viewModel.bodyLines[5].find("Pair Selected Host"), std::string::npos); + } + + TEST(ShellViewTest, ShowsKeypadBasedHostEntryInstructionsAndValidation) { + app::ClientState state = app::create_initial_state(); + state.menu.handle_command(input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + state.addHostDraft.activeField = app::AddHostField::port; + state.addHostDraft.portInput = "48000"; + state.addHostDraft.validationMessage = "That host is already saved"; + state.addHostDraft.connectionMessage = "Connected to 192.168.0.10:48000"; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.title, "Add Host"); + ASSERT_GE(viewModel.bodyLines.size(), 8U); + EXPECT_EQ(viewModel.bodyLines[0], "Manual host entry with a popup keypad."); + EXPECT_EQ(viewModel.bodyLines[1], "Current address: 192.168.0.10"); + EXPECT_EQ(viewModel.bodyLines[2], "Current port: 48000"); + EXPECT_EQ(viewModel.bodyLines[3], "Selected field: Port"); + EXPECT_EQ(viewModel.bodyLines[4], "Select Host Address or Port to open the keypad modal."); + EXPECT_EQ(viewModel.bodyLines[5], "Use Clear Current Field to erase the selected field."); + EXPECT_EQ(viewModel.bodyLines[7], "Validation: That host is already saved"); + EXPECT_EQ(viewModel.bodyLines[8], "Connection: Connected to 192.168.0.10:48000"); + ASSERT_GE(viewModel.footerLines.size(), 6U); + EXPECT_EQ(viewModel.footerLines[1], "Select Address or Port to open the keypad modal"); + } + + TEST(ShellViewTest, BuildsTheAddHostKeypadModalAsANumberPad) { + app::ClientState state = app::create_initial_state(); + state.menu.handle_command(input::UiCommand::move_down); + 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.keypadModalVisible); + EXPECT_EQ(viewModel.keypadModalTitle, "Address Keypad"); + ASSERT_GE(viewModel.keypadModalLines.size(), 3U); + EXPECT_EQ(viewModel.keypadModalLines[0], "Editing field: Address"); + ASSERT_EQ(viewModel.keypadModalButtons.size(), 11U); + EXPECT_EQ(viewModel.keypadModalColumnCount, 3U); + EXPECT_EQ(viewModel.keypadModalButtons[0].label, "1"); + EXPECT_TRUE(viewModel.keypadModalButtons[0].selected); + EXPECT_EQ(viewModel.keypadModalButtons[9].label, "."); + EXPECT_EQ(viewModel.keypadModalButtons[10].label, "0"); + EXPECT_EQ(viewModel.footerLines[1], "Keypad open: D-pad moves, A enters, X deletes, Start accepts, B cancels"); + } + + TEST(ShellViewTest, ShowsThatPairingCanBeStartedForAnUnpairedHost) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired}, + }); + app::handle_command(state, input::UiCommand::activate); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.title, "Hosts"); + ASSERT_GE(viewModel.bodyLines.size(), 5U); + EXPECT_EQ(viewModel.bodyLines[0], "Saved hosts: 1"); + EXPECT_EQ(viewModel.bodyLines[1], "Selected host: Living Room PC"); + EXPECT_EQ(viewModel.bodyLines[4], "Pairing: Not paired yet"); + EXPECT_EQ(viewModel.bodyLines[5], "Select Pair Selected Host to start the Sunshine pairing handshake in the background."); + + bool foundPairingAction = false; + for (const ui::ShellMenuRow &row : viewModel.menuRows) { + if (row.id == "pair-host") { + foundPairingAction = true; + EXPECT_TRUE(row.enabled); + EXPECT_EQ(row.label, "Pair Selected Host"); + } + } + EXPECT_TRUE(foundPairingAction); + } + + TEST(ShellViewTest, AddsStatsAndRecentLogsToTheOverlayWhenVisible) { + app::ClientState state = app::create_initial_state(); + state.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.overlayVisible); + EXPECT_EQ(viewModel.overlayTitle, "Diagnostics"); + ASSERT_GE(viewModel.overlayLines.size(), 4U); + EXPECT_EQ(viewModel.overlayLines[0], "Stream: 1280x720 @ 60 FPS"); + EXPECT_EQ(viewModel.overlayLines[1], "Connection: Okay"); + EXPECT_EQ(viewModel.overlayLines[2], "[INFO] app: Entered shell"); + EXPECT_EQ(viewModel.overlayLines[3], "[WARN] network: No active stream"); + } + + TEST(ShellViewTest, CanScrollBackToEarlierLogEntriesInTheOverlay) { + app::ClientState state = app::create_initial_state(); + state.overlayVisible = true; + state.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.overlayLines.empty()); + EXPECT_EQ(viewModel.overlayLines.front(), "Showing earlier log entries"); + } + + TEST(ShellViewTest, UsesScreenSpecificTextForTheSettingsScreen) { + app::ClientState state = app::create_initial_state(); + state.menu.handle_command(input::UiCommand::move_down); + state.menu.handle_command(input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(state.activeScreen, app::ScreenId::settings); + EXPECT_EQ(viewModel.title, "Settings"); + ASSERT_FALSE(viewModel.bodyLines.empty()); + EXPECT_NE(viewModel.bodyLines.front().find("Display, input, overlay, and logging settings"), std::string::npos); + } + +} // namespace + 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/xbe/assets/fonts/vegur-regular.ttf b/xbe/assets/fonts/vegur-regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1299b45525545da6a3027a99437f8686fa679b7c GIT binary patch literal 21116 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 literal 0 HcmV?d00001 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 @@ - + From 7c678235000f1b629788a7c90a6de38984f9ff0f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 5 Apr 2026 09:52:58 -0400 Subject: [PATCH 02/35] Add NXDK OpenSSL/socket compatibility & pairing fixes Provide NXDK-specific compatibility for OpenSSL and sockets: add src/_nxdk_compat (openssl_compat.h, poll_compat.cpp, stat_compat.cpp), remove legacy openssl compat files, and wire them into CMake (include dirs, build/install commands, and source list changes). Refactor host_pairing to be cross-platform (NXDK vs Winsock): unify socket error handling, non-blocking connect helper, timeouts, TLS connect error reporting, serverinfo path construction with uniqueId, and ensure socket initialization works on NXDK. Minor UI text cleanups and include stat_compat.cpp in the final executable. Also update third-party submodule pins for moonlight-common-c and nxdk. --- cmake/modules/GetOpenSSL.cmake | 29 +++- cmake/sources.cmake | 4 + cmake/xbox-build.cmake | 23 ++- src/_nxdk_compat/openssl_compat.h | 158 +++++++++++++++++ src/_nxdk_compat/poll_compat.cpp | 83 +++++++++ src/_nxdk_compat/stat_compat.cpp | 43 +++++ .../moonlight_common_c_compat.h | 10 -- src/compat/openssl/conio.h | 32 ---- src/compat/openssl/fd_compat.cpp | 21 --- src/compat/openssl/openssl_apps_compat.h | 162 ------------------ src/compat/openssl/stat_compat.cpp | 20 --- src/compat/openssl/sys/resource.h | 36 ---- src/network/host_pairing.cpp | 153 ++++++++++++++--- src/ui/shell_screen.cpp | 1 - src/ui/shell_view.cpp | 15 +- third-party/moonlight-common-c | 2 +- third-party/nxdk | 2 +- 17 files changed, 462 insertions(+), 332 deletions(-) create mode 100644 src/_nxdk_compat/openssl_compat.h create mode 100644 src/_nxdk_compat/poll_compat.cpp create mode 100644 src/_nxdk_compat/stat_compat.cpp delete mode 100644 src/compat/moonlight-common-c/moonlight_common_c_compat.h delete mode 100644 src/compat/openssl/conio.h delete mode 100644 src/compat/openssl/fd_compat.cpp delete mode 100644 src/compat/openssl/openssl_apps_compat.h delete mode 100644 src/compat/openssl/stat_compat.cpp delete mode 100644 src/compat/openssl/sys/resource.h diff --git a/cmake/modules/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake index 5cf0715..8d10321 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -61,15 +61,16 @@ if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "XBOX") -D_fstat=fstat -D_exit=_Exit -include - "${CMAKE_SOURCE_DIR}/src/compat/openssl/openssl_apps_compat.h" - "-I${CMAKE_SOURCE_DIR}/src/compat/openssl" + "${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) @@ -81,6 +82,15 @@ if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "XBOX") "AR=llvm-ar" "RANLIB=llvm-ranlib" "CPPFLAGS=${OPENSSL_CPPFLAGS}") + + set(OPENSSL_BUILD_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + build_libs) + set(OPENSSL_INSTALL_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + install_dev) else() if(CMAKE_SIZEOF_VOID_P EQUAL 8) set(OPENSSL_CONFIGURE_TARGET mingw64) @@ -97,6 +107,15 @@ else() "CC=${CMAKE_C_COMPILER}" "CXX=${CMAKE_CXX_COMPILER}") + set(OPENSSL_BUILD_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + build_libs) + set(OPENSSL_INSTALL_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + install_dev) + if(DEFINED CMAKE_AR AND NOT CMAKE_AR STREQUAL "") list(APPEND OPENSSL_ENV "AR=${CMAKE_AR}") endif() @@ -116,11 +135,9 @@ ExternalProject_Add(openssl_external "--prefix=${OPENSSL_INSTALL_DIR}" "--openssldir=${OPENSSL_INSTALL_DIR}/ssl" BUILD_COMMAND - ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} build_libs + ${OPENSSL_BUILD_COMMAND} INSTALL_COMMAND - ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} install_dev + ${OPENSSL_INSTALL_COMMAND} BUILD_BYPRODUCTS "${OPENSSL_INSTALL_DIR}/lib/libcrypto.a" "${OPENSSL_INSTALL_DIR}/lib/libssl.a" diff --git a/cmake/sources.cmake b/cmake/sources.cmake index 0474c57..b6427ed 100644 --- a/cmake/sources.cmake +++ b/cmake/sources.cmake @@ -9,6 +9,10 @@ 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" diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 77e8e54..e35d945 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -47,8 +47,12 @@ set(CMAKE_C_FLAGS_RELEASE "-O2") # moonlight-common-c submodule include(GetOpenSSL REQUIRED) -set(MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_HEADER - "${CMAKE_SOURCE_DIR}/src/compat/moonlight-common-c/moonlight_common_c_compat.h") +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") 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") @@ -56,10 +60,17 @@ 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_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) 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 - -include "${MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_HEADER}" -Wno-unused-function -Wno-error=unused-function) target_link_libraries(moonlight-common-c PRIVATE NXDK::ws2_32) @@ -68,9 +79,15 @@ endif() 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}" + "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" + "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}" + "${MOONLIGHT_NXDK_LWIP_POSIX_COMPAT_DIR}" ) target_link_libraries(${CMAKE_PROJECT_NAME} PUBLIC diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h new file mode 100644 index 0000000..9a0f5a5 --- /dev/null +++ b/src/_nxdk_compat/openssl_compat.h @@ -0,0 +1,158 @@ +#pragma once + +#ifndef __STDC_WANT_LIB_EXT1__ +#define __STDC_WANT_LIB_EXT1__ 1 +#endif + +#include +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +ssize_t lwip_recv(int s, void *mem, size_t len, int flags); +ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags); + +#ifndef F_OK +#define F_OK 0 +#endif + +#ifndef R_OK +#define R_OK 4 +#endif + +#ifndef W_OK +#define W_OK 2 +#endif + +#ifndef X_OK +#define X_OK 1 +#endif + +#ifndef AF_UNIX +#define AF_UNIX (-1) +#endif + +#define access moonlight_nxdk_openssl_access +#define fileno moonlight_nxdk_openssl_fileno +#define read moonlight_nxdk_openssl_read +#define write moonlight_nxdk_openssl_write +#define close moonlight_nxdk_openssl_close +#define _close moonlight_nxdk_openssl__close +#define open moonlight_nxdk_openssl_open +#define _open moonlight_nxdk_openssl__open +#define fdopen moonlight_nxdk_openssl_fdopen +#define _fdopen moonlight_nxdk_openssl__fdopen +#define _unlink moonlight_nxdk_openssl__unlink +#define chmod moonlight_nxdk_openssl_chmod +#define getuid moonlight_nxdk_openssl_getuid +#define geteuid moonlight_nxdk_openssl_geteuid +#define getgid moonlight_nxdk_openssl_getgid +#define getegid moonlight_nxdk_openssl_getegid + +static inline int moonlight_nxdk_openssl_access(const char *path, int mode) +{ + (void) path; + (void) mode; + return -1; +} + +static inline int moonlight_nxdk_openssl_fileno(FILE *stream) +{ + (void) stream; + return -1; +} + +static inline ssize_t moonlight_nxdk_openssl_read(int fd, void *buffer, size_t count) +{ + return lwip_recv(fd, buffer, count, 0); +} + +static inline ssize_t moonlight_nxdk_openssl_write(int fd, const void *buffer, size_t count) +{ + return lwip_send(fd, buffer, count, 0); +} + +static inline int moonlight_nxdk_openssl_close(int fd) +{ + (void) fd; + return -1; +} + +static inline int moonlight_nxdk_openssl__close(int fd) +{ + return moonlight_nxdk_openssl_close(fd); +} + +static inline int moonlight_nxdk_openssl_open(const char *path, int flags, ...) +{ + (void) path; + (void) flags; + return -1; +} + +static inline int moonlight_nxdk_openssl__open(const char *path, int flags, ...) +{ + (void) path; + (void) flags; + return -1; +} + +static inline FILE *moonlight_nxdk_openssl_fdopen(int fd, const char *mode) +{ + (void) fd; + (void) mode; + return NULL; +} + +static inline FILE *moonlight_nxdk_openssl__fdopen(int fd, const char *mode) +{ + return moonlight_nxdk_openssl_fdopen(fd, mode); +} + +static inline int moonlight_nxdk_openssl__unlink(const char *path) +{ + (void) path; + return -1; +} + +static inline int moonlight_nxdk_openssl_chmod(const char *path, int mode) +{ + (void) path; + (void) mode; + return -1; +} + +static inline unsigned int moonlight_nxdk_openssl_getuid(void) +{ + return 0; +} + +static inline unsigned int moonlight_nxdk_openssl_geteuid(void) +{ + return 0; +} + +static inline unsigned int moonlight_nxdk_openssl_getgid(void) +{ + return 0; +} + +static inline unsigned int moonlight_nxdk_openssl_getegid(void) +{ + return 0; +} + +static inline int moonlight_nxdk_openssl_gmtime_s(struct tm *result, const time_t *timer) +{ + return gmtime_s(timer, result); +} + +#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..a1a383e --- /dev/null +++ b/src/_nxdk_compat/poll_compat.cpp @@ -0,0 +1,83 @@ +#ifdef NXDK + +#include +#include + +#include +#include +#include + +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; + } + + const int selectResult = select(maxFd + 1, &readSet, &writeSet, &errorSet, timeoutPointer); + if (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..26146ea --- /dev/null +++ b/src/_nxdk_compat/stat_compat.cpp @@ -0,0 +1,43 @@ +#ifdef NXDK + +#include + +#include + +extern "C" { + +int stat(const char *path, struct stat *status) +{ + (void) path; + + if (status != nullptr) { + std::memset(status, 0, sizeof(*status)); + } + + return -1; +} + +int fstat(int fd, struct stat *status) +{ + (void) fd; + + if (status != nullptr) { + std::memset(status, 0, sizeof(*status)); + } + + return 0; +} + +int _stat(const char *path, struct stat *status) +{ + return stat(path, status); +} + +int _fstat(int fd, struct stat *status) +{ + return fstat(fd, status); +} + +} // extern "C" + +#endif diff --git a/src/compat/moonlight-common-c/moonlight_common_c_compat.h b/src/compat/moonlight-common-c/moonlight_common_c_compat.h deleted file mode 100644 index 7da5e32..0000000 --- a/src/compat/moonlight-common-c/moonlight_common_c_compat.h +++ /dev/null @@ -1,10 +0,0 @@ -#ifndef MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_H -#define MOONLIGHT_MOONLIGHT_COMMON_C_COMPAT_H - -#ifdef NXDK -#ifndef __analysis_assume -#define __analysis_assume(expression) ((void) (expression)) -#endif -#endif - -#endif diff --git a/src/compat/openssl/conio.h b/src/compat/openssl/conio.h deleted file mode 100644 index e669b23..0000000 --- a/src/compat/openssl/conio.h +++ /dev/null @@ -1,32 +0,0 @@ -#ifndef MOONLIGHT_OPENSSL_CONIO_H -#define MOONLIGHT_OPENSSL_CONIO_H - -#ifdef __cplusplus -extern "C" { -#endif - -static inline int _kbhit(void) -{ - return 0; -} - -static inline int _getch(void) -{ - return -1; -} - -static inline int kbhit(void) -{ - return _kbhit(); -} - -static inline int getch(void) -{ - return _getch(); -} - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/src/compat/openssl/fd_compat.cpp b/src/compat/openssl/fd_compat.cpp deleted file mode 100644 index 57cb279..0000000 --- a/src/compat/openssl/fd_compat.cpp +++ /dev/null @@ -1,21 +0,0 @@ -#ifdef NXDK - -extern "C" { - -int close(int fd) -{ - (void) fd; - return 0; -} - -long lseek(int fd, long offset, int whence) -{ - (void) fd; - (void) offset; - (void) whence; - return -1; -} - -} - -#endif diff --git a/src/compat/openssl/openssl_apps_compat.h b/src/compat/openssl/openssl_apps_compat.h deleted file mode 100644 index daae450..0000000 --- a/src/compat/openssl/openssl_apps_compat.h +++ /dev/null @@ -1,162 +0,0 @@ -#ifndef MOONLIGHT_OPENSSL_APPS_COMPAT_H -#define MOONLIGHT_OPENSSL_APPS_COMPAT_H - -// platform includes -#include -#include -#include -#include -#include -#include - -#ifdef accept -#undef accept -#endif -#ifdef bind -#undef bind -#endif -#ifdef close -#undef close -#endif -#ifdef connect -#undef connect -#endif -#ifdef getpeername -#undef getpeername -#endif -#ifdef getsockname -#undef getsockname -#endif -#ifdef getsockopt -#undef getsockopt -#endif -#ifdef ioctl -#undef ioctl -#endif -#ifdef ioctlsocket -#undef ioctlsocket -#endif -#ifdef listen -#undef listen -#endif -#ifdef recv -#undef recv -#endif -#ifdef recvfrom -#undef recvfrom -#endif -#ifdef recvmsg -#undef recvmsg -#endif -#ifdef send -#undef send -#endif -#ifdef sendmsg -#undef sendmsg -#endif -#ifdef sendto -#undef sendto -#endif -#ifdef setsockopt -#undef setsockopt -#endif -#ifdef shutdown -#undef shutdown -#endif -#ifdef socket -#undef socket -#endif -#ifdef select -#undef select -#endif - -#ifdef __cplusplus -extern "C" { -#endif - -#ifndef F_OK -#define F_OK 0 -#endif - -#ifndef AF_UNIX -#define AF_UNIX (-1) -#endif - -static inline int access(const char *path, int mode) -{ - (void) path; - (void) mode; - return -1; -} - -static inline int fileno(FILE *stream) -{ - (void) stream; - return -1; -} - -static inline int open(const char *path, int flags, ...) -{ - (void) path; - (void) flags; - return -1; -} - -static inline int _open(const char *path, int flags, ...) -{ - (void) path; - (void) flags; - return -1; -} - -static inline FILE *fdopen(int fd, const char *mode) -{ - (void) fd; - (void) mode; - return NULL; -} - -static inline FILE *_fdopen(int fd, const char *mode) -{ - return fdopen(fd, mode); -} - -static inline int _unlink(const char *path) -{ - (void) path; - return -1; -} - -static inline int chmod(const char *path, int mode) -{ - (void) path; - (void) mode; - return 0; -} - -static inline int gmtime_s(struct tm *result, const time_t *timer) -{ - if (result == NULL || timer == NULL) { - return -1; - } - - struct tm *temporary = gmtime(timer); - if (temporary == NULL) { - memset(result, 0, sizeof(*result)); - return -1; - } - - *result = *temporary; - return 0; -} - -static inline int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, struct timeval *timeout) -{ - return lwip_select(maxfdp1, readset, writeset, exceptset, timeout); -} - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/src/compat/openssl/stat_compat.cpp b/src/compat/openssl/stat_compat.cpp deleted file mode 100644 index 4311dc8..0000000 --- a/src/compat/openssl/stat_compat.cpp +++ /dev/null @@ -1,20 +0,0 @@ -#ifdef NXDK - -// platform includes -#include - -extern "C" { - -int _stat(const char *path, struct stat *buffer) -{ - return stat(path, buffer); -} - -int _fstat(int fd, struct stat *buffer) -{ - return fstat(fd, buffer); -} - -} // extern "C" - -#endif diff --git a/src/compat/openssl/sys/resource.h b/src/compat/openssl/sys/resource.h deleted file mode 100644 index 5fbb249..0000000 --- a/src/compat/openssl/sys/resource.h +++ /dev/null @@ -1,36 +0,0 @@ -#ifndef MOONLIGHT_OPENSSL_COMPAT_SYS_RESOURCE_H -#define MOONLIGHT_OPENSSL_COMPAT_SYS_RESOURCE_H - -// platform includes -#include -#include - -#ifdef __cplusplus -extern "C" { -#endif - -#define RUSAGE_SELF 0 - -struct rusage { - struct timeval ru_utime; - struct timeval ru_stime; -}; - -static inline int getrusage(int who, struct rusage *usage) -{ - (void) who; - if (usage != NULL) { - usage->ru_utime.tv_sec = 0; - usage->ru_utime.tv_usec = 0; - usage->ru_stime.tv_sec = 0; - usage->ru_stime.tv_usec = 0; - } - return -1; -} - -#ifdef __cplusplus -} -#endif - -#endif - diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index d2d4888..77f7ec5 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -15,12 +15,28 @@ #include // platform includes +#ifdef NXDK +#include +#include +#include +#else #include #include +#endif // nxdk includes #ifdef NXDK #include + +using SOCKET = int; + +#ifndef INVALID_SOCKET +#define INVALID_SOCKET (-1) +#endif + +#ifndef SOCKET_ERROR +#define SOCKET_ERROR (-1) +#endif #endif #define OPENSSL_SUPPRESS_DEPRECATED @@ -73,18 +89,26 @@ namespace { 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"; struct WsaGuard { WsaGuard() : initialized(false) { +#ifdef NXDK + initialized = true; +#else WSADATA wsaData {}; initialized = WSAStartup(MAKEWORD(2, 2), &wsaData) == 0; +#endif } ~WsaGuard() { +#ifndef NXDK if (initialized) { WSACleanup(); } +#endif } bool initialized; @@ -104,6 +128,61 @@ namespace { SOCKET handle; }; + bool append_error(std::string *errorMessage, std::string message); + + int last_socket_error() { +#ifdef NXDK + return errno; +#else + return WSAGetLastError(); +#endif + } + + bool is_connect_in_progress_error(int errorCode) { +#ifdef NXDK + return errorCode == EWOULDBLOCK || errorCode == EINPROGRESS || errorCode == EALREADY; +#else + return errorCode == WSAEWOULDBLOCK || errorCode == WSAEINPROGRESS || errorCode == WSAEALREADY; +#endif + } + + bool is_timeout_error(int errorCode) { +#ifdef NXDK + 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; +#else + u_long nonBlockingMode = enabled ? 1UL : 0UL; +#endif + + if (ioctlsocket(socketHandle, FIONBIO, &nonBlockingMode) != 0) { + return append_error(errorMessage, std::string("Failed to configure the host pairing socket mode (socket error ") + std::to_string(last_socket_error()) + ")"); + } + + return true; + } + + void set_socket_timeouts(SOCKET socketHandle) { +#ifdef NXDK + 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) { @@ -249,6 +328,11 @@ namespace { 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_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); @@ -889,18 +973,17 @@ namespace { } trace_pairing_phase("IPv4 socket address ready"); - u_long nonBlockingMode = 1; trace_pairing_phase("setting non-blocking connect mode"); - if (ioctlsocket(socketGuard->handle, FIONBIO, &nonBlockingMode) != 0) { - return append_error(errorMessage, "Failed to configure the host pairing socket for a timed connect (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + if (!set_socket_non_blocking(socketGuard->handle, true, errorMessage)) { + return false; } trace_pairing_detail("connecting to " + address + ":" + std::to_string(port)); const int connectResult = connect(socketGuard->handle, reinterpret_cast(&socketAddress), sizeof(socketAddress)); if (connectResult == SOCKET_ERROR) { - const int connectError = WSAGetLastError(); - if (connectError != WSAEWOULDBLOCK && connectError != WSAEINPROGRESS && connectError != WSAEALREADY) { - return append_error(errorMessage, "Failed to connect to the host pairing endpoint at " + address + ":" + std::to_string(port) + " (Winsock error " + std::to_string(connectError) + ")"); + const int connectError = last_socket_error(); + if (!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) + ")"); } fd_set writeSet; @@ -926,22 +1009,19 @@ namespace { int socketErrorLength = sizeof(socketError); #endif if (getsockopt(socketGuard->handle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { - return append_error(errorMessage, "Failed to query the host pairing socket status after connect (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + 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) + " (Winsock error " + std::to_string(socketError) + ")"); + return append_error(errorMessage, "Host refused the pairing connection on " + address + ":" + std::to_string(port) + " (socket error " + std::to_string(socketError) + ")"); } } - nonBlockingMode = 0; trace_pairing_phase("restoring blocking mode after connect"); - if (ioctlsocket(socketGuard->handle, FIONBIO, &nonBlockingMode) != 0) { - return append_error(errorMessage, "Failed to restore the host pairing socket to blocking mode after connect (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + if (!set_socket_non_blocking(socketGuard->handle, false, errorMessage)) { + return false; } - const DWORD timeoutMilliseconds = SOCKET_TIMEOUT_MILLISECONDS; - setsockopt(socketGuard->handle, SOL_SOCKET, SO_RCVTIMEO, reinterpret_cast(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); - setsockopt(socketGuard->handle, SOL_SOCKET, SO_SNDTIMEO, reinterpret_cast(&timeoutMilliseconds), sizeof(timeoutMilliseconds)); + set_socket_timeouts(socketGuard->handle); trace_pairing_phase("socket connected"); return true; @@ -958,10 +1038,10 @@ namespace { break; } if (bytesRead < 0) { - const int socketError = WSAGetLastError(); - return append_error(errorMessage, socketError == WSAETIMEDOUT + 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 (Winsock error " + std::to_string(socketError) + ")"); + : "Failed while reading the host pairing response (socket error " + std::to_string(socketError) + ")"); } received.append(buffer, buffer + bytesRead); @@ -999,7 +1079,7 @@ namespace { if (errorCode == SSL_ERROR_WANT_READ || errorCode == SSL_ERROR_WANT_WRITE) { continue; } - return append_openssl_error(errorMessage, errorCode == SSL_ERROR_SYSCALL && WSAGetLastError() == WSAETIMEDOUT + 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"); } @@ -1026,7 +1106,7 @@ namespace { while (sent < request.size()) { 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 (Winsock error " + std::to_string(WSAGetLastError()) + ")"); + return append_error(errorMessage, "Failed to send the host pairing request (socket error " + std::to_string(last_socket_error()) + ")"); } sent += static_cast(bytesSent); } @@ -1076,13 +1156,20 @@ namespace { return true; } - bool query_server_info_internal(const std::string &address, uint16_t preferredHttpPort, HttpResponse *response, network::HostPairingServerInfo *serverInfo, std::string *errorMessage) { + bool query_server_info_internal( + const std::string &address, + uint16_t preferredHttpPort, + std::string_view uniqueId, + HttpResponse *response, + network::HostPairingServerInfo *serverInfo, + std::string *errorMessage + ) { if (address.empty()) { return append_error(errorMessage, "Pairing requires a valid host address"); } const std::vector candidatePorts = build_serverinfo_port_candidates(preferredHttpPort); - const std::string serverInfoPath = "/serverinfo?uniqueid=0123456789ABCDEF&uuid=11111111-2222-3333-4444-555555555555"; + const std::string serverInfoPath = build_serverinfo_path(uniqueId); std::vector attemptFailures; for (uint16_t candidatePort : candidatePorts) { @@ -1134,10 +1221,10 @@ namespace { HttpResponse *response, std::string *errorMessage ) { - trace_pairing_phase("http_get: WSAStartup"); + trace_pairing_phase("http_get: socket initialization"); WsaGuard wsaGuard; if (!wsaGuard.initialized) { - return append_error(errorMessage, "Failed to initialize Winsock for host pairing"); + return append_error(errorMessage, "Failed to initialize socket support for host pairing"); } SocketGuard socketGuard; @@ -1161,7 +1248,10 @@ namespace { } else { trace_pairing_phase("http_get: preparing TLS"); - initialize_openssl(); + 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"); @@ -1192,8 +1282,15 @@ namespace { #endif ERR_clear_error(); trace_pairing_phase("http_get: SSL_connect"); - if (SSL_connect(ssl.get()) != 1) { - return append_openssl_error(errorMessage, "Failed to establish the encrypted host pairing session"); + const int connectResult = SSL_connect(ssl.get()); + if (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; @@ -1401,7 +1498,7 @@ namespace network { } bool query_server_info(const std::string &address, uint16_t preferredHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { - return query_server_info_internal(address, preferredHttpPort, nullptr, serverInfo, errorMessage); + return query_server_info_internal(address, preferredHttpPort, DEFAULT_SERVERINFO_UNIQUE_ID, nullptr, serverInfo, errorMessage); } HostPairingResult pair_host(const HostPairingRequest &request) { @@ -1456,7 +1553,7 @@ namespace network { trace_pairing_phase("requesting /serverinfo"); HostPairingServerInfo serverInfo {}; - if (!query_server_info_internal(request.address, httpPort, &response, &serverInfo, &errorMessage)) { + if (!query_server_info_internal(request.address, httpPort, uniqueId, &response, &serverInfo, &errorMessage)) { return fail_with_phase("serverinfo", errorMessage); } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 1298bd3..549e302 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include // local includes diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index a922e21..d9ed427 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -80,10 +80,7 @@ namespace { case app::ScreenId::home: { std::vector lines = { - "Controller-first Moonlight shell prototype.", - "Use Hosts to review saved PCs and future pairing work.", - "Use Add Host to create a manual host entry with keypad editing.", - "Use Settings for display, input, and logging options.", + "Hello, Moonlight!", "Saved hosts available: " + std::to_string(state.hosts.size()), }; @@ -112,7 +109,7 @@ namespace { lines.push_back(std::string("Pairing: ") + (host->pairingState == app::PairingState::paired ? "Paired" : "Not paired yet")); lines.emplace_back(host->pairingState == app::PairingState::paired ? "This host is already paired." - : "Select Pair Selected Host to start the Sunshine pairing handshake in the background."); + : "Select Pair Selected Host to start the pairing handshake in the background."); } else { lines.emplace_back("Select a saved host to inspect its address and pairing state."); @@ -128,13 +125,10 @@ namespace { case app::ScreenId::add_host: { std::vector lines = { - "Manual host entry with a popup keypad.", + "Manual host entry.", std::string("Current address: ") + app::current_add_host_address(state), std::string("Current port: ") + std::to_string(app::current_add_host_port(state)), std::string("Selected field: ") + active_add_host_field_label(state), - "Select Host Address or Port to open the keypad modal.", - "Use Clear Current Field to erase the selected field.", - "Test Connection checks reachability before you save the host.", }; if (!state.addHostDraft.validationMessage.empty()) { @@ -150,10 +144,9 @@ namespace { case app::ScreenId::pair_host: { std::vector lines = { - "Pairing now prepares the client identity and runs the network handshake in the background so the shell stays responsive.", std::string("Target host: ") + state.pairingDraft.targetAddress, std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort), - std::string("Last generated PIN: ") + app::current_pairing_pin(state), + std::string("PIN: ") + app::current_pairing_pin(state), "Enter the PIN on the host if prompted and wait for the status below.", "Cancel leaves this screen, but the active network request may need a few seconds to unwind.", }; 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 From a59d8acd8f423d83cfe56e504e87abab3ba22530 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 00:06:47 -0400 Subject: [PATCH 03/35] Add initial client shell, host & logging features Introduce the first M0 client shell and supporting infrastructure: structured in-memory logging (logger + log_file), controller/keyboard input mapping, retained menu model, host records and pairing flow scaffolding, saved files and cover art cache, startup memory/video utilities, and UI state management (client_state). Adds grid/modal navigation, add-host keypad, apps view, log viewer, and many unit tests and icon assets. Also includes various refactors and helpers for host/app selection, scrolling, and menu rebuilding to enable further work on pairing, streaming and Xbox runtime integration. --- .github/copilot-instructions.md | 4 + docs/sunshine-pairing-research.md | 49 + docs/working-client-plan.md | 101 + src/app/client_state.cpp | 1801 +++++++---- src/app/client_state.h | 170 ++ src/app/host_records.cpp | 13 + src/app/host_records.h | 63 +- src/app/pairing_flow.cpp | 4 +- src/input/navigation_input.cpp | 6 +- src/input/navigation_input.h | 4 + src/logging/log_file.cpp | 208 ++ src/logging/log_file.h | 29 + src/logging/logger.cpp | 82 +- src/logging/logger.h | 23 +- src/main.cpp | 109 +- src/network/host_pairing.cpp | 1125 ++++++- src/network/host_pairing.h | 63 +- src/splash/splash_screen.cpp | 29 +- src/splash/splash_screen.h | 5 + src/startup/cover_art_cache.cpp | 225 ++ src/startup/cover_art_cache.h | 32 + src/startup/memory_stats.cpp | 23 +- src/startup/memory_stats.h | 6 + src/startup/saved_files.cpp | 262 ++ src/startup/saved_files.h | 73 + src/startup/video_mode.cpp | 27 +- src/startup/video_mode.h | 3 + src/ui/menu_model.cpp | 1 + src/ui/shell_screen.cpp | 3044 +++++++++++++++++-- src/ui/shell_view.cpp | 803 +++-- src/ui/shell_view.h | 107 +- test-output/logging/long-lines.log | 1 + test-output/logging/moonlight.log | 3 + test-output/logging/reset.log | 1 + tests/unit/app/client_state_test.cpp | 546 +++- tests/unit/app/pairing_flow_test.cpp | 4 +- tests/unit/input/navigation_input_test.cpp | 4 +- tests/unit/logging/log_file_test.cpp | 72 + tests/unit/logging/logger_test.cpp | 16 +- tests/unit/network/host_pairing_test.cpp | 180 ++ tests/unit/startup/cover_art_cache_test.cpp | 57 + tests/unit/startup/saved_files_test.cpp | 125 + tests/unit/ui/shell_view_test.cpp | 316 +- xbe/assets/icons/add-host.svg | 11 + xbe/assets/icons/button-a.svg | 7 + xbe/assets/icons/button-b.svg | 7 + xbe/assets/icons/button-lb.svg | 7 + xbe/assets/icons/button-lt.svg | 7 + xbe/assets/icons/button-rb.svg | 7 + xbe/assets/icons/button-rt.svg | 8 + xbe/assets/icons/button-select.svg | 8 + xbe/assets/icons/button-start.svg | 8 + xbe/assets/icons/button-x.svg | 7 + xbe/assets/icons/button-y.svg | 7 + xbe/assets/icons/gear.svg | 21 + xbe/assets/icons/host-monitor-offline.svg | 11 + xbe/assets/icons/host-monitor-online.svg | 9 + xbe/assets/icons/host-monitor-pairing.svg | 8 + xbe/assets/icons/support.svg | 10 + 59 files changed, 8634 insertions(+), 1328 deletions(-) create mode 100644 docs/sunshine-pairing-research.md create mode 100644 docs/working-client-plan.md create mode 100644 src/logging/log_file.cpp create mode 100644 src/logging/log_file.h create mode 100644 src/startup/cover_art_cache.cpp create mode 100644 src/startup/cover_art_cache.h create mode 100644 src/startup/saved_files.cpp create mode 100644 src/startup/saved_files.h create mode 100644 test-output/logging/long-lines.log create mode 100644 test-output/logging/moonlight.log create mode 100644 test-output/logging/reset.log create mode 100644 tests/unit/logging/log_file_test.cpp create mode 100644 tests/unit/startup/cover_art_cache_test.cpp create mode 100644 tests/unit/startup/saved_files_test.cpp create mode 100644 xbe/assets/icons/add-host.svg create mode 100644 xbe/assets/icons/button-a.svg create mode 100644 xbe/assets/icons/button-b.svg create mode 100644 xbe/assets/icons/button-lb.svg create mode 100644 xbe/assets/icons/button-lt.svg create mode 100644 xbe/assets/icons/button-rb.svg create mode 100644 xbe/assets/icons/button-rt.svg create mode 100644 xbe/assets/icons/button-select.svg create mode 100644 xbe/assets/icons/button-start.svg create mode 100644 xbe/assets/icons/button-x.svg create mode 100644 xbe/assets/icons/button-y.svg create mode 100644 xbe/assets/icons/gear.svg create mode 100644 xbe/assets/icons/host-monitor-offline.svg create mode 100644 xbe/assets/icons/host-monitor-online.svg create mode 100644 xbe/assets/icons/host-monitor-pairing.svg create mode 100644 xbe/assets/icons/support.svg diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ead0f9e..2c8a5e6 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -3,6 +3,10 @@ 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 follow the style guidelines defined in .clang-format for c/c++ code. diff --git a/docs/sunshine-pairing-research.md b/docs/sunshine-pairing-research.md new file mode 100644 index 0000000..b2c27d4 --- /dev/null +++ b/docs/sunshine-pairing-research.md @@ -0,0 +1,49 @@ +# Sunshine pairing research notes + +## Current findings + +- The vendored `third-party/moonlight-common-c` tree exposes streaming, connection testing, and input APIs through `Limelight.h`. +- The current public API surface does not expose a ready-made host pairing entry point. +- The library clearly expects host metadata from `/serverinfo` and session startup through `/launch` and `/resume`, but pairing is not currently wrapped by an app-facing helper in this tree. +- The current shell UI was previously pretending pairing existed. That is why the pair action remains disabled until a real backend is added. + +## Practical implication for this project + +A real Sunshine pairing flow in this codebase should be implemented as a project-owned adapter instead of another reducer-only placeholder. + +## Recommended adapter boundary + +Create a narrow module that owns these responsibilities: + +1. Open an HTTPS connection to the target host. +2. Query `/serverinfo` and validate the host response. +3. Create or load the client certificate/key material used for pairing. +4. Start the Sunshine pairing request with the generated four-digit PIN. +5. Poll or continue the handshake until Sunshine reports success or failure. +6. Persist the resulting client identity and update the saved host record to `paired` only after host-confirmed success. + +## Proposed implementation order + +1. Add a platform-neutral `src/network/sunshine_pairing.h/.cpp` adapter interface. +2. Implement host-native parsing and state tests for the adapter responses before wiring it into SDL. +3. Use the vendored OpenSSL already present in the Xbox build to handle TLS and certificate material. +4. Add reducer events for: + - pairing requested; + - pairing started; + - PIN ready; + - pairing succeeded; + - pairing failed; + - pairing cancelled. +5. Re-enable the `Pair Selected Host` and `Start Pairing` actions only after the adapter can report real host-backed success. + +## Non-goals for the first pairing slice + +- LAN discovery +- unpair support +- host app-list browsing +- session launch or resume changes beyond what pairing needs + +## Why this note exists + +This project is at the point where the UI shell is ready for a real backend, but `moonlight-common-c` does not currently give this repository a drop-in pairing call. The next change should therefore add a small, testable Sunshine-specific adapter rather than extending the placeholder reducer state again. + diff --git a/docs/working-client-plan.md b/docs/working-client-plan.md new file mode 100644 index 0000000..3000954 --- /dev/null +++ b/docs/working-client-plan.md @@ -0,0 +1,101 @@ +# Working Moonlight client plan + +## Goals + +Turn `Moonlight-XboxOG` into a usable Moonlight client for the original Xbox by building the client in small, testable milestones. The near-term work should focus on host-native coverage, controller-first UI flows, structured logging, pairing, host management, and a streaming pipeline that can later connect the Xbox runtime to `moonlight-common-c`. + +## Architecture decisions + +### Streaming core + +- Use the vendored `third-party/moonlight-common-c` codebase as the transport, pairing, RTSP, ENet, control, and input foundation. +- Treat `moonlight-embedded` as a reference implementation, especially for host discovery, pairing, and session setup. +- Keep the first shipping codec target narrow: H.264 video plus stereo Opus audio. +- Delay FFmpeg integration until there is a clear Xbox-native decode gap that cannot be filled with a lighter custom H.264 path. + +### UI stack + +- Build a project-owned retained-mode UI layer on top of `SDL2`. +- Use `SDL_ttf`, which already exists in the vendored `nxdk` tree, for text rendering. +- Prefer a local icon atlas and project-owned widgets over adding a heavy UI submodule. +- Keep the UI model platform-neutral so controller navigation, focus, and menu state can be covered by host-native gtests. + +### Input model + +- Make the controller the primary navigation path. +- Map keyboard input into the same abstract UI commands so the host-native build and emulator workflows behave the same way. +- Treat mouse support as optional until nxdk input support is validated for the relevant devices. +- Allow a controller-driven virtual cursor mode later for streamed desktop interactions. + +### Logging and observability + +- Use structured log entries with severity, category, and message fields. +- Keep a ring buffer for on-screen diagnostics and crash-adjacent inspection. +- Mirror accepted log entries to the platform debug console. +- Build the statistics overlay from typed telemetry snapshots instead of formatting strings at capture sites. + +### xemu networking + +- Explicitly support launcher-controlled networking modes. +- Keep the default user-mode network path for simple outbound connectivity. +- Add tap networking support for LAN discovery and broadcast-sensitive workflows. +- Keep the xemu runtime state inside `.local/xemu` so pairing files, EEPROM data, and launcher config stay reproducible per workspace. + +## Milestones + +### M0: Test-first core shell + +- Expand the host-native unit test surface with platform-neutral modules under `src/app/`, `src/input/`, `src/logging/`, and `src/streaming/`. +- Build the retained menu model, keyboard and controller command mapping, and overlay formatting in isolation. +- Add a proper logging core and use it from startup and future runtime services. + +### M1: Rendered home shell on Xbox + +- Render a real home screen with text, focus states, and controller navigation. +- Add placeholder screens for Hosts, Add Host, and Settings. +- Show the new logging buffer and stats overlay from the Xbox runtime. + +### M2: Host records, discovery, and pairing + +- Add persistent host records and pairing state. +- Start with manual IP entry and PIN pairing. +- Add LAN discovery after the manual path is working in both xemu and on hardware. +- Cover parsing, persistence, and state transitions with host-native tests. + +### M3: Session control plane + +- Query host capabilities, app lists, and current sessions. +- Support launch, resume, and quit flows. +- Add connection preflight checks using `moonlight-common-c` port helpers and surface actionable log messages in the UI. + +### M4: Streaming pipeline + +- Connect `moonlight-common-c` callbacks to Xbox-specific video, audio, and input backends. +- Start with H.264 and stereo Opus. +- Add frame pacing, reconnect handling, controller rumble, and proper cleanup. + +### M5: User-visible polish + +- Replace placeholder menus with full host and app detail screens. +- Flesh out settings, overlay customization, input tuning, and controller hotkeys. +- Add pause and status overlays, stream problem notifications, and better recovery flows. + +## Testing strategy + +- Keep all non-rendering business logic platform-neutral and covered by gtests. +- Prefer reducers, models, and adapters that can be exercised without SDL or nxdk. +- Add targeted emulator smoke tests for launcher behavior and runtime boot flow. +- Add parser, persistence, and connection-state tests before integrating network or pairing code into the Xbox runtime. +- Use xemu only for integration coverage after host-native tests already lock in behavior. + +## Initial implementation in this change + +This changeset starts M0 by adding: + +- a structured in-memory logger; +- controller and keyboard command mapping for menus; +- a retained menu model that skips disabled entries and reports activations; +- an initial top-level client shell state machine for Home, Hosts, Add Host, and Settings; +- a typed streaming statistics overlay formatter; +- launcher groundwork for explicit xemu networking configuration and portable runtime state. + diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index b16d0ad..5c7e546 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -8,14 +8,22 @@ #include #include +#include "src/network/host_pairing.h" + namespace { - constexpr const char *HOST_MENU_ID_PREFIX = "host:"; - constexpr std::size_t OVERLAY_SCROLL_STEP = 4; - constexpr std::size_t ADD_HOST_KEYPAD_COLUMN_COUNT = 3; + 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:"; struct AddHostKeypadButton { - const char *id; char character; }; @@ -27,178 +35,205 @@ namespace { while (pin.size() < 4U) { pin.insert(pin.begin(), '0'); } - return pin; } - std::string build_host_menu_id(const std::string &address, uint16_t port) { - return std::string(HOST_MENU_ID_PREFIX) + address + ":" + std::to_string(app::effective_host_port(port)); + std::string default_add_host_address() { + return "192.168.0.10"; } - bool parse_host_menu_id(const std::string &itemId, std::string *address, uint16_t *port) { - if (itemId.rfind(HOST_MENU_ID_PREFIX, 0) != 0) { - return false; + std::vector build_add_host_keypad_buttons(const app::ClientState &state) { + if (state.addHostDraft.activeField == app::AddHostField::address) { + return {{'1'}, {'2'}, {'3'}, {'4'}, {'5'}, {'6'}, {'7'}, {'8'}, {'9'}, {'.'}, {'0'}}; } - const std::string endpoint = itemId.substr(std::char_traits::length(HOST_MENU_ID_PREFIX)); - const std::size_t separatorIndex = endpoint.find_last_of(':'); - if (separatorIndex == std::string::npos) { - return false; + return {{'1'}, {'2'}, {'3'}, {'4'}, {'5'}, {'6'}, {'7'}, {'8'}, {'9'}, {'0'}}; + } + + 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"; } - uint16_t parsedPort = 0; - if (!app::try_parse_host_port(endpoint.substr(separatorIndex + 1), &parsedPort)) { + return std::string(SETTINGS_CATEGORY_PREFIX) + "logging"; + } + + bool starts_with(const std::string &value, const char *prefix) { + return value.rfind(prefix, 0U) == 0U; + } + + bool host_matches_endpoint(const app::HostRecord &host, const std::string &address, uint16_t port) { + if (host.address != address) { return false; } - if (address != nullptr) { - *address = endpoint.substr(0, separatorIndex); + const uint16_t effectivePort = app::effective_host_port(port); + if (app::effective_host_port(host.port) == effectivePort) { + return true; } - if (port != nullptr) { - *port = parsedPort; + if (host.resolvedHttpPort != 0 && host.resolvedHttpPort == effectivePort) { + return true; } - return true; + if (host.httpsPort != 0 && host.httpsPort == effectivePort) { + return true; + } + return false; } - std::string build_endpoint_label(const std::string &address, uint16_t port) { - if (app::effective_host_port(port) == app::DEFAULT_HOST_PORT) { - return address; - } + void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen) { + state.addHostDraft = { + default_add_host_address(), + {}, + app::AddHostField::address, + {false, 0U, {}}, + returnScreen, + {}, + {}, + false, + }; + } - return address + ":" + std::to_string(app::effective_host_port(port)); + void reset_confirmation(app::ClientState &state) { + state.confirmation = {}; } - std::string default_add_host_address() { - return "192.168.0.10"; + 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; } const app::HostRecord *find_host_by_endpoint(const std::vector &hosts, const std::string &address, uint16_t port) { - const uint16_t effectivePort = app::effective_host_port(port); - const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, effectivePort](const app::HostRecord &host) { - return host.address == address && app::effective_host_port(host.port) == effectivePort; + const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, port](const app::HostRecord &host) { + return host_matches_endpoint(host, address, port); }); - if (iterator == hosts.end()) { - return nullptr; - } - - return &(*iterator); + return iterator == hosts.end() ? nullptr : &(*iterator); } app::HostRecord *find_host_by_endpoint(std::vector &hosts, const std::string &address, uint16_t port) { - const uint16_t effectivePort = app::effective_host_port(port); - const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, effectivePort](const app::HostRecord &host) { - return host.address == address && app::effective_host_port(host.port) == effectivePort; + const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, port](const app::HostRecord &host) { + return host_matches_endpoint(host, address, port); }); - if (iterator == hosts.end()) { - return nullptr; + return iterator == hosts.end() ? nullptr : &(*iterator); + } + + 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; + } - return &(*iterator); + 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); } - const app::HostRecord *selected_host_for_menu(const app::ClientState &state) { - if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) { - std::string address; - uint16_t port = 0; - if (parse_host_menu_id(selectedItem->id, &address, &port)) { - return find_host_by_endpoint(state.hosts, address, port); + 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); + } - if (!state.hosts.empty()) { - return &state.hosts.front(); + void refresh_running_flags(app::HostRecord *host) { + if (host == nullptr) { + return; } - return nullptr; + for (app::HostAppRecord &appRecord : host->apps) { + appRecord.running = static_cast(appRecord.id) == host->runningGameId; + } } - std::vector build_add_host_keypad_buttons(const app::ClientState &state) { - if (state.addHostDraft.activeField == app::AddHostField::address) { - return { - {"keypad-1", '1'}, - {"keypad-2", '2'}, - {"keypad-3", '3'}, - {"keypad-4", '4'}, - {"keypad-5", '5'}, - {"keypad-6", '6'}, - {"keypad-7", '7'}, - {"keypad-8", '8'}, - {"keypad-9", '9'}, - {"keypad-dot", '.'}, - {"keypad-0", '0'}, - }; + void clamp_selected_host_index(app::ClientState &state) { + if (state.hosts.empty()) { + state.selectedHostIndex = 0U; + state.hostsFocusArea = app::HostsFocusArea::toolbar; + state.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX; + return; } - return { - {"keypad-1", '1'}, - {"keypad-2", '2'}, - {"keypad-3", '3'}, - {"keypad-4", '4'}, - {"keypad-5", '5'}, - {"keypad-6", '6'}, - {"keypad-7", '7'}, - {"keypad-8", '8'}, - {"keypad-9", '9'}, - {"keypad-0", '0'}, - }; + if (state.selectedHostIndex >= state.hosts.size()) { + state.selectedHostIndex = state.hosts.size() - 1U; + } } - std::string add_host_field_menu_id(app::AddHostField field) { - return field == app::AddHostField::address ? "edit-address" : "edit-port"; + void reset_hosts_home_selection(app::ClientState &state) { + if (state.hosts.empty()) { + state.hostsFocusArea = app::HostsFocusArea::toolbar; + state.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX; + state.selectedHostIndex = 0U; + return; + } + + state.hostsFocusArea = app::HostsFocusArea::grid; + state.selectedHostIndex = 0U; } - void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen) { - state.addHostDraft = { - default_add_host_address(), - {}, - app::AddHostField::address, - {false, 0U, {}}, - returnScreen, - {}, - {}, - false, - }; + void clamp_selected_app_index(app::ClientState &state) { + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr) { + state.selectedAppIndex = 0U; + return; + } + + const std::vector indices = visible_app_indices(*host, state.showHiddenApps); + if (indices.empty()) { + state.selectedAppIndex = 0U; + return; + } + + if (state.selectedAppIndex >= indices.size()) { + state.selectedAppIndex = indices.size() - 1U; + } } std::vector build_menu_for_state(const app::ClientState &state) { switch (state.activeScreen) { - case app::ScreenId::home: + case app::ScreenId::settings: return { - {"hosts", "Hosts", true}, - {"add-host", "Add Host", true}, - {"settings", "Settings", true}, - {"exit", "Exit", true}, + {settings_category_menu_id(app::SettingsCategory::logging), "Logging", true}, + {settings_category_menu_id(app::SettingsCategory::display), "Display", true}, + {settings_category_menu_id(app::SettingsCategory::input), "Input", true}, + {settings_category_menu_id(app::SettingsCategory::reset), "Reset", true}, }; - case app::ScreenId::hosts: - { - const app::HostRecord *selectedHost = selected_host_for_menu(state); - std::vector items; - items.reserve(state.hosts.size() + 6); - - for (const app::HostRecord &host : state.hosts) { - items.push_back({ - build_host_menu_id(host.address, host.port), - host.displayName + (host.pairingState == app::PairingState::paired ? " [paired]" : " [not paired]"), - true, - }); - } - - items.push_back({"add-host", "Add Host", true}); - items.push_back({"test-connection", "Test Selected Host", selectedHost != nullptr}); - items.push_back({"pair-host", "Pair Selected Host", selectedHost != nullptr}); - items.push_back({"delete-host", "Delete Selected Host", selectedHost != nullptr}); - items.push_back({"discover-hosts", "Discover Hosts (soon)", false}); - items.push_back({"back-home", "Back", true}); - return items; - } case app::ScreenId::add_host: return { - {"edit-address", "Host Address: " + state.addHostDraft.addressInput, true}, - {"edit-port", std::string("Port: ") + (state.addHostDraft.portInput.empty() ? "default (47984)" : state.addHostDraft.portInput), true}, - {"clear-field", "Clear Current Field", state.addHostDraft.activeField == app::AddHostField::address - ? !state.addHostDraft.addressInput.empty() - : !state.addHostDraft.portInput.empty()}, - {"use-default-port", "Use Default Port", state.addHostDraft.activeField == app::AddHostField::port && !state.addHostDraft.portInput.empty()}, + {"edit-address", "Host Address", true}, + {"edit-port", "Host Port", true}, {"test-connection", "Test Connection", true}, {"start-pairing", "Start Pairing", true}, {"save-host", "Save Host", true}, @@ -208,52 +243,159 @@ namespace { return { {"cancel-pairing", "Cancel", true}, }; - case app::ScreenId::settings: + 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.activeScreen != app::ScreenId::settings) { + return {}; + } + + switch (state.selectedSettingsCategory) { + case app::SettingsCategory::logging: + return { + {"view-log-file", "View Log File", true}, + {"cycle-log-level", std::string("Logging Level: ") + logging::to_string(state.loggingLevel), true}, + }; + case app::SettingsCategory::display: return { - {"display-settings", "Display", true}, - {"input-settings", "Input", true}, - {"logging-settings", "Logging", true}, - {"back-home", "Back", true}, + {"display-placeholder", "Display settings are not implemented yet", true}, }; + case app::SettingsCategory::input: + return { + {"input-placeholder", "Input settings are not implemented yet", true}, + }; + case app::SettingsCategory::reset: + { + std::vector items = { + {"factory-reset", "Factory Reset", true}, + }; + for (const startup::SavedFileEntry &savedFile : state.savedFiles) { + items.push_back({std::string(DELETE_SAVED_FILE_MENU_ID_PREFIX) + savedFile.path, "Delete " + savedFile.displayName, true}); + } + return items; + } } return {}; } - void rebuild_menu(app::ClientState &state, const std::string &preferredItemId = {}, bool preserveCurrentSelection = true) { - const std::string currentSelection = preserveCurrentSelection && state.menu.selected_item() != nullptr + 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); - if (state.menu.selected_item() != nullptr && state.menu.selected_item()->id == preferredItemId) { - return; - } + if (!preferredItemId.empty() && state.menu.select_item_by_id(preferredItemId)) { + return; + } + if (!previousSelection.empty()) { + state.menu.select_item_by_id(previousSelection); } - if (!currentSelection.empty()) { - state.menu.select_item_by_id(currentSelection); + 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 close_modal(app::ClientState &state) { + state.modal = {}; + reset_confirmation(state); } void set_screen(app::ClientState &state, app::ScreenId screen, const std::string &preferredItemId = {}) { state.activeScreen = screen; + if (screen == app::ScreenId::settings) { + state.savedFilesDirty = true; + state.settingsFocusArea = app::SettingsFocusArea::categories; + } + close_modal(state); rebuild_menu(state, preferredItemId, false); + 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.logViewerPlacement) { + case app::LogViewerPlacement::full: + state.logViewerPlacement = app::LogViewerPlacement::left; + return; + case app::LogViewerPlacement::left: + state.logViewerPlacement = app::LogViewerPlacement::right; + return; + case app::LogViewerPlacement::right: + state.logViewerPlacement = app::LogViewerPlacement::full; + return; + } + } + + void scroll_log_viewer(app::ClientState &state, bool towardOlderEntries, std::size_t step) { + if (state.logViewerLines.empty() || step == 0U) { + state.logViewerScrollOffset = 0U; + return; + } + + const std::size_t maxOffset = state.logViewerLines.size() > 1U ? state.logViewerLines.size() - 1U : 0U; + if (towardOlderEntries) { + state.logViewerScrollOffset = std::min(maxOffset, state.logViewerScrollOffset + step); + return; + } + + state.logViewerScrollOffset = state.logViewerScrollOffset > step ? state.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 = 0; - state.addHostDraft.keypad.stagedInput = field == app::AddHostField::address - ? state.addHostDraft.addressInput - : state.addHostDraft.portInput; - state.statusMessage = field == app::AddHostField::address - ? "Editing host address" - : "Editing host port"; + state.addHostDraft.keypad.selectedButtonIndex = 0U; + state.addHostDraft.keypad.stagedInput = field == app::AddHostField::address ? state.addHostDraft.addressInput : state.addHostDraft.portInput; + state.statusMessage = field == app::AddHostField::address ? "Editing host address" : "Editing host port"; rebuild_menu(state, add_host_field_menu_id(field)); } @@ -270,9 +412,7 @@ namespace { } else { state.addHostDraft.portInput = state.addHostDraft.keypad.stagedInput; - state.statusMessage = state.addHostDraft.portInput.empty() - ? "Using default Moonlight host port 47984" - : "Updated host port"; + state.statusMessage = state.addHostDraft.portInput.empty() ? "Using default Moonlight host port 47989" : "Updated host port"; } state.addHostDraft.validationMessage.clear(); @@ -281,9 +421,7 @@ namespace { } void cancel_add_host_keypad(app::ClientState &state) { - state.statusMessage = state.addHostDraft.activeField == app::AddHostField::address - ? "Cancelled host address edit" - : "Cancelled host port edit"; + state.statusMessage = state.addHostDraft.activeField == app::AddHostField::address ? "Cancelled host address edit" : "Cancelled host port edit"; close_add_host_keypad(state); } @@ -293,99 +431,47 @@ namespace { return false; } - const int rowCount = static_cast((buttons.size() + ADD_HOST_KEYPAD_COLUMN_COUNT - 1) / ADD_HOST_KEYPAD_COLUMN_COUNT); + const int rowCount = static_cast((buttons.size() + ADD_HOST_KEYPAD_COLUMN_COUNT - 1U) / ADD_HOST_KEYPAD_COLUMN_COUNT); const std::size_t currentIndex = state.addHostDraft.keypad.selectedButtonIndex % buttons.size(); - const int currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT); - const int currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT); - const int nextRow = (currentRow + rowCount + rowDelta) % rowCount; - int nextColumn = (currentColumn + static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT) + columnDelta) % static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT); - int nextIndex = (nextRow * static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT)) + nextColumn; - while (nextIndex >= static_cast(buttons.size()) && nextColumn > 0) { - --nextColumn; - nextIndex = (nextRow * static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT)) + nextColumn; - } - - state.addHostDraft.keypad.selectedButtonIndex = nextIndex >= 0 && nextIndex < static_cast(buttons.size()) - ? static_cast(nextIndex) - : currentIndex; - return state.addHostDraft.keypad.selectedButtonIndex != currentIndex; - } - - std::string *active_add_host_input_buffer(app::ClientState &state) { - if (state.addHostDraft.keypad.visible) { - return &state.addHostDraft.keypad.stagedInput; - } - - return state.addHostDraft.activeField == app::AddHostField::address - ? &state.addHostDraft.addressInput - : &state.addHostDraft.portInput; - } + int currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT); + int currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT); - bool append_to_active_add_host_field(app::ClientState &state, char character) { - std::string *target = active_add_host_input_buffer(state); + currentRow = std::clamp(currentRow + rowDelta, 0, rowCount - 1); + currentColumn = std::clamp(currentColumn + columnDelta, 0, static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT) - 1); - if (state.addHostDraft.activeField == app::AddHostField::address) { - if ((character < '0' || character > '9') && character != '.') { - return false; - } - if (target->size() >= 15) { - return false; - } - } - else { - if (character < '0' || character > '9') { - return false; - } - if (target->size() >= 5) { - return false; - } + std::size_t nextIndex = static_cast((currentRow * static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT)) + currentColumn); + if (nextIndex >= buttons.size()) { + nextIndex = buttons.size() - 1U; } - - target->push_back(character); - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); - state.statusMessage = state.addHostDraft.activeField == app::AddHostField::address - ? "Editing host address" - : "Editing host port"; + state.addHostDraft.keypad.selectedButtonIndex = nextIndex; return true; } - bool backspace_active_add_host_field(app::ClientState &state) { - std::string *target = active_add_host_input_buffer(state); - if (target->empty()) { - return false; - } - - target->pop_back(); + 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(); - return true; } - void clear_active_add_host_field(app::ClientState &state) { - if (state.addHostDraft.activeField == app::AddHostField::address) { - state.addHostDraft.addressInput.clear(); + void backspace_active_add_host_field(app::ClientState &state) { + if (!state.addHostDraft.keypad.stagedInput.empty()) { + state.addHostDraft.keypad.stagedInput.pop_back(); } - else { - state.addHostDraft.portInput.clear(); - } - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); } 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 address"; + *errorMessage = "Enter a valid IPv4 host address"; } return false; } uint16_t port = 0; - if (!app::try_parse_host_port(state.addHostDraft.portInput, &port)) { + if (!state.addHostDraft.portInput.empty() && !app::try_parse_host_port(state.addHostDraft.portInput, &port)) { if (errorMessage != nullptr) { - *errorMessage = "Enter a valid TCP port or leave it empty for the default"; + *errorMessage = "Enter a valid host port"; } return false; } @@ -399,140 +485,677 @@ namespace { return true; } - void enter_add_host_screen(app::ClientState &state) { - reset_add_host_draft(state, state.activeScreen); - set_screen(state, app::ScreenId::add_host, "edit-address"); + 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 enter_pair_host_screen(app::ClientState &state, const std::string &targetAddress, uint16_t targetPort) { - state.pairingDraft = app::create_pairing_draft(targetAddress, app::effective_host_port(targetPort), generate_pairing_pin()); - state.pairingDraft.stage = app::PairingStage::in_progress; - set_screen(state, app::ScreenId::pair_host, "cancel-pairing"); + void move_toolbar_selection(app::ClientState &state, int direction) { + const std::size_t current = state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT; + state.selectedToolbarButtonIndex = direction < 0 + ? (current + HOST_TOOLBAR_BUTTON_COUNT - 1U) % HOST_TOOLBAR_BUTTON_COUNT + : (current + 1U) % HOST_TOOLBAR_BUTTON_COUNT; } -} // namespace + std::size_t grid_row_count(std::size_t itemCount, std::size_t columnCount) { + return itemCount == 0U || columnCount == 0U ? 0U : ((itemCount + columnCount - 1U) / columnCount); + } -namespace app { + std::size_t grid_row_start(std::size_t row, std::size_t columnCount) { + return row * columnCount; + } - ClientState create_initial_state() { - ClientState state { - ScreenId::home, - false, - false, - false, - 0U, - ui::MenuModel(), - {}, - {default_add_host_address(), {}, AddHostField::address, {false, 0U, {}}, ScreenId::home, {}, {}, false}, - {{}, DEFAULT_HOST_PORT, {}, PairingStage::idle, {}}, - {}, - }; - rebuild_menu(state); - return state; + 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); } - const char *to_string(ScreenId screen) { - switch (screen) { - case ScreenId::home: - return "home"; - case ScreenId::hosts: - return "hosts"; - case ScreenId::add_host: - return "add_host"; - case ScreenId::pair_host: - return "pair_host"; - case ScreenId::settings: - return "settings"; + 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 "unknown"; + return rowStart + std::min(preferredColumn, (rowEnd - rowStart) - 1U); } - void replace_hosts(ClientState &state, std::vector hosts, std::string statusMessage) { - state.hosts = std::move(hosts); - state.hostsDirty = false; - state.statusMessage = std::move(statusMessage); - - if (state.activeScreen == ScreenId::hosts || state.activeScreen == ScreenId::home) { - rebuild_menu(state); + 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; } - else if (state.activeScreen == ScreenId::pair_host) { - if (find_host_by_endpoint(state.hosts, state.pairingDraft.targetAddress, state.pairingDraft.targetPort) == nullptr) { - set_screen(state, ScreenId::hosts); - } - else { - rebuild_menu(state); - } + if (selectedIndex == nullptr || itemCount == 0U || columnCount == 0U) { + return false; } - } - std::string current_add_host_address(const ClientState &state) { - return state.addHostDraft.addressInput; - } + 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) { + const std::size_t rowEnd = grid_row_end(itemCount, currentRow, columnCount); + if (currentIndex + 1U < rowEnd) { + ++currentIndex; + continue; + } - uint16_t current_add_host_port(const ClientState &state) { - uint16_t port = 0; - if (!try_parse_host_port(state.addHostDraft.portInput, &port)) { - return DEFAULT_HOST_PORT; + const std::size_t nextRow = (currentIndex / columnCount) + 1U; + if (nextRow >= rowCount) { + break; + } + currentIndex = grid_row_start(nextRow, columnCount); + } + *selectedIndex = currentIndex; + return true; } - return effective_host_port(port); - } - - std::string current_pairing_pin(const ClientState &state) { - return state.pairingDraft.generatedPin; - } + if (columnDelta < 0) { + for (int step = 0; step < -columnDelta; ++step) { + if ((currentIndex % columnCount) > 0U) { + --currentIndex; + continue; + } - void apply_connection_test_result(ClientState &state, bool success, std::string message) { - if (state.activeScreen == ScreenId::add_host) { - state.addHostDraft.connectionMessage = message; - state.addHostDraft.lastConnectionSucceeded = success; + 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; } - state.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; - state.pairingDraft.statusMessage = message; - state.statusMessage = std::move(message); - - if (!success) { + if (rowDelta == 0) { return false; } - HostRecord *host = find_host_by_endpoint(state.hosts, address, port); - if (host == nullptr || host->pairingState == PairingState::paired) { + const int targetRow = static_cast(currentRow) + rowDelta; + if (targetRow < 0) { + if (movedAboveFirstRow != nullptr) { + *movedAboveFirstRow = true; + } return false; } - host->pairingState = PairingState::paired; - state.hostsDirty = true; + const std::size_t clampedRow = std::min(static_cast(targetRow), rowCount - 1U); + *selectedIndex = closest_index_in_row(itemCount, clampedRow, columnCount, currentColumn); return true; } - const HostRecord *selected_host(const ClientState &state) { - if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) { - std::string address; - uint16_t port = 0; - if (parse_host_menu_id(selectedItem->id, &address, &port)) { - return find_host_by_endpoint(state.hosts, address, port); - } + void move_host_grid_selection(app::ClientState &state, int rowDelta, int columnDelta) { + if (state.hosts.empty()) { + state.hostsFocusArea = app::HostsFocusArea::toolbar; + return; } - return nullptr; + bool movedAboveFirstRow = false; + move_grid_selection(state.hosts.size(), HOST_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.selectedHostIndex, &movedAboveFirstRow); + if (movedAboveFirstRow) { + state.hostsFocusArea = app::HostsFocusArea::toolbar; + return; + } + state.hostsFocusArea = app::HostsFocusArea::grid; } - AppUpdate handle_command(ClientState &state, input::UiCommand command) { - AppUpdate update {}; - update.connectionTestPort = 0; - update.pairingPort = 0; + void move_app_grid_selection(app::ClientState &state, int rowDelta, int columnDelta) { + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr) { + state.selectedAppIndex = 0U; + return; + } + + const std::vector indices = visible_app_indices(*host, state.showHiddenApps); + if (indices.empty()) { + state.selectedAppIndex = 0U; + return; + } + + move_grid_selection(indices.size(), APP_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.selectedAppIndex); + } + + void enter_add_host_screen(app::ClientState &state) { + reset_add_host_draft(state, state.activeScreen == app::ScreenId::add_host ? app::ScreenId::hosts : state.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) { + const app::HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + if (host != nullptr && host->reachability == app::HostReachability::offline) { + state.statusMessage = "Host is offline. Bring it online before pairing."; + return false; + } + + state.pairingDraft = app::create_pairing_draft(address, app::effective_host_port(port), generate_pairing_pin()); + set_screen(state, app::ScreenId::pair_host, "cancel-pairing"); + return true; + } + + bool enter_apps_screen(app::ClientState &state, bool showHiddenApps) { + app::HostRecord *host = state.hosts.empty() ? nullptr : &state.hosts[state.selectedHostIndex]; + if (host == nullptr) { + return false; + } + if (host->reachability == app::HostReachability::offline) { + state.statusMessage = "Host is offline. Bring it online before opening apps."; + return false; + } + if (host->pairingState != app::PairingState::paired) { + state.statusMessage = "This host is no longer paired. Pair it again from Sunshine before opening apps."; + return false; + } + + state.showHiddenApps = showHiddenApps; + state.selectedAppIndex = 0U; + state.appsScrollPage = 0U; + host->appListState = app::HostAppListState::loading; + host->appListStatusMessage = (host->apps.empty() ? "Loading apps for " : "Refreshing apps for ") + host->displayName + "..."; + state.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.size(); ++index) { + if (host_matches_endpoint(state.hosts[index], address, port)) { + state.selectedHostIndex = index; + state.hostsFocusArea = app::HostsFocusArea::grid; + return; + } + } + } + + logging::LogLevel next_logging_level(logging::LogLevel currentLevel) { + switch (currentLevel) { + case logging::LogLevel::trace: + return logging::LogLevel::warning; + case logging::LogLevel::debug: + return logging::LogLevel::trace; + case logging::LogLevel::info: + return logging::LogLevel::debug; + case logging::LogLevel::warning: + return logging::LogLevel::error; + case logging::LogLevel::error: + return logging::LogLevel::info; + } + return logging::LogLevel::info; + } + + bool handle_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) { + if (!state.modal.active()) { + return false; + } + + if (state.modal.id == app::ModalId::log_viewer) { + switch (command) { + case input::UiCommand::back: + case input::UiCommand::activate: + case input::UiCommand::confirm: + close_modal(state); + update->modalClosed = true; + return true; + case input::UiCommand::delete_character: + case input::UiCommand::open_context_menu: + cycle_log_viewer_placement(state); + 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; + } + } + + if (command == input::UiCommand::back) { + close_modal(state); + update->modalClosed = true; + 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; + } + + switch (state.modal.id) { + case app::ModalId::support: + case app::ModalId::host_details: + case app::ModalId::app_details: + close_modal(state); + update->modalClosed = true; + return true; + case app::ModalId::log_viewer: + return true; + case app::ModalId::confirmation: + { + const bool confirmed = state.modal.selectedActionIndex % 2U == 0U; + const app::ConfirmationAction action = state.confirmation.action; + const std::string targetPath = state.confirmation.targetPath; + close_modal(state); + update->modalClosed = true; + if (!confirmed) { + state.statusMessage = "Cancelled the pending reset action"; + return true; + } + if (action == app::ConfirmationAction::delete_saved_file) { + update->savedFileDeleteRequested = true; + update->savedFileDeletePath = targetPath; + return true; + } + if (action == app::ConfirmationAction::factory_reset) { + update->factoryResetRequested = true; + return true; + } + return true; + } + case app::ModalId::host_actions: + { + const app::HostRecord *host = app::selected_host(state); + if (host == nullptr) { + close_modal(state); + update->modalClosed = true; + return true; + } + + switch (state.modal.selectedActionIndex % 4U) { + case 0: + close_modal(state); + update->modalClosed = true; + if (host->pairingState == app::PairingState::paired) { + update->appsBrowseRequested = true; + update->appsBrowseShowHidden = true; + } + else { + if (enter_pair_host_screen(state, host->address, host->port)) { + update->screenChanged = true; + update->pairingRequested = true; + update->pairingAddress = state.pairingDraft.targetAddress; + update->pairingPort = state.pairingDraft.targetPort; + update->pairingPin = state.pairingDraft.generatedPin; + } + } + return true; + case 1: + close_modal(state); + update->modalClosed = true; + update->connectionTestRequested = true; + update->connectionTestAddress = host->address; + update->connectionTestPort = app::effective_host_port(host->port); + return true; + case 2: + if (state.selectedHostIndex < state.hosts.size()) { + state.hosts.erase(state.hosts.begin() + static_cast(state.selectedHostIndex)); + state.hostsDirty = true; + update->hostsChanged = true; + clamp_selected_host_index(state); + close_modal(state); + update->modalClosed = true; + state.statusMessage = "Deleted saved host"; + } + return true; + case 3: + open_modal(state, app::ModalId::host_details); + return true; + } + return true; + } + case app::ModalId::app_actions: + { + const app::HostRecord *host = app::apps_host(state); + const app::HostAppRecord *selectedApp = app::selected_app(state); + if (host == nullptr || selectedApp == nullptr) { + close_modal(state); + update->modalClosed = true; + return true; + } + + app::HostRecord *mutableHost = &state.hosts[state.selectedHostIndex]; + const std::vector indices = visible_app_indices(*mutableHost, state.showHiddenApps); + if (indices.empty()) { + close_modal(state); + update->modalClosed = true; + return true; + } + app::HostAppRecord &appRecord = mutableHost->apps[indices[state.selectedAppIndex]]; + + switch (state.modal.selectedActionIndex % 3U) { + case 0: + appRecord.hidden = !appRecord.hidden; + close_modal(state); + update->modalClosed = 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; + close_modal(state); + update->modalClosed = true; + return true; + } + return true; + } + case app::ModalId::none: + return false; + } + + return false; + } + +} // namespace + +namespace app { + + ClientState create_initial_state() { + return { + ScreenId::hosts, + false, + false, + false, + 0U, + HostsFocusArea::toolbar, + DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX, + 0U, + 0U, + 0U, + false, + ui::MenuModel(), + ui::MenuModel(), + {}, + {default_add_host_address(), {}, AddHostField::address, {false, 0U, {}}, ScreenId::hosts, {}, {}, false}, + {{}, DEFAULT_HOST_PORT, {}, PairingStage::idle, {}}, + {}, + SettingsFocusArea::categories, + SettingsCategory::logging, + {}, + {}, + {}, + {}, + 0U, + LogViewerPlacement::full, + logging::LogLevel::info, + {}, + true, + }; + } + + 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 = std::move(hosts); + state.hostsDirty = false; + state.statusMessage = std::move(statusMessage); + reset_hosts_home_selection(state); + clamp_selected_host_index(state); + clamp_selected_app_index(state); + + if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { + rebuild_menu(state); + } + } + + void replace_saved_files(ClientState &state, std::vector savedFiles) { + state.savedFiles = std::move(savedFiles); + state.savedFilesDirty = false; + if (state.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.activeScreen == ScreenId::add_host) { + state.addHostDraft.connectionMessage = message; + state.addHostDraft.lastConnectionSucceeded = success; + } + if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { + state.hosts[state.selectedHostIndex].reachability = success ? HostReachability::online : HostReachability::offline; + } + state.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.statusMessage = std::move(message); + + HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + if (host == nullptr) { + return false; + } + + if (success) { + host->pairingState = PairingState::paired; + host->reachability = HostReachability::online; + select_host_by_endpoint(state, address, port); + set_screen(state, ScreenId::hosts); + state.hostsDirty = true; + return true; + } + + host->pairingState = PairingState::not_paired; + return false; + } + + 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_host_by_endpoint(state.hosts, address, port); + if (host == nullptr) { + return; + } + + const bool hostIsActiveAppsScreenSelection = state.activeScreen == ScreenId::apps && selected_host(state) == host; + const HostAppRecord *currentSelection = selected_app(state); + const int selectedAppId = currentSelection == nullptr ? 0 : currentSelection->id; + + if (!success) { + const bool hostIsUnpaired = network::error_indicates_unpaired_client(message); + if (hostIsUnpaired) { + host->pairingState = PairingState::not_paired; + host->apps.clear(); + host->appListContentHash = 0; + host->lastAppListRefreshTick = 0U; + host->appListState = HostAppListState::failed; + host->appListStatusMessage = message; + if (hostIsActiveAppsScreenSelection) { + state.statusMessage = std::move(message); + } + refresh_running_flags(host); + clamp_selected_app_index(state); + 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.statusMessage = std::move(message); + } + refresh_running_flags(host); + clamp_selected_app_index(state); + return; + } + + const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; + + if (appListChanged) { + 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)); + } + host->apps = std::move(mergedApps); + } + else { + refresh_running_flags(host); + } + + host->appListContentHash = appListContentHash; + host->appListState = HostAppListState::ready; + host->appListStatusMessage = message; + if (hostIsActiveAppsScreenSelection) { + state.statusMessage.clear(); + } + + if (selectedAppId != 0) { + const std::size_t restoredIndex = visible_app_index_for_id(*host, state.showHiddenApps, selectedAppId); + if (restoredIndex != static_cast(-1)) { + state.selectedAppIndex = restoredIndex; + } + } + clamp_selected_app_index(state); + } + + void mark_cover_art_cached(ClientState &state, const std::string &address, uint16_t port, int appId) { + HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + if (host == nullptr) { + return; + } + + for (HostAppRecord &appRecord : host->apps) { + if (appRecord.id == appId) { + appRecord.boxArtCached = true; + return; + } + } + } + + void set_log_file_path(ClientState &state, std::string logFilePath) { + state.logFilePath = std::move(logFilePath); + } + + void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage) { + state.logViewerLines = std::move(lines); + state.logViewerScrollOffset = 0U; + state.statusMessage = std::move(statusMessage); + open_modal(state, ModalId::log_viewer); + } + + const HostRecord *selected_host(const ClientState &state) { + if (state.hosts.empty() || state.selectedHostIndex >= state.hosts.size()) { + return nullptr; + } + return &state.hosts[state.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.showHiddenApps); + if (indices.empty()) { + return nullptr; + } + const std::size_t visibleIndex = std::min(state.selectedAppIndex, indices.size() - 1U); + return &host->apps[indices[visibleIndex]]; + } + + const HostRecord *apps_host(const ClientState &state) { + return selected_host(state); + } + + AppUpdate handle_command(ClientState &state, input::UiCommand command) { + AppUpdate update {}; if (command == input::UiCommand::toggle_overlay) { state.overlayVisible = !state.overlayVisible; if (!state.overlayVisible) { - state.overlayScrollOffset = 0; + state.overlayScrollOffset = 0U; } update.overlayChanged = true; update.overlayVisibilityChanged = true; @@ -545,11 +1168,19 @@ namespace app { update.overlayChanged = true; return update; } - if (command == input::UiCommand::next_page) { - state.overlayScrollOffset = state.overlayScrollOffset > OVERLAY_SCROLL_STEP - ? state.overlayScrollOffset - OVERLAY_SCROLL_STEP - : 0; + state.overlayScrollOffset = state.overlayScrollOffset > OVERLAY_SCROLL_STEP ? state.overlayScrollOffset - OVERLAY_SCROLL_STEP : 0U; + update.overlayChanged = true; + return update; + } + if (command == input::UiCommand::fast_previous_page) { + state.overlayScrollOffset += OVERLAY_SCROLL_STEP * 3U; + update.overlayChanged = true; + return update; + } + if (command == input::UiCommand::fast_next_page) { + const std::size_t fastStep = OVERLAY_SCROLL_STEP * 3U; + state.overlayScrollOffset = state.overlayScrollOffset > fastStep ? state.overlayScrollOffset - fastStep : 0U; update.overlayChanged = true; return update; } @@ -572,61 +1203,282 @@ namespace app { case input::UiCommand::back: cancel_add_host_keypad(state); return update; + case input::UiCommand::delete_character: + backspace_active_add_host_field(state); + return update; + case input::UiCommand::confirm: + accept_add_host_keypad(state); + return update; case input::UiCommand::activate: { const std::vector buttons = build_add_host_keypad_buttons(state); - if (buttons.empty()) { - return update; + if (!buttons.empty()) { + append_to_active_add_host_field(state, buttons[state.addHostDraft.keypad.selectedButtonIndex % buttons.size()].character); } + return update; + } + 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 update; + } + } + + if (handle_modal_command(state, command, &update)) { + return update; + } - const AddHostKeypadButton &button = buttons[state.addHostDraft.keypad.selectedButtonIndex % buttons.size()]; - append_to_active_add_host_field(state, button.character); + if (command == input::UiCommand::delete_character && !state.statusMessage.empty()) { + state.statusMessage.clear(); + return update; + } - rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField)); - return update; + if (state.activeScreen == ScreenId::settings) { + if (command == input::UiCommand::move_left && state.settingsFocusArea == SettingsFocusArea::options) { + state.settingsFocusArea = SettingsFocusArea::categories; + return update; + } + if (command == input::UiCommand::move_right && state.settingsFocusArea == SettingsFocusArea::categories && !state.detailMenu.items().empty()) { + state.settingsFocusArea = SettingsFocusArea::options; + return update; + } + + if (state.settingsFocusArea == SettingsFocusArea::categories) { + const ui::MenuUpdate categoryUpdate = state.menu.handle_command(command); + if (categoryUpdate.backRequested) { + set_screen(state, ScreenId::hosts); + update.screenChanged = true; + return update; + } + if (!categoryUpdate.activationRequested) { + return update; + } + + update.activatedItemId = categoryUpdate.activatedItemId; + if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::logging)) { + state.selectedSettingsCategory = SettingsCategory::logging; + } + else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::display)) { + state.selectedSettingsCategory = SettingsCategory::display; + } + else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::input)) { + state.selectedSettingsCategory = SettingsCategory::input; + } + else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::reset)) { + state.selectedSettingsCategory = SettingsCategory::reset; + } + rebuild_menu(state); + if (!state.detailMenu.items().empty()) { + state.settingsFocusArea = SettingsFocusArea::options; + } + return update; + } + + const ui::MenuUpdate detailUpdate = state.detailMenu.handle_command(command); + if (detailUpdate.backRequested) { + state.settingsFocusArea = SettingsFocusArea::categories; + return update; + } + if (!detailUpdate.activationRequested) { + return update; + } + + update.activatedItemId = detailUpdate.activatedItemId; + if (detailUpdate.activatedItemId == "view-log-file") { + update.logViewRequested = true; + return update; + } + if (detailUpdate.activatedItemId == "cycle-log-level") { + state.loggingLevel = next_logging_level(state.loggingLevel); + state.statusMessage = std::string("Logging level set to ") + logging::to_string(state.loggingLevel); + rebuild_menu(state, "cycle-log-level"); + return update; + } + if (detailUpdate.activatedItemId == "factory-reset") { + open_confirmation( + state, + ConfirmationAction::factory_reset, + "Factory Reset", + { + "Delete all Moonlight saved data?", + "This removes hosts, logs, pairing identity, and cached cover art.", + } + ); + update.modalOpened = true; + return update; + } + 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.modalOpened = true; + return update; + } + state.statusMessage = detailUpdate.activatedItemId + " is not implemented yet"; + return update; + } + + if (state.activeScreen == ScreenId::hosts) { + switch (command) { + case input::UiCommand::move_left: + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + move_toolbar_selection(state, -1); + } + else { + move_host_grid_selection(state, 0, -1); + } + return update; + case input::UiCommand::move_right: + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + move_toolbar_selection(state, 1); + } + else { + move_host_grid_selection(state, 0, 1); } + return update; + case input::UiCommand::move_down: + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + if (!state.hosts.empty()) { + state.hostsFocusArea = HostsFocusArea::grid; + } + } + else { + move_host_grid_selection(state, 1, 0); + } + return update; + case input::UiCommand::move_up: + if (state.hostsFocusArea == HostsFocusArea::grid) { + move_host_grid_selection(state, -1, 0); + } + return update; + case input::UiCommand::open_context_menu: + if (state.hostsFocusArea == HostsFocusArea::grid && selected_host(state) != nullptr) { + open_modal(state, ModalId::host_actions); + update.modalOpened = true; + } + return update; + case input::UiCommand::activate: case input::UiCommand::confirm: - accept_add_host_keypad(state); + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 0U) { + set_screen(state, ScreenId::settings, settings_category_menu_id(SettingsCategory::logging)); + update.screenChanged = true; + update.activatedItemId = "settings-button"; + return update; + } + if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 1U) { + open_modal(state, ModalId::support); + update.modalOpened = true; + update.activatedItemId = "support-button"; + return update; + } + enter_add_host_screen(state); + update.screenChanged = true; + update.activatedItemId = "add-host-button"; + return update; + } + + if (const HostRecord *host = selected_host(state); host != nullptr) { + update.activatedItemId = "select-host"; + if (host->pairingState == PairingState::paired) { + update.appsBrowseRequested = true; + update.appsBrowseShowHidden = false; + } + else { + if (enter_pair_host_screen(state, host->address, host->port)) { + update.screenChanged = true; + update.pairingRequested = true; + update.pairingAddress = state.pairingDraft.targetAddress; + update.pairingPort = state.pairingDraft.targetPort; + update.pairingPin = state.pairingDraft.generatedPin; + } + } + } return update; + case input::UiCommand::back: case input::UiCommand::delete_character: - backspace_active_add_host_field(state); - rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField)); - return update; - case input::UiCommand::none: 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: - break; + case input::UiCommand::none: + return update; } } - const ui::MenuUpdate menuUpdate = state.menu.handle_command(command); - if (state.activeScreen == ScreenId::hosts && menuUpdate.selectionChanged) { - rebuild_menu(state); + if (state.activeScreen == ScreenId::apps) { + switch (command) { + case input::UiCommand::move_left: + move_app_grid_selection(state, 0, -1); + return update; + case input::UiCommand::move_right: + move_app_grid_selection(state, 0, 1); + return update; + case input::UiCommand::move_up: + move_app_grid_selection(state, -1, 0); + return update; + case input::UiCommand::move_down: + move_app_grid_selection(state, 1, 0); + return update; + case input::UiCommand::open_context_menu: + if (selected_app(state) != nullptr) { + open_modal(state, ModalId::app_actions); + update.modalOpened = true; + } + return update; + case input::UiCommand::activate: + case input::UiCommand::confirm: + if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) { + state.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; + update.activatedItemId = "launch-app"; + } + return update; + case input::UiCommand::back: + state.statusMessage.clear(); + set_screen(state, ScreenId::hosts); + update.screenChanged = true; + return update; + 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 update; + } } + const ui::MenuUpdate menuUpdate = state.menu.handle_command(command); if (menuUpdate.overlayToggleRequested) { state.overlayVisible = !state.overlayVisible; - if (!state.overlayVisible) { - state.overlayScrollOffset = 0; - } update.overlayChanged = true; update.overlayVisibilityChanged = true; + return update; } - if (menuUpdate.backRequested && state.activeScreen != ScreenId::home) { - if (state.activeScreen == ScreenId::add_host) { - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); - set_screen(state, state.addHostDraft.returnScreen == ScreenId::add_host ? ScreenId::home : state.addHostDraft.returnScreen, state.addHostDraft.returnScreen == ScreenId::hosts ? "add-host" : std::string {}); - } - else if (state.activeScreen == ScreenId::pair_host) { - set_screen(state, ScreenId::hosts, build_host_menu_id(state.pairingDraft.targetAddress, state.pairingDraft.targetPort)); - } - else { - set_screen(state, ScreenId::home); + if (menuUpdate.backRequested) { + if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { + if (state.activeScreen == ScreenId::pair_host) { + update.pairingCancelledRequested = true; + } + state.statusMessage = state.activeScreen == ScreenId::apps ? std::string {} : state.statusMessage; + set_screen(state, ScreenId::hosts); + update.screenChanged = true; } - update.screenChanged = true; return update; } @@ -636,231 +1488,104 @@ namespace app { update.activatedItemId = menuUpdate.activatedItemId; - if (menuUpdate.activatedItemId == "hosts") { - set_screen(state, ScreenId::hosts); - update.screenChanged = true; + if (state.activeScreen == ScreenId::pair_host) { + if (menuUpdate.activatedItemId == "cancel-pairing") { + update.pairingCancelledRequested = true; + set_screen(state, ScreenId::hosts); + update.screenChanged = true; + } return update; } - if (menuUpdate.activatedItemId == "add-host") { - enter_add_host_screen(state); - update.screenChanged = true; + if (state.activeScreen != ScreenId::add_host) { return update; } - if (menuUpdate.activatedItemId == "settings") { - set_screen(state, ScreenId::settings); - update.screenChanged = true; + if (menuUpdate.activatedItemId == "edit-address") { + open_add_host_keypad(state, AddHostField::address); return update; } - - if (menuUpdate.activatedItemId == "back-home") { - set_screen(state, ScreenId::home); - update.screenChanged = true; + if (menuUpdate.activatedItemId == "edit-port") { + open_add_host_keypad(state, AddHostField::port); return update; } - if (state.activeScreen == ScreenId::add_host) { - if (menuUpdate.activatedItemId == "edit-address") { - open_add_host_keypad(state, AddHostField::address); - return update; - } - - if (menuUpdate.activatedItemId == "edit-port") { - open_add_host_keypad(state, AddHostField::port); - return update; - } - - if (menuUpdate.activatedItemId == "clear-field") { - clear_active_add_host_field(state); - rebuild_menu(state, add_host_field_menu_id(state.addHostDraft.activeField)); - return update; - } + std::string normalizedAddress; + uint16_t parsedPort = 0; + std::string validationError; + const bool draftIsValid = normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &validationError); - if (menuUpdate.activatedItemId == "use-default-port") { - state.addHostDraft.portInput.clear(); - state.statusMessage = "Using default Moonlight host port 47984"; - rebuild_menu(state, "edit-port"); + if (menuUpdate.activatedItemId == "test-connection") { + if (!draftIsValid) { + state.addHostDraft.validationMessage = validationError; + state.statusMessage = validationError; return update; } + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage = "Testing connection to " + normalizedAddress + (parsedPort == 0 ? std::string {} : ":" + std::to_string(parsedPort)) + "..."; + state.statusMessage = state.addHostDraft.connectionMessage; + update.connectionTestRequested = true; + update.connectionTestAddress = normalizedAddress; + update.connectionTestPort = effective_host_port(parsedPort); + return update; + } - std::string normalizedAddress; - uint16_t parsedPort = 0; - std::string errorMessage; - const bool draftIsValid = normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &errorMessage); - - if (menuUpdate.activatedItemId == "test-connection") { - if (!draftIsValid) { - state.addHostDraft.validationMessage = errorMessage; - state.statusMessage = errorMessage; - rebuild_menu(state, "test-connection"); - return update; - } - - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage = "Testing connection to " + build_endpoint_label(normalizedAddress, parsedPort) + "..."; - state.statusMessage = state.addHostDraft.connectionMessage; - update.connectionTestRequested = true; - update.connectionTestAddress = normalizedAddress; - update.connectionTestPort = effective_host_port(parsedPort); + if (menuUpdate.activatedItemId == "save-host") { + if (!draftIsValid) { + state.addHostDraft.validationMessage = validationError; + state.statusMessage = validationError; return update; } - - if (menuUpdate.activatedItemId == "save-host") { - if (!draftIsValid) { - state.addHostDraft.validationMessage = errorMessage; - state.statusMessage = errorMessage; - rebuild_menu(state, menuUpdate.activatedItemId); - return update; - } - - HostRecord *existingHost = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); - if (existingHost == nullptr) { - state.hosts.push_back({ - build_default_host_display_name(normalizedAddress), - normalizedAddress, - parsedPort, - PairingState::not_paired, - }); - state.hostsDirty = true; - update.hostsChanged = true; - existingHost = &state.hosts.back(); - } - else if (menuUpdate.activatedItemId == "save-host") { - state.addHostDraft.validationMessage = "That host is already saved"; - state.statusMessage = state.addHostDraft.validationMessage; - rebuild_menu(state, menuUpdate.activatedItemId); - return update; - } - - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); - - if (menuUpdate.activatedItemId == "save-host") { - state.statusMessage = "Saved host " + build_endpoint_label(existingHost->address, existingHost->port); - set_screen(state, ScreenId::hosts, build_host_menu_id(existingHost->address, existingHost->port)); - update.screenChanged = true; - update.activatedItemId = build_host_menu_id(existingHost->address, existingHost->port); - return update; - } - - } - - if (menuUpdate.activatedItemId == "cancel-add-host") { - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); - set_screen(state, state.addHostDraft.returnScreen == ScreenId::add_host ? ScreenId::home : state.addHostDraft.returnScreen, state.addHostDraft.returnScreen == ScreenId::hosts ? "add-host" : std::string {}); - update.screenChanged = true; + if (find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort) != nullptr) { + state.addHostDraft.validationMessage = "That host is already saved"; + state.statusMessage = state.addHostDraft.validationMessage; return update; } - } - if (menuUpdate.activatedItemId == "test-connection") { - if (state.activeScreen == ScreenId::hosts) { - if (const HostRecord *host = selected_host_for_menu(state); host != nullptr) { - state.statusMessage = "Testing connection to " + build_endpoint_label(host->address, host->port) + "..."; - update.connectionTestAddress = host->address; - update.connectionTestPort = effective_host_port(host->port); - update.connectionTestRequested = true; - } - } - return update; - } - - if (menuUpdate.activatedItemId == "pair-host") { - if (const HostRecord *host = selected_host_for_menu(state); host != nullptr) { - enter_pair_host_screen(state, host->address, host->port); - update.screenChanged = true; - update.pairingRequested = true; - update.pairingAddress = state.pairingDraft.targetAddress; - update.pairingPort = state.pairingDraft.targetPort; - update.pairingPin = state.pairingDraft.generatedPin; - } + state.hosts.push_back(make_host_record(normalizedAddress, parsedPort)); + state.selectedHostIndex = state.hosts.size() - 1U; + state.hostsFocusArea = HostsFocusArea::grid; + state.hostsDirty = true; + update.hostsChanged = true; + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + state.statusMessage = "Saved host " + normalizedAddress; + set_screen(state, ScreenId::hosts); + update.screenChanged = true; return update; } if (menuUpdate.activatedItemId == "start-pairing") { - std::string normalizedAddress; - uint16_t parsedPort = 0; - std::string errorMessage; - if (!normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &errorMessage)) { - state.addHostDraft.validationMessage = errorMessage; - state.statusMessage = errorMessage; - rebuild_menu(state, menuUpdate.activatedItemId); + if (!draftIsValid) { + state.addHostDraft.validationMessage = validationError; + state.statusMessage = validationError; return update; } - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); - HostRecord *host = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); if (host == nullptr) { - state.hosts.push_back({ - build_default_host_display_name(normalizedAddress), - normalizedAddress, - parsedPort, - PairingState::not_paired, - }); + state.hosts.push_back(make_host_record(normalizedAddress, parsedPort)); + state.selectedHostIndex = state.hosts.size() - 1U; state.hostsDirty = true; update.hostsChanged = true; host = &state.hosts.back(); } - enter_pair_host_screen(state, host->address, host->port); - update.screenChanged = true; - update.pairingRequested = true; - update.pairingAddress = state.pairingDraft.targetAddress; - update.pairingPort = state.pairingDraft.targetPort; - update.pairingPin = state.pairingDraft.generatedPin; - return update; - } - - if (state.activeScreen == ScreenId::pair_host) { - - if (menuUpdate.activatedItemId == "cancel-pairing") { - set_screen(state, ScreenId::hosts, build_host_menu_id(state.pairingDraft.targetAddress, state.pairingDraft.targetPort)); + if (enter_pair_host_screen(state, host->address, host->port)) { update.screenChanged = true; - return update; - } - } - - if (menuUpdate.activatedItemId == "delete-host") { - if (const HostRecord *host = selected_host_for_menu(state); host != nullptr) { - const std::string deletedAddress = host->address; - const uint16_t deletedPort = host->port; - const std::string deletedName = host->displayName; - const auto iterator = std::find_if(state.hosts.begin(), state.hosts.end(), [&deletedAddress, deletedPort](const HostRecord &candidate) { - return candidate.address == deletedAddress && effective_host_port(candidate.port) == effective_host_port(deletedPort); - }); - const auto removedIndex = static_cast(std::distance(state.hosts.begin(), iterator)); - - if (iterator != state.hosts.end()) { - state.hosts.erase(iterator); - state.hostsDirty = true; - state.statusMessage = "Deleted host " + deletedName; - - std::string preferredItemId = "add-host"; - if (!state.hosts.empty()) { - const std::size_t preferredIndex = std::min(removedIndex, state.hosts.size() - 1); - preferredItemId = build_host_menu_id(state.hosts[preferredIndex].address, state.hosts[preferredIndex].port); - } - - set_screen(state, ScreenId::hosts, preferredItemId); - update.hostsChanged = true; - } + update.pairingRequested = true; + update.pairingAddress = state.pairingDraft.targetAddress; + update.pairingPort = state.pairingDraft.targetPort; + update.pairingPin = state.pairingDraft.generatedPin; } return update; } - if (const HostRecord *host = selected_host(state); host != nullptr) { - state.statusMessage = "Selected " + host->displayName + " at " + build_endpoint_label(host->address, host->port) + "."; - rebuild_menu(state, build_host_menu_id(host->address, host->port)); - return update; - } - - if (menuUpdate.activatedItemId == "exit") { - state.shouldExit = true; - update.exitRequested = true; + if (menuUpdate.activatedItemId == "cancel-add-host") { + state.addHostDraft.validationMessage.clear(); + state.addHostDraft.connectionMessage.clear(); + set_screen(state, ScreenId::hosts); + update.screenChanged = true; } return update; diff --git a/src/app/client_state.h b/src/app/client_state.h index 7701601..de9040d 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -9,6 +9,8 @@ #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 { @@ -19,11 +21,67 @@ namespace app { enum class ScreenId { home, hosts, + apps, add_host, pair_host, settings, }; + /** + * @brief Focus areas on the hosts page. + */ + enum class HostsFocusArea { + toolbar, + grid, + }; + + /** + * @brief Active modal surfaced on top of the current page. + */ + enum class ModalId { + none, + support, + host_actions, + host_details, + app_actions, + app_details, + confirmation, + log_viewer, + }; + + enum class LogViewerPlacement { + full, + left, + right, + }; + + /** + * @brief Focus areas used by the two-pane settings screen. + */ + enum class SettingsFocusArea { + categories, + options, + }; + + /** + * @brief Top-level categories shown on the left side of the settings screen. + */ + enum class SettingsCategory { + logging, + display, + input, + reset, + }; + + /** + * @brief Destructive confirmation requests surfaced in a modal popup. + */ + enum class ConfirmationAction { + none, + delete_saved_file, + factory_reset, + }; + /** * @brief Active field for keypad-based host entry. */ @@ -55,6 +113,24 @@ namespace app { bool lastConnectionSucceeded; }; + /** + * @brief Context modal state shared by the hosts and apps pages. + */ + struct ModalState { + ModalId id = ModalId::none; + std::size_t selectedActionIndex = 0; + bool active() const { + return id != ModalId::none; + } + }; + + struct ConfirmationDialogState { + ConfirmationAction action = ConfirmationAction::none; + std::string targetPath; + std::string title; + std::vector lines; + }; + /** * @brief Serializable app state for the menu-driven client shell. */ @@ -64,11 +140,29 @@ namespace app { bool shouldExit; bool hostsDirty; std::size_t overlayScrollOffset; + HostsFocusArea hostsFocusArea; + std::size_t selectedToolbarButtonIndex; + std::size_t selectedHostIndex; + std::size_t selectedAppIndex; + std::size_t appsScrollPage; + bool showHiddenApps; ui::MenuModel menu; + ui::MenuModel detailMenu; std::vector hosts; AddHostDraft addHostDraft; PairingDraft pairingDraft; + ModalState modal; + SettingsFocusArea settingsFocusArea = SettingsFocusArea::categories; + SettingsCategory selectedSettingsCategory = SettingsCategory::logging; + ConfirmationDialogState confirmation; std::string statusMessage; + std::string logFilePath; + std::vector logViewerLines; + std::size_t logViewerScrollOffset = 0U; + LogViewerPlacement logViewerPlacement = LogViewerPlacement::full; + logging::LogLevel loggingLevel = logging::LogLevel::info; + std::vector savedFiles; + bool savedFilesDirty = true; }; /** @@ -82,12 +176,21 @@ namespace app { bool hostsChanged; bool connectionTestRequested; bool pairingRequested; + bool pairingCancelledRequested; + bool appsBrowseRequested; + bool appsBrowseShowHidden; + bool logViewRequested; + bool savedFileDeleteRequested; + bool factoryResetRequested; + bool modalOpened; + bool modalClosed; std::string activatedItemId; std::string connectionTestAddress; uint16_t connectionTestPort; std::string pairingAddress; uint16_t pairingPort; std::string pairingPin; + std::string savedFileDeletePath; }; /** @@ -114,6 +217,14 @@ namespace app { */ 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. * @@ -159,6 +270,49 @@ namespace app { */ bool apply_pairing_result(ClientState &state, const std::string &address, uint16_t port, bool success, std::string message); + /** + * @brief Apply a fetched Sunshine 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 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); + + void set_log_file_path(ClientState &state, std::string logFilePath); + + void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage); + + /** + * @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 saved host on the Hosts screen. * @@ -167,6 +321,22 @@ namespace app { */ 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. * diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index 8ce1513..3f1f561 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -72,6 +72,19 @@ namespace app { 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) { diff --git a/src/app/host_records.h b/src/app/host_records.h index 6f02ff3..8d0c367 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -8,7 +8,7 @@ namespace app { - inline constexpr uint16_t DEFAULT_HOST_PORT = 47984; + inline constexpr uint16_t DEFAULT_HOST_PORT = 47989; /** * @brief Pairing state tracked for a saved host record. @@ -18,14 +18,63 @@ namespace app { paired, }; + /** + * @brief Reachability state tracked for a discovered or saved host. + */ + enum class HostReachability { + unknown, + online, + offline, + }; + + /** + * @brief Fetch state for the per-host Sunshine app library. + */ + enum class HostAppListState { + idle, + loading, + ready, + failed, + }; + + /** + * @brief App metadata shown on the per-host apps page. + */ + struct HostAppRecord { + std::string name; + int id = 0; + bool hdrSupported = false; + bool hidden = false; + bool favorite = false; + std::string boxArtCacheKey; + bool boxArtCached = false; + bool running = false; + }; + /** * @brief Manual host record shown in the shell. */ struct HostRecord { std::string displayName; std::string address; - uint16_t port; - PairingState pairingState; + uint16_t port = 0; + PairingState pairingState = PairingState::not_paired; + HostReachability reachability = HostReachability::unknown; + std::string activeAddress; + std::string uuid; + std::string localAddress; + std::string remoteAddress; + std::string ipv6Address; + std::string manualAddress; + std::string macAddress; + uint16_t httpsPort = 0; + uint32_t runningGameId = 0; + std::vector apps; + HostAppListState appListState = HostAppListState::idle; + std::string appListStatusMessage; + uint16_t resolvedHttpPort = 0; + uint64_t appListContentHash = 0; + uint32_t lastAppListRefreshTick = 0; }; /** @@ -44,6 +93,14 @@ namespace app { */ 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. * diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp index b6dfcb5..2cc3a06 100644 --- a/src/app/pairing_flow.cpp +++ b/src/app/pairing_flow.cpp @@ -8,8 +8,8 @@ namespace app { targetAddress, targetPort, std::move(generatedPin), - PairingStage::pin_ready, - "Pairing request sent. Enter the PIN on the host if prompted.", + PairingStage::idle, + "Checking whether the host is reachable before pairing begins.", }; return draft; } diff --git a/src/input/navigation_input.cpp b/src/input/navigation_input.cpp index ae81f70..9d5efb3 100644 --- a/src/input/navigation_input.cpp +++ b/src/input/navigation_input.cpp @@ -22,12 +22,12 @@ namespace input { 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; - case GamepadButton::y: - return UiCommand::toggle_overlay; } return UiCommand::none; @@ -59,6 +59,8 @@ namespace input { 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; } diff --git a/src/input/navigation_input.h b/src/input/navigation_input.h index 4a05ae5..7e6b7dc 100644 --- a/src/input/navigation_input.h +++ b/src/input/navigation_input.h @@ -14,9 +14,12 @@ namespace input { activate, confirm, back, + open_context_menu, delete_character, previous_page, next_page, + fast_previous_page, + fast_next_page, toggle_overlay, }; @@ -55,6 +58,7 @@ namespace input { page_up, page_down, i, + m, f3, }; diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp new file mode 100644 index 0000000..e178a0c --- /dev/null +++ b/src/logging/log_file.cpp @@ -0,0 +1,208 @@ +// class header include +#include "src/logging/log_file.h" + +// standard includes +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +// local includes +#include "src/startup/host_storage.h" + +namespace { + + std::string normalize_directory_component(std::string path) { + while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { + path.pop_back(); + } + return path; + } + + bool is_drive_root_path(const std::string &path) { + return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; + } + + 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { + partialPath = directoryPath.substr(0, 2); + startIndex = 2; + } + + for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { + partialPath.push_back(directoryPath[index]); + const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; + const bool atPathEnd = index + 1 == directoryPath.size(); + if (!atSeparator && !atPathEnd) { + continue; + } + + if (is_drive_root_path(partialPath)) { + continue; + } + + const std::string normalizedPath = normalize_directory_component(partialPath); + if (normalizedPath.empty()) { + continue; + } + + if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { + if (errorMessage != nullptr) { + *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); + } + return false; + } + } + + return true; + } + + std::string parent_directory(const std::string &filePath) { + const std::size_t separatorIndex = filePath.find_last_of("\\/"); + if (separatorIndex == std::string::npos) { + return {}; + } + + return filePath.substr(0, separatorIndex); + } + + 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() { + const std::string hostStoragePath = startup::default_host_storage_path(); + const std::string directoryPath = parent_directory(hostStoragePath); + if (directoryPath.empty()) { + return "moonlight.log"; + } + + return directoryPath + "\\moonlight.log"; + } + + bool reset_log_file(const std::string &filePath, std::string *errorMessage) { + if (!ensure_directory_exists(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 (!ensure_directory_exists(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"; + const std::size_t bytesWritten = std::fwrite(line.data(), 1, line.size(), file); + if (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; + } + + 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)); + }; + + char buffer[1024] = {}; + std::string pendingLine; + while (std::fgets(buffer, static_cast(sizeof(buffer)), file) != nullptr) { + pendingLine += buffer; + const std::size_t pendingLength = pendingLine.size(); + if (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..d281e19 --- /dev/null +++ b/src/logging/log_file.h @@ -0,0 +1,29 @@ +#pragma once + +// standard includes +#include +#include +#include + +// local includes +#include "src/logging/logger.h" + +namespace logging { + + struct LoadLogFileResult { + std::string filePath; + std::vector lines; + bool fileFound = false; + std::string errorMessage; + }; + + std::string default_log_file_path(); + + bool reset_log_file(const std::string &filePath = default_log_file_path(), std::string *errorMessage = nullptr); + + bool append_log_file_entry(const LogEntry &entry, const std::string &filePath = default_log_file_path(), std::string *errorMessage = nullptr); + + 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 index 8773745..41d913c 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -2,14 +2,72 @@ #include "src/logging/logger.h" // standard includes +#include +#include +#include #include +#if defined(_WIN32) +#include +#endif + namespace { bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { return static_cast(candidateLevel) >= static_cast(minimumLevel); } + 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 { @@ -31,6 +89,24 @@ namespace logging { return "UNKNOWN"; } + std::string format_timestamp(const LogTimestamp ×tamp) { + char buffer[32] = {}; + const bool validTimestamp = is_valid_timestamp(timestamp); + std::snprintf( + buffer, + sizeof(buffer), + "%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; + } + std::string format_entry(const LogEntry &entry) { if (entry.category.empty()) { return std::string("[") + to_string(entry.level) + "] " + entry.message; @@ -39,10 +115,11 @@ namespace logging { return std::string("[") + to_string(entry.level) + "] " + entry.category + ": " + entry.message; } - Logger::Logger(std::size_t capacity) + Logger::Logger(std::size_t capacity, TimestampProvider timestampProvider) : capacity_(capacity == 0 ? 1 : capacity), minimumLevel_(LogLevel::info), - nextSequence_(1) {} + nextSequence_(1), + timestampProvider_(timestampProvider ? std::move(timestampProvider) : TimestampProvider(current_local_timestamp)) {} std::size_t Logger::capacity() const { return capacity_; @@ -70,6 +147,7 @@ namespace logging { level, std::move(category), std::move(message), + timestampProvider_(), }; ++nextSequence_; diff --git a/src/logging/logger.h b/src/logging/logger.h index 9b40a92..ec74e6d 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -24,14 +24,26 @@ namespace logging { /** * @brief Structured log entry stored by the in-memory logger. */ + struct LogTimestamp { + int year = 0; + int month = 0; + int day = 0; + int hour = 0; + int minute = 0; + int second = 0; + int millisecond = 0; + }; + struct LogEntry { uint64_t sequence; LogLevel level; std::string category; std::string message; + LogTimestamp timestamp {}; }; using LogSink = std::function; + using TimestampProvider = std::function; /** * @brief Return the display label for a log level. @@ -41,6 +53,14 @@ namespace logging { */ 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 log entry for text consoles or overlays. * @@ -59,7 +79,7 @@ namespace logging { * * @param capacity Maximum number of retained entries. */ - explicit Logger(std::size_t capacity = 256); + explicit Logger(std::size_t capacity = 256, TimestampProvider timestampProvider = {}); /** * @brief Return the maximum number of retained entries. @@ -120,6 +140,7 @@ namespace logging { std::size_t capacity_; LogLevel minimumLevel_; uint64_t nextSequence_; + TimestampProvider timestampProvider_; std::deque entries_; std::vector sinks_; }; diff --git a/src/main.cpp b/src/main.cpp index be8792f..199d6ce 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -3,8 +3,13 @@ #include #include +// standard includes +#include +#include + // local includes #include "src/app/client_state.h" +#include "src/logging/log_file.h" #include "src/logging/logger.h" #include "src/network/runtime_network.h" #include "src/splash/splash_screen.h" @@ -15,6 +20,13 @@ namespace { + struct StartupTaskState { + SDL_Thread *thread = nullptr; + std::atomic completed = false; + startup::LoadSavedHostsResult loadedHosts; + network::RuntimeNetworkStatus runtimeNetworkStatus; + }; + int report_startup_failure(logging::Logger &logger, const char *category, const std::string &message) { logger.log(logging::LogLevel::error, category, message); debugPrint("%s\n", message.c_str()); @@ -23,45 +35,68 @@ namespace { return 1; } + int run_startup_task(void *context) { + StartupTaskState *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, std::memory_order_release); + return 0; + } + + void finish_startup_task(logging::Logger &logger, 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) { + logger.log(logging::LogLevel::warning, "hosts", warning); + } + if (task->loadedHosts.fileFound) { + app::replace_hosts(clientState, task->loadedHosts.hosts, "Loaded " + std::to_string(task->loadedHosts.hosts.size()) + " saved host(s)"); + logger.log(logging::LogLevel::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)) { + logger.log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); + } + if (!task->runtimeNetworkStatus.ready) { + clientState.statusMessage = task->runtimeNetworkStatus.summary; + } + } + } // namespace int main() { logging::Logger logger; - logger.add_sink([](const logging::LogEntry &entry) { - const std::string formattedEntry = logging::format_entry(entry); - debugPrint("%s\n", formattedEntry.c_str()); - }); - app::ClientState clientState = app::create_initial_state(); - - const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); - for (const std::string &warning : loadedHosts.warnings) { - logger.log(logging::LogLevel::warning, "hosts", warning); + const std::string logFilePath = logging::default_log_file_path(); + app::set_log_file_path(clientState, logFilePath); + std::string logResetError; + if (!logging::reset_log_file(logFilePath, &logResetError)) { + debugPrint("Failed to reset runtime log file %s: %s\n", logFilePath.c_str(), logResetError.c_str()); } - if (loadedHosts.fileFound) { - app::replace_hosts(clientState, loadedHosts.hosts, "Loaded " + std::to_string(loadedHosts.hosts.size()) + " saved host(s)"); - logger.log(logging::LogLevel::info, "hosts", "Loaded " + std::to_string(loadedHosts.hosts.size()) + " saved host record(s)"); - } - + logger.add_sink([logFilePath](const logging::LogEntry &entry) { + std::string ignoredError; + logging::append_log_file_entry(entry, logFilePath, &ignoredError); + }); + logger.set_minimum_level(clientState.loggingLevel); logger.log(logging::LogLevel::info, "app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); + logger.log(logging::LogLevel::info, "logging", "Writing runtime logs to " + logFilePath); const startup::VideoModeSelection videoModeSelection = startup::select_best_video_mode(); const VIDEO_MODE &bestVideoMode = videoModeSelection.bestVideoMode; - XVideoSetMode(640, 480, 32, REFRESH_DEFAULT); - - startup::log_video_modes(videoModeSelection); - startup::log_memory_statistics(); - const network::RuntimeNetworkStatus runtimeNetworkStatus = network::initialize_runtime_networking(); - for (const std::string &line : network::format_runtime_network_status_lines(runtimeNetworkStatus)) { - logger.log(runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); - } - if (!runtimeNetworkStatus.ready) { - clientState.statusMessage = runtimeNetworkStatus.summary; - } - - Sleep(3000); - XVideoSetMode(bestVideoMode.width, bestVideoMode.height, bestVideoMode.bpp, bestVideoMode.refresh); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { @@ -82,8 +117,24 @@ int main() { return exitCode; } + StartupTaskState startupTask {}; + startupTask.thread = SDL_CreateThread(run_startup_task, "startup-init", &startupTask); + if (startupTask.thread == nullptr) { + run_startup_task(&startupTask); + } + logger.log(logging::LogLevel::info, "app", "Showing splash screen"); - splash::show_splash_screen(window, bestVideoMode, 2500U); + splash::show_splash_screen(window, bestVideoMode, [&startupTask]() { + return !startupTask.completed.load(std::memory_order_acquire); + }); + + finish_startup_task(logger, clientState, &startupTask); + for (const std::string &line : startup::format_video_mode_lines(videoModeSelection)) { + logger.log(logging::LogLevel::info, "video", line); + } + for (const std::string &line : startup::format_memory_statistics_lines()) { + logger.log(logging::LogLevel::info, "memory", line); + } logger.log(logging::LogLevel::info, "app", "Starting interactive shell"); const int exitCode = ui::run_shell(window, bestVideoMode, clientState, logger); diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 77f7ec5..f888125 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -2,6 +2,7 @@ #include "src/network/host_pairing.h" // standard includes +#include #include #include #include @@ -66,21 +67,11 @@ extern "C" int rand_s(unsigned int *randomValue); namespace { void trace_pairing_phase(const char *message) { -#ifdef NXDK - if (message != nullptr) { - debugPrint("[PAIRING] %s\n", message); - } -#else (void) message; -#endif } void trace_pairing_detail(const std::string &message) { -#ifdef NXDK - debugPrint("[PAIRING] %s\n", message.c_str()); -#else (void) message; -#endif } constexpr std::size_t UNIQUE_ID_BYTE_COUNT = 8; @@ -91,6 +82,7 @@ namespace { 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 from Sunshine."; struct WsaGuard { WsaGuard() @@ -130,6 +122,32 @@ namespace { bool append_error(std::string *errorMessage, std::string message); + 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 void *bytes, std::size_t byteCount) { + if (hash == nullptr || bytes == nullptr) { + return; + } + + const unsigned char *cursor = static_cast(bytes); + for (std::size_t index = 0; index < byteCount; ++index) { + *hash ^= cursor[index]; + *hash *= 1099511628211ULL; + } + } + + void append_hash_string(uint64_t *hash, std::string_view text) { + append_hash_bytes(hash, text.data(), text.size()); + static constexpr unsigned char delimiter = 0x1F; + append_hash_bytes(hash, &delimiter, 1U); + } + int last_socket_error() { #ifdef NXDK return errno; @@ -270,7 +288,8 @@ namespace { const network::PairingIdentity *tlsClientIdentity, std::string_view expectedTlsCertificatePem, HttpResponse *response, - std::string *errorMessage + std::string *errorMessage, + const std::atomic *cancelRequested = nullptr ); std::string summarize_http_payload_preview(std::string_view text) { @@ -333,6 +352,33 @@ namespace { 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); @@ -924,6 +970,599 @@ namespace { 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); + + 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 (nameIndex > 0) { + const char previousCharacter = openTag[nameIndex - 1]; + if (previousCharacter != '<' && !std::isspace(static_cast(previousCharacter))) { + cursor = nameIndex + 1; + continue; + } + } + + std::size_t separatorIndex = nameIndex + attributeName.size(); + while (separatorIndex < openTag.size() && std::isspace(static_cast(openTag[separatorIndex]))) { + ++separatorIndex; + } + if (separatorIndex >= openTag.size() || openTag[separatorIndex] != '=') { + cursor = nameIndex + 1; + continue; + } + + ++separatorIndex; + while (separatorIndex < openTag.size() && std::isspace(static_cast(openTag[separatorIndex]))) { + ++separatorIndex; + } + if (separatorIndex >= openTag.size() || (openTag[separatorIndex] != '"' && openTag[separatorIndex] != '\'')) { + cursor = nameIndex + 1; + continue; + } + + const char quoteCharacter = openTag[separatorIndex]; + const std::size_t valueStart = separatorIndex + 1; + 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()) { + 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; + } + + void skip_json_whitespace(std::string_view json, std::size_t *cursor) { + if (cursor == nullptr) { + return; + } + + while (*cursor < json.size() && std::isspace(static_cast(json[*cursor]))) { + ++(*cursor); + } + } + + bool parse_json_string_literal(std::string_view json, std::size_t *cursor, std::string *value) { + if (cursor == nullptr || *cursor >= json.size() || json[*cursor] != '"') { + return false; + } + + ++(*cursor); + std::string parsedValue; + while (*cursor < json.size()) { + const char character = json[*cursor]; + ++(*cursor); + if (character == '"') { + if (value != nullptr) { + *value = std::move(parsedValue); + } + return true; + } + if (character != '\\') { + parsedValue.push_back(character); + continue; + } + + if (*cursor >= json.size()) { + return false; + } + + const char escaped = json[*cursor]; + ++(*cursor); + switch (escaped) { + case '"': + case '\\': + case '/': + parsedValue.push_back(escaped); + break; + case 'b': + parsedValue.push_back('\b'); + break; + case 'f': + parsedValue.push_back('\f'); + break; + case 'n': + parsedValue.push_back('\n'); + break; + case 'r': + parsedValue.push_back('\r'); + break; + case 't': + parsedValue.push_back('\t'); + break; + case 'u': + if ((*cursor + 4U) > json.size()) { + return false; + } + parsedValue.push_back('?'); + *cursor += 4U; + break; + default: + parsedValue.push_back(escaped); + break; + } + } + + return false; + } + + bool find_matching_json_delimiter(std::string_view json, std::size_t openIndex, char openCharacter, char closeCharacter, std::size_t *closeIndex) { + if (openIndex >= json.size() || json[openIndex] != openCharacter) { + return false; + } + + bool inString = false; + bool escaped = false; + int depth = 0; + for (std::size_t index = openIndex; index < json.size(); ++index) { + const char character = json[index]; + if (inString) { + if (escaped) { + escaped = false; + } + else if (character == '\\') { + escaped = true; + } + else if (character == '"') { + inString = false; + } + continue; + } + + if (character == '"') { + inString = true; + continue; + } + if (character == openCharacter) { + ++depth; + continue; + } + if (character == closeCharacter) { + --depth; + if (depth == 0) { + if (closeIndex != nullptr) { + *closeIndex = index; + } + return true; + } + } + } + + return false; + } + + bool extract_json_named_array(std::string_view json, std::string_view fieldName, std::string_view *arrayView) { + const std::string keyToken = "\"" + std::string(fieldName) + "\""; + std::size_t cursor = 0; + while (cursor < json.size()) { + const std::size_t keyIndex = json.find(keyToken, cursor); + if (keyIndex == std::string_view::npos) { + return false; + } + + std::size_t separatorIndex = keyIndex + keyToken.size(); + skip_json_whitespace(json, &separatorIndex); + if (separatorIndex >= json.size() || json[separatorIndex] != ':') { + cursor = keyIndex + 1; + continue; + } + + ++separatorIndex; + skip_json_whitespace(json, &separatorIndex); + if (separatorIndex >= json.size() || json[separatorIndex] != '[') { + cursor = keyIndex + 1; + continue; + } + + std::size_t arrayEnd = separatorIndex; + if (!find_matching_json_delimiter(json, separatorIndex, '[', ']', &arrayEnd)) { + return false; + } + + if (arrayView != nullptr) { + *arrayView = json.substr(separatorIndex, arrayEnd - separatorIndex + 1U); + } + return true; + } + + return false; + } + + std::vector extract_json_object_blocks(std::string_view arrayView) { + std::vector blocks; + if (arrayView.size() < 2U || arrayView.front() != '[' || arrayView.back() != ']') { + return blocks; + } + + std::size_t cursor = 1; + while (cursor + 1U < arrayView.size()) { + skip_json_whitespace(arrayView, &cursor); + if (cursor + 1U >= arrayView.size()) { + break; + } + if (arrayView[cursor] == ',') { + ++cursor; + continue; + } + if (arrayView[cursor] != '{') { + ++cursor; + continue; + } + + std::size_t objectEnd = cursor; + if (!find_matching_json_delimiter(arrayView, cursor, '{', '}', &objectEnd)) { + break; + } + + blocks.push_back(arrayView.substr(cursor, objectEnd - cursor + 1U)); + cursor = objectEnd + 1U; + } + + return blocks; + } + + bool extract_json_field_value(std::string_view object, std::string_view fieldName, std::string_view *valueView, bool *isStringValue = nullptr) { + if (object.empty() || object.front() != '{') { + return false; + } + + std::size_t cursor = 1; + while (cursor < object.size()) { + skip_json_whitespace(object, &cursor); + if (cursor >= object.size() || object[cursor] == '}') { + return false; + } + + std::size_t keyCursor = cursor; + std::string key; + if (!parse_json_string_literal(object, &keyCursor, &key)) { + return false; + } + + skip_json_whitespace(object, &keyCursor); + if (keyCursor >= object.size() || object[keyCursor] != ':') { + return false; + } + + ++keyCursor; + skip_json_whitespace(object, &keyCursor); + if (keyCursor >= object.size()) { + return false; + } + + const std::size_t valueStart = keyCursor; + std::size_t valueEnd = valueStart; + bool valueIsString = false; + if (object[keyCursor] == '"') { + valueIsString = true; + std::string ignored; + if (!parse_json_string_literal(object, &keyCursor, &ignored)) { + return false; + } + valueEnd = keyCursor; + } + else if (object[keyCursor] == '{') { + if (!find_matching_json_delimiter(object, keyCursor, '{', '}', &valueEnd)) { + return false; + } + keyCursor = valueEnd + 1U; + } + else if (object[keyCursor] == '[') { + if (!find_matching_json_delimiter(object, keyCursor, '[', ']', &valueEnd)) { + return false; + } + keyCursor = valueEnd + 1U; + } + else { + while (keyCursor < object.size() && object[keyCursor] != ',' && object[keyCursor] != '}') { + ++keyCursor; + } + valueEnd = keyCursor; + } + + if (key == fieldName) { + if (valueView != nullptr) { + *valueView = trim_ascii_whitespace(object.substr(valueStart, valueEnd - valueStart)); + } + if (isStringValue != nullptr) { + *isStringValue = valueIsString; + } + return true; + } + + cursor = keyCursor; + if (cursor < object.size() && object[cursor] == ',') { + ++cursor; + } + } + + return false; + } + + bool extract_json_string_like_field(std::string_view object, const std::vector &fieldNames, std::string *value) { + for (std::string_view fieldName : fieldNames) { + std::string_view rawValue; + bool isStringValue = false; + if (!extract_json_field_value(object, fieldName, &rawValue, &isStringValue)) { + continue; + } + + if (isStringValue) { + std::size_t cursor = 0; + std::string parsedValue; + if (parse_json_string_literal(rawValue, &cursor, &parsedValue)) { + if (value != nullptr) { + *value = std::move(parsedValue); + } + return true; + } + } + else if (!rawValue.empty()) { + if (value != nullptr) { + *value = std::string(trim_ascii_whitespace(rawValue)); + } + return true; + } + } + + return false; + } + + bool extract_json_bool_like_field(std::string_view object, const std::vector &fieldNames, bool *value) { + for (std::string_view fieldName : fieldNames) { + std::string text; + if (!extract_json_string_like_field(object, {fieldName}, &text)) { + continue; + } + + if (try_parse_flag(text, value)) { + return true; + } + } + + return false; + } + + 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 parse_json_app_list_response(std::string_view json, std::vector *apps, std::string *errorMessage) { + const std::string_view trimmed = trim_ascii_whitespace(json); + std::string_view appArray; + if (!trimmed.empty() && trimmed.front() == '[') { + appArray = trimmed; + } + else { + for (std::string_view fieldName : {std::string_view("apps"), std::string_view("Applications"), std::string_view("applications"), std::string_view("games"), std::string_view("Games"), std::string_view("applist"), std::string_view("data")}) { + if (extract_json_named_array(trimmed, fieldName, &appArray)) { + break; + } + } + } + + if (appArray.empty()) { + return append_error(errorMessage, "The host applist response did not contain any app entries"); + } + + const std::vector appObjects = extract_json_object_blocks(appArray); + if (appObjects.empty()) { + return append_error(errorMessage, "The host applist response did not contain any app entries"); + } + + std::vector parsedApps; + parsedApps.reserve(appObjects.size()); + for (std::string_view appObject : appObjects) { + std::string name; + std::string idText; + extract_json_string_like_field(appObject, {"AppTitle", "title", "Title", "name", "Name", "displayName", "DisplayName"}, &name); + extract_json_string_like_field(appObject, {"ID", "id", "Id", "appid", "appId", "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; + extract_json_bool_like_field(appObject, {"IsHdrSupported", "HDRSupported", "isHdrSupported", "hdrSupported"}, &hdrSupported); + extract_json_bool_like_field(appObject, {"Hidden", "hidden", "IsHidden", "isHidden"}, &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 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); + + uint32_t statusCode = 200; + if (!statusCodeText.empty() && try_parse_uint32(trim_ascii_whitespace(statusCodeText), &statusCode) && statusCode != 200U) { + const std::string normalizedStatusMessage = statusMessage.empty() + ? "The host returned Sunshine 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); + } + + std::vector extract_xml_element_blocks(std::string_view xml, std::string_view tagName) { + std::vector blocks; + const std::string openPrefix = "<" + std::string(tagName); + const std::string closeTag = ""; + std::size_t cursor = 0; + + while (cursor < xml.size()) { + 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] != '>' && !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::size_t closeIndex = xml.find(closeTag, openEnd + 1); + if (closeIndex == std::string_view::npos) { + break; + } + + blocks.push_back(xml.substr(openEnd + 1, closeIndex - openEnd - 1)); + cursor = closeIndex + closeTag.size(); + } + + return blocks; + } + + 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; @@ -946,10 +1585,35 @@ namespace { return value != 0; } - bool connect_socket(const std::string &address, uint16_t port, SocketGuard *socketGuard, std::string *errorMessage) { + 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 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()) { @@ -986,17 +1650,35 @@ namespace { return append_error(errorMessage, "Failed to connect to the host pairing endpoint at " + address + ":" + std::to_string(port) + " (socket error " + std::to_string(connectError) + ")"); } - fd_set writeSet; - FD_ZERO(&writeSet); - FD_SET(socketGuard->handle, &writeSet); - timeval timeout { - SOCKET_TIMEOUT_MILLISECONDS / 1000, - (SOCKET_TIMEOUT_MILLISECONDS % 1000) * 1000, - }; - trace_pairing_phase("waiting for timed connect completion"); - const int selectResult = select(static_cast(socketGuard->handle) + 1, nullptr, &writeSet, nullptr, &timeout); + constexpr int CONNECT_POLL_INTERVAL_MILLISECONDS = 100; + int remainingWaitMilliseconds = SOCKET_TIMEOUT_MILLISECONDS; + int selectResult = 0; + while (remainingWaitMilliseconds > 0) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + + fd_set writeSet; + FD_ZERO(&writeSet); + FD_SET(socketGuard->handle, &writeSet); + const int waitMilliseconds = std::min(remainingWaitMilliseconds, CONNECT_POLL_INTERVAL_MILLISECONDS); + timeval timeout { + waitMilliseconds / 1000, + (waitMilliseconds % 1000) * 1000, + }; + + selectResult = select(static_cast(socketGuard->handle) + 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)); @@ -1027,12 +1709,16 @@ namespace { return true; } - bool recv_all_plain(SOCKET socketHandle, std::string *response, std::string *errorMessage) { + bool recv_all_plain(SOCKET socketHandle, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { std::string received; char buffer[4096] = {}; std::size_t completeLength = 0; while (true) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + const int bytesRead = recv(socketHandle, buffer, sizeof(buffer), 0); if (bytesRead == 0) { break; @@ -1061,12 +1747,16 @@ namespace { return true; } - bool recv_all_ssl(SSL *ssl, std::string *response, std::string *errorMessage) { + bool recv_all_ssl(SSL *ssl, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { std::string received; char buffer[4096] = {}; std::size_t completeLength = 0; while (true) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + const int bytesRead = SSL_read(ssl, buffer, sizeof(buffer)); if (bytesRead == 0) { break; @@ -1101,9 +1791,13 @@ namespace { return true; } - bool send_all_plain(SOCKET socketHandle, std::string_view request, std::string *errorMessage) { + 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()) + ")"); @@ -1114,9 +1808,13 @@ namespace { return true; } - bool send_all_ssl(SSL *ssl, std::string_view request, std::string *errorMessage) { + 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"); @@ -1162,11 +1860,15 @@ namespace { std::string_view uniqueId, HttpResponse *response, network::HostPairingServerInfo *serverInfo, - std::string *errorMessage + 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); @@ -1177,7 +1879,7 @@ namespace { HttpResponse candidateResponse {}; std::string attemptError; - if (!http_get(address, candidatePort, serverInfoPath, false, nullptr, {}, &candidateResponse, &attemptError)) { + if (!http_get(address, candidatePort, serverInfoPath, false, nullptr, {}, &candidateResponse, &attemptError, cancelRequested)) { attemptFailures.push_back(std::to_string(candidatePort) + ": " + attemptError); continue; } @@ -1219,8 +1921,13 @@ namespace { const network::PairingIdentity *tlsClientIdentity, std::string_view expectedTlsCertificatePem, HttpResponse *response, - std::string *errorMessage + std::string *errorMessage, + const std::atomic *cancelRequested ) { + if (pairing_cancel_requested(cancelRequested)) { + return append_cancelled_pairing_error(errorMessage); + } + trace_pairing_phase("http_get: socket initialization"); WsaGuard wsaGuard; if (!wsaGuard.initialized) { @@ -1229,7 +1936,7 @@ namespace { SocketGuard socketGuard; trace_pairing_phase("http_get: connect_socket"); - if (!connect_socket(address, port, &socketGuard, errorMessage)) { + if (!connect_socket(address, port, &socketGuard, errorMessage, cancelRequested)) { return false; } @@ -1242,11 +1949,15 @@ namespace { std::string rawResponse; if (!useTls) { trace_pairing_phase("http_get: sending plain request"); - if (!send_all_plain(socketGuard.handle, request, errorMessage) || !recv_all_plain(socketGuard.handle, &rawResponse, errorMessage)) { + if (!send_all_plain(socketGuard.handle, request, errorMessage, cancelRequested) || !recv_all_plain(socketGuard.handle, &rawResponse, errorMessage, cancelRequested)) { return false; } } else { + 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; @@ -1261,6 +1972,9 @@ namespace { 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) { @@ -1297,7 +2011,7 @@ namespace { } trace_pairing_phase("http_get: sending TLS request"); - if (!send_all_ssl(ssl.get(), request, errorMessage) || !recv_all_ssl(ssl.get(), &rawResponse, errorMessage)) { + if (!send_all_ssl(ssl.get(), request, errorMessage, cancelRequested) || !recv_all_ssl(ssl.get(), &rawResponse, errorMessage, cancelRequested)) { return false; } } @@ -1488,22 +2202,328 @@ namespace network { 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 parse_json_app_list_response(trimmedResponse, apps, errorMessage); + } + + 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 {}; + std::string authorizationError; + if (!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; } bool query_server_info(const std::string &address, uint16_t preferredHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage) { - return query_server_info_internal(address, preferredHttpPort, DEFAULT_SERVERINFO_UNIQUE_ID, nullptr, serverInfo, 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; + const std::string appListAddress = resolve_reachable_address(address, resolvedServerInfo); + if (!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"); + } + + std::string parseError; + if (!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, &entry.id, sizeof(entry.id)); + append_hash_bytes(&hash, &entry.hdrSupported, sizeof(entry.hdrSupported)); + append_hash_bytes(&hash, &entry.hidden, sizeof(entry.hidden)); + } + return hash; } - HostPairingResult pair_host(const HostPairingRequest &request) { + 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 {}; + std::string attemptError; + if (!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"); HostPairingResult result {false, false, "Pairing failed"}; + auto fail_if_cancelled = [&cancelRequested, &result]() { + if (!pairing_cancel_requested(cancelRequested)) { + return false; + } + + result = {false, false, "Pairing cancelled"}; + trace_pairing_detail(result.message); + return true; + }; auto fail_with_phase = [&result](std::string_view phase, const std::string &message) { result.message = "Pairing failed during " + std::string(phase) + ": " + message; trace_pairing_detail(result.message); @@ -1532,6 +2552,9 @@ namespace network { result.message = "Client pairing identity is missing or invalid"; return result; } + if (fail_if_cancelled()) { + return result; + } std::unique_ptr clientCertificate = load_certificate(request.identity.certificatePem); std::unique_ptr clientPrivateKey = load_private_key(request.identity.privateKeyPem); @@ -1553,9 +2576,12 @@ namespace network { trace_pairing_phase("requesting /serverinfo"); HostPairingServerInfo serverInfo {}; - if (!query_server_info_internal(request.address, httpPort, uniqueId, &response, &serverInfo, &errorMessage)) { + if (!query_server_info_internal(request.address, httpPort, uniqueId, &response, &serverInfo, &errorMessage, cancelRequested)) { return fail_with_phase("serverinfo", errorMessage); } + if (fail_if_cancelled()) { + return result; + } if (serverInfo.paired) { result.success = true; @@ -1569,6 +2595,9 @@ namespace network { result.message = errorMessage; return result; } + if (fail_if_cancelled()) { + return result; + } const std::string saltHex = hex_encode(saltBytes.data(), saltBytes.size()); const std::string certHex = certificate_hex(request.identity.certificatePem); @@ -1578,9 +2607,12 @@ namespace network { } const std::string phase1Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&phrase=getservercert&salt=" + saltHex + "&clientcert=" + certHex; trace_pairing_phase("phase 1 getservercert request"); - if (!http_get(request.address, serverInfo.httpPort, phase1Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + if (!http_get(request.address, serverInfo.httpPort, phase1Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { return fail_with_phase("phase 1 (getservercert)", errorMessage); } + if (fail_if_cancelled()) { + return result; + } if (phaseValue != "1") { result.message = "The host rejected the initial pairing request"; return result; @@ -1602,6 +2634,9 @@ namespace network { if (!derive_aes_key(saltHex, request.pin, serverInfo.serverMajorVersion, &aesKey, &errorMessage)) { return fail_with_phase("phase 1 (derive AES key)", errorMessage); } + if (fail_if_cancelled()) { + return result; + } std::array clientChallengeBytes {}; if (!fill_random_bytes(clientChallengeBytes.data(), clientChallengeBytes.size(), &errorMessage)) { @@ -1618,9 +2653,12 @@ namespace network { } const std::string phase2Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&clientchallenge=" + hex_encode(encryptedClientChallenge.data(), encryptedClientChallenge.size()); trace_pairing_phase("phase 2 clientchallenge request"); - if (!http_get(request.address, serverInfo.httpPort, phase2Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + if (!http_get(request.address, serverInfo.httpPort, phase2Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { return fail_with_phase("phase 2 (client challenge)", errorMessage); } + if (fail_if_cancelled()) { + return result; + } if (phaseValue != "1") { result.message = "The host rejected the client challenge during pairing"; return result; @@ -1673,9 +2711,12 @@ namespace network { } const std::string phase3Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&serverchallengeresp=" + hex_encode(encryptedClientHash.data(), encryptedClientHash.size()); trace_pairing_phase("phase 3 serverchallengeresp request"); - if (!http_get(request.address, serverInfo.httpPort, phase3Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + if (!http_get(request.address, serverInfo.httpPort, phase3Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { return fail_with_phase("phase 3 (server challenge response)", errorMessage); } + if (fail_if_cancelled()) { + return result; + } if (phaseValue != "1") { result.message = "The host rejected the server challenge response during pairing"; return result; @@ -1717,9 +2758,12 @@ namespace network { } const std::string phase4Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&clientpairingsecret=" + hex_encode(clientPairingSecret.data(), clientPairingSecret.size()); trace_pairing_phase("phase 4 clientpairingsecret request"); - if (!http_get(request.address, serverInfo.httpPort, phase4Path, false, nullptr, {}, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + if (!http_get(request.address, serverInfo.httpPort, phase4Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { return fail_with_phase("phase 4 (client pairing secret)", errorMessage); } + if (fail_if_cancelled()) { + return result; + } if (phaseValue != "1") { result.message = "The host rejected the client pairing secret"; return result; @@ -1730,9 +2774,12 @@ namespace network { } const std::string phase5Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&phrase=pairchallenge"; trace_pairing_phase("phase 5 pairchallenge request"); - if (!http_get(request.address, serverInfo.httpsPort, phase5Path, true, &request.identity, plainCertPem, &response, &errorMessage) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { + if (!http_get(request.address, serverInfo.httpsPort, phase5Path, true, &request.identity, plainCertPem, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { return fail_with_phase("phase 5 (pairchallenge)", errorMessage); } + if (fail_if_cancelled()) { + return result; + } if (phaseValue != "1") { result.message = "The host rejected the final encrypted pairing challenge"; return result; diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index a97ecf1..427ae7a 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -1,9 +1,11 @@ #pragma once // standard includes +#include #include #include #include +#include namespace network { @@ -14,10 +16,27 @@ namespace network { }; struct HostPairingServerInfo { - int serverMajorVersion; - uint16_t httpPort; - uint16_t httpsPort; - bool paired; + int serverMajorVersion = 0; + uint16_t httpPort = 0; + uint16_t httpsPort = 0; + bool paired = false; + bool pairingStatusCurrentClientKnown = false; + bool pairingStatusCurrentClient = false; + std::string hostName; + std::string uuid; + std::string activeAddress; + std::string localAddress; + std::string remoteAddress; + std::string ipv6Address; + std::string macAddress; + uint32_t runningGameId = 0; + }; + + struct HostAppEntry { + std::string name; + int id = 0; + bool hdrSupported = false; + bool hidden = false; }; struct HostPairingRequest { @@ -40,8 +59,42 @@ namespace network { bool parse_server_info_response(std::string_view xml, uint16_t fallbackHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage = nullptr); + bool parse_app_list_response(std::string_view xml, std::vector *apps, std::string *errorMessage = nullptr); + + bool error_indicates_unpaired_client(std::string_view errorMessage); + + uint64_t hash_app_list_entries(const std::vector &apps); + + std::string resolve_reachable_address(const std::string &requestedAddress, const HostPairingServerInfo &serverInfo); + + bool query_server_info( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity *clientIdentity, + HostPairingServerInfo *serverInfo, + std::string *errorMessage = nullptr + ); + bool query_server_info(const std::string &address, uint16_t preferredHttpPort, HostPairingServerInfo *serverInfo, std::string *errorMessage = nullptr); - HostPairingResult pair_host(const HostPairingRequest &request); + bool query_app_list( + const std::string &address, + uint16_t preferredHttpPort, + const PairingIdentity *clientIdentity, + std::vector *apps, + HostPairingServerInfo *serverInfo = nullptr, + std::string *errorMessage = nullptr + ); + + bool query_app_asset( + const std::string &address, + uint16_t httpsPort, + const PairingIdentity *clientIdentity, + int appId, + std::vector *assetBytes, + std::string *errorMessage = nullptr + ); + + HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested = nullptr); } // namespace network diff --git a/src/splash/splash_screen.cpp b/src/splash/splash_screen.cpp index 3d56764..ba12ae4 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -200,7 +200,6 @@ namespace { return nullptr; } - debugPrint("Normalized splash asset to format: %s\n", SDL_GetPixelFormatName(normalizedSurface->format->format)); return normalizedSurface; } @@ -227,11 +226,7 @@ namespace { IMG_Quit(); } -} // namespace - -namespace splash { - - void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, unsigned int durationMilliseconds) { + void runSplashScreen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing) { int done = 0; const int imageInitFlags = IMG_INIT_JPG | IMG_INIT_PNG; const int initializedImageFlags = IMG_Init(imageInitFlags); @@ -247,9 +242,7 @@ namespace splash { 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) { @@ -273,7 +266,6 @@ namespace splash { } SDL_Rect logoDestination = createCenteredRect(screenSurface, imageSurface->w, imageSurface->h); - const Uint32 startTicks = SDL_GetTicks(); while (!done) { while (SDL_PollEvent(&event)) { @@ -300,7 +292,7 @@ namespace splash { return; } - if (SDL_GetTicks() - startTicks >= durationMilliseconds) { + if (!keepShowing()) { done = 1; } @@ -310,4 +302,19 @@ namespace splash { 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 3b6a154..c2b173c 100644 --- a/src/splash/splash_screen.h +++ b/src/splash/splash_screen.h @@ -3,10 +3,15 @@ // nxdk includes #include +// standard includes +#include + struct SDL_Window; namespace splash { void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, unsigned int durationMilliseconds = 1500U); + void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing); + } diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp new file mode 100644 index 0000000..90f658e --- /dev/null +++ b/src/startup/cover_art_cache.cpp @@ -0,0 +1,225 @@ +// class header include +#include "src/startup/cover_art_cache.h" + +// standard includes +#include +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +// nxdk includes +#if defined(__has_include) +#if __has_include() +#include +#include +#define MOONLIGHT_HAS_NXDK_XBE 1 +#endif +#if __has_include() +#include +#define MOONLIGHT_HAS_NXDK_MOUNT 1 +#endif +#endif + +namespace { + + std::string normalize_directory_component(std::string path) { + while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { + path.pop_back(); + } + return path; + } + + bool is_drive_root_path(const std::string &path) { + return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; + } + + 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { + partialPath = directoryPath.substr(0, 2); + startIndex = 2; + } + + for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { + partialPath.push_back(directoryPath[index]); + const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; + const bool atPathEnd = index + 1 == directoryPath.size(); + if (!atSeparator && !atPathEnd) { + continue; + } + + if (is_drive_root_path(partialPath)) { + continue; + } + + const std::string normalizedPath = normalize_directory_component(partialPath); + if (normalizedPath.empty()) { + continue; + } + + if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { + if (errorMessage != nullptr) { + *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); + } + return false; + } + } + + return true; + } + + std::string parent_directory(const std::string &filePath) { + const std::size_t separatorIndex = filePath.find_last_of("\\/"); + if (separatorIndex == std::string::npos) { + return {}; + } + + return filePath.substr(0, separatorIndex); + } + + std::string title_scoped_storage_root() { +#ifdef MOONLIGHT_HAS_NXDK_XBE +#ifdef MOONLIGHT_HAS_NXDK_MOUNT + if (!nxIsDriveMounted('E') && !nxMountDrive('E', "\\Device\\Harddisk0\\Partition1\\")) { + return {}; + } +#endif + + char titleIdBuffer[9] = {}; + std::snprintf(titleIdBuffer, sizeof(titleIdBuffer), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); + return std::string("E:\\UDATA\\") + titleIdBuffer + "\\"; +#else + return {}; +#endif + } + + std::string cover_art_cache_path(std::string_view cacheKey, const std::string &cacheRoot) { + return 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() { + const std::string titleScopedRoot = title_scoped_storage_root(); + if (!titleScopedRoot.empty()) { + return titleScopedRoot + "cover-art-cache"; + } + + return {"moonlight-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; + } + + 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 (!ensure_directory_exists(cacheRoot, &errorMessage)) { + return {false, errorMessage}; + } + if (!ensure_directory_exists(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))}; + } + + const std::size_t bytesWritten = std::fwrite(bytes.data(), 1, bytes.size(), file); + if (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..c973d4f --- /dev/null +++ b/src/startup/cover_art_cache.h @@ -0,0 +1,32 @@ +#pragma once + +// standard includes +#include +#include +#include + +namespace startup { + + struct LoadCoverArtResult { + std::vector bytes; + bool fileFound = false; + std::string errorMessage; + }; + + struct SaveCoverArtResult { + bool success = false; + std::string errorMessage; + }; + + std::string default_cover_art_cache_root(); + + std::string build_cover_art_cache_key(std::string_view hostUuid, std::string_view hostAddress, int appId); + + bool cover_art_exists(std::string_view cacheKey, const std::string &cacheRoot = default_cover_art_cache_root()); + + LoadCoverArtResult load_cover_art(std::string_view cacheKey, const std::string &cacheRoot = default_cover_art_cache_root()); + + 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/memory_stats.cpp b/src/startup/memory_stats.cpp index 7d7390a..47d6cd3 100644 --- a/src/startup/memory_stats.cpp +++ b/src/startup/memory_stats.cpp @@ -12,23 +12,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()) { + debugPrint("%s\n", line.c_str()); + } } } // namespace startup diff --git a/src/startup/memory_stats.h b/src/startup/memory_stats.h index 10f3f13..59eb32c 100644 --- a/src/startup/memory_stats.h +++ b/src/startup/memory_stats.h @@ -1,7 +1,13 @@ #pragma once +// standard includes +#include +#include + namespace startup { + std::vector format_memory_statistics_lines(); + 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..1caa8ad --- /dev/null +++ b/src/startup/saved_files.cpp @@ -0,0 +1,262 @@ +// class header include +#include "src/startup/saved_files.h" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include + +// local includes +#include "src/logging/log_file.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 logFilePath; + std::string pairingDirectory; + std::string coverArtCacheRoot; + }; + + bool append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + return false; + } + + std::string join_path(const std::string &left, const std::string &right) { + if (left.empty()) { + return right; + } + if (left.back() == '\\' || left.back() == '/') { + return left + right; + } + return left + "\\" + right; + } + + std::string file_name_from_path(const std::string &path) { + const std::size_t separatorIndex = path.find_last_of("\\/"); + return separatorIndex == std::string::npos ? path : path.substr(separatorIndex + 1U); + } + + 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 (std::tolower(static_cast(path[index])) != std::tolower(static_cast(prefix[index]))) { + return false; + } + } + return true; + } + + 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); + } + + bool try_get_file_size(const std::string &path, std::uint64_t *sizeBytes) { + WIN32_FILE_ATTRIBUTE_DATA fileData {}; + if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &fileData)) { + return false; + } + if (fileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + return false; + } + + if (sizeBytes != nullptr) { + ULARGE_INTEGER sizeValue {}; + sizeValue.HighPart = fileData.nFileSizeHigh; + sizeValue.LowPart = fileData.nFileSizeLow; + *sizeBytes = sizeValue.QuadPart; + } + return true; + } + + ResolvedSavedFileCatalogConfig resolve_config(const startup::SavedFileCatalogConfig &config) { + return { + config.hostStoragePath.empty() ? startup::default_host_storage_path() : config.hostStoragePath, + 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() || 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; + } + + WIN32_FIND_DATAA findData {}; + const std::string searchPattern = join_path(rootPath, "*"); + HANDLE handle = FindFirstFileA(searchPattern.c_str(), &findData); + if (handle == INVALID_HANDLE_VALUE) { + const DWORD errorCode = GetLastError(); + if (errorCode != ERROR_FILE_NOT_FOUND && errorCode != ERROR_PATH_NOT_FOUND) { + if (warnings != nullptr) { + warnings->push_back("Failed to enumerate saved files in '" + rootPath + "': error " + std::to_string(static_cast(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(static_cast(lastError))); + } + } + + bool path_is_managed_saved_file(const std::string &path, const ResolvedSavedFileCatalogConfig &config) { + if (path == config.hostStoragePath || path == config.logFilePath) { + return true; + } + + 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), + }; + if (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.logFilePath, file_name_from_path(resolvedConfig.logFilePath)); + add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_UNIQUE_ID_FILE_NAME), "pairing\\uniqueid.dat"); + add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_CERTIFICATE_FILE_NAME), "pairing\\client.pem"); + add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_PRIVATE_KEY_FILE_NAME), "pairing\\key.pem"); + 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..5a5a3cd --- /dev/null +++ b/src/startup/saved_files.h @@ -0,0 +1,73 @@ +#pragma once + +// standard includes +#include +#include +#include + +namespace startup { + + /** + * @brief Describes one Moonlight-managed file that exists on disk. + */ + struct SavedFileEntry { + std::string path; + std::string displayName; + std::uint64_t sizeBytes = 0; + }; + + /** + * @brief Optional path overrides used to inspect Moonlight-managed files. + */ + struct SavedFileCatalogConfig { + std::string hostStoragePath; + std::string logFilePath; + std::string pairingDirectory; + std::string coverArtCacheRoot; + }; + + /** + * @brief Result of enumerating Moonlight-managed files on disk. + */ + struct ListSavedFilesResult { + std::vector files; + std::vector warnings; + }; + + /** + * @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/video_mode.cpp b/src/startup/video_mode.cpp index 78569a2..467e34a 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -52,14 +52,31 @@ 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)) { + debugPrint("%s\n", line.c_str()); + } } } // namespace startup diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index 772ee5b..d3226ea 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -1,6 +1,7 @@ #pragma once // standard includes +#include #include // nxdk includes @@ -67,6 +68,8 @@ namespace startup { */ VideoModeSelection select_best_video_mode(int bpp = 32, int refresh = REFRESH_DEFAULT); + std::vector format_video_mode_lines(const VideoModeSelection &selection); + /** * @brief Log information about available and selected video modes. * diff --git a/src/ui/menu_model.cpp b/src/ui/menu_model.cpp index ea9c7aa..737d5a8 100644 --- a/src/ui/menu_model.cpp +++ b/src/ui/menu_model.cpp @@ -74,6 +74,7 @@ namespace ui { 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: diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 549e302..6e378d0 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -4,7 +4,13 @@ // standard includes #include #include +#include +#include +#include +#include #include +#include +#include #include // nxdk includes @@ -16,26 +22,34 @@ // local includes #include "src/input/navigation_input.h" +#include "src/logging/log_file.h" #include "src/network/host_pairing.h" #include "src/network/runtime_network.h" #include "src/os.h" +#include "src/startup/cover_art_cache.h" #include "src/startup/client_identity_storage.h" #include "src/startup/host_storage.h" +#include "src/startup/saved_files.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 = 0x1A; - constexpr Uint8 PANEL_GREEN = 0x1F; - constexpr Uint8 PANEL_BLUE = 0x25; - constexpr Uint8 ACCENT_RED = 0x76; - constexpr Uint8 ACCENT_GREEN = 0xB9; - constexpr Uint8 ACCENT_BLUE = 0xFF; + 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; @@ -43,25 +57,422 @@ namespace { constexpr Uint8 MUTED_GREEN = 0xAB; constexpr Uint8 MUTED_BLUE = 0xB5; constexpr Sint16 TRIGGER_PAGE_SCROLL_THRESHOLD = 16000; + constexpr Uint32 CONTEXT_HOLD_MILLISECONDS = 550U; + constexpr Uint32 EXIT_COMBO_HOLD_MILLISECONDS = 900U; + constexpr Uint32 LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS = 110U; + constexpr Uint32 LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS = 45U; + constexpr int MIN_SVG_RASTER_DIMENSION = 256; + 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"; + } + + Uint32 read_surface_pixel(const SDL_Surface *surface, int x, int y) { + const Uint8 *row = static_cast(surface->pixels) + (y * surface->pitch); + Uint32 pixel = 0; + std::memcpy(&pixel, row + (x * static_cast(sizeof(Uint32))), sizeof(Uint32)); + return pixel; + } + + void write_surface_pixel(SDL_Surface *surface, int x, int y, Uint32 pixel) { + Uint8 *row = static_cast(surface->pixels) + (y * surface->pitch); + std::memcpy(row + (x * static_cast(sizeof(Uint32))), &pixel, sizeof(Uint32)); + } + + Uint32 sample_bilinear_pixel(const SDL_Surface *sourceSurface, float sourceX, float sourceY, const SDL_PixelFormat *targetFormat) { + const int x0 = std::clamp(static_cast(std::floor(sourceX)), 0, sourceSurface->w - 1); + const int y0 = std::clamp(static_cast(std::floor(sourceY)), 0, sourceSurface->h - 1); + const int x1 = std::min(x0 + 1, sourceSurface->w - 1); + const int y1 = std::min(y0 + 1, sourceSurface->h - 1); + const float tx = std::clamp(sourceX - static_cast(x0), 0.0f, 1.0f); + const float ty = std::clamp(sourceY - static_cast(y0), 0.0f, 1.0f); + + Uint8 topLeftRed = 0; + Uint8 topLeftGreen = 0; + Uint8 topLeftBlue = 0; + Uint8 topLeftAlpha = 0; + Uint8 topRightRed = 0; + Uint8 topRightGreen = 0; + Uint8 topRightBlue = 0; + Uint8 topRightAlpha = 0; + Uint8 bottomLeftRed = 0; + Uint8 bottomLeftGreen = 0; + Uint8 bottomLeftBlue = 0; + Uint8 bottomLeftAlpha = 0; + Uint8 bottomRightRed = 0; + Uint8 bottomRightGreen = 0; + Uint8 bottomRightBlue = 0; + Uint8 bottomRightAlpha = 0; + + SDL_GetRGBA(read_surface_pixel(sourceSurface, x0, y0), sourceSurface->format, &topLeftRed, &topLeftGreen, &topLeftBlue, &topLeftAlpha); + SDL_GetRGBA(read_surface_pixel(sourceSurface, x1, y0), sourceSurface->format, &topRightRed, &topRightGreen, &topRightBlue, &topRightAlpha); + SDL_GetRGBA(read_surface_pixel(sourceSurface, x0, y1), sourceSurface->format, &bottomLeftRed, &bottomLeftGreen, &bottomLeftBlue, &bottomLeftAlpha); + SDL_GetRGBA(read_surface_pixel(sourceSurface, x1, y1), sourceSurface->format, &bottomRightRed, &bottomRightGreen, &bottomRightBlue, &bottomRightAlpha); + + const float topRed = (static_cast(topLeftRed) * (1.0f - tx)) + (static_cast(topRightRed) * tx); + const float topGreen = (static_cast(topLeftGreen) * (1.0f - tx)) + (static_cast(topRightGreen) * tx); + const float topBlue = (static_cast(topLeftBlue) * (1.0f - tx)) + (static_cast(topRightBlue) * tx); + const float topAlpha = (static_cast(topLeftAlpha) * (1.0f - tx)) + (static_cast(topRightAlpha) * tx); + const float bottomRed = (static_cast(bottomLeftRed) * (1.0f - tx)) + (static_cast(bottomRightRed) * tx); + const float bottomGreen = (static_cast(bottomLeftGreen) * (1.0f - tx)) + (static_cast(bottomRightGreen) * tx); + const float bottomBlue = (static_cast(bottomLeftBlue) * (1.0f - tx)) + (static_cast(bottomRightBlue) * tx); + const float bottomAlpha = (static_cast(bottomLeftAlpha) * (1.0f - tx)) + (static_cast(bottomRightAlpha) * tx); + + return SDL_MapRGBA( + targetFormat, + static_cast((topRed * (1.0f - ty)) + (bottomRed * ty)), + static_cast((topGreen * (1.0f - ty)) + (bottomGreen * ty)), + static_cast((topBlue * (1.0f - ty)) + (bottomBlue * ty)), + static_cast((topAlpha * (1.0f - ty)) + (bottomAlpha * ty)) + ); + } + + 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 *create_scaled_surface_bilinear(SDL_Surface *sourceSurface, int targetWidth, int targetHeight) { + if (sourceSurface == nullptr || targetWidth <= 0 || targetHeight <= 0) { + return nullptr; + } + + SDL_Surface *scaledSurface = SDL_CreateRGBSurfaceWithFormat(0, targetWidth, targetHeight, 32, SDL_PIXELFORMAT_ARGB8888); + if (scaledSurface == nullptr) { + SDL_FreeSurface(sourceSurface); + return nullptr; + } + + if (SDL_LockSurface(sourceSurface) < 0) { + SDL_FreeSurface(sourceSurface); + SDL_FreeSurface(scaledSurface); + return nullptr; + } + + if (SDL_LockSurface(scaledSurface) < 0) { + SDL_UnlockSurface(sourceSurface); + SDL_FreeSurface(sourceSurface); + SDL_FreeSurface(scaledSurface); + return nullptr; + } + + for (int y = 0; y < scaledSurface->h; ++y) { + const float sourceY = ((static_cast(y) + 0.5f) * static_cast(sourceSurface->h) / static_cast(scaledSurface->h)) - 0.5f; + for (int x = 0; x < scaledSurface->w; ++x) { + const float sourceX = ((static_cast(x) + 0.5f) * static_cast(sourceSurface->w) / static_cast(scaledSurface->w)) - 0.5f; + write_surface_pixel(scaledSurface, x, y, sample_bilinear_pixel(sourceSurface, sourceX, sourceY, scaledSurface->format)); + } + } + + SDL_UnlockSurface(scaledSurface); + SDL_UnlockSurface(sourceSurface); + SDL_FreeSurface(sourceSurface); + + if (SDL_SetSurfaceBlendMode(scaledSurface, SDL_BLENDMODE_BLEND) != 0) { + SDL_FreeSurface(scaledSurface); + return nullptr; + } + + return scaledSurface; + } + + SDL_Surface *prepare_asset_surface(SDL_Surface *surface, const char *relativePath) { + SDL_Surface *normalizedSurface = normalize_asset_surface(surface); + if (normalizedSurface == nullptr || !asset_path_uses_svg(relativePath)) { + return normalizedSurface; + } + + const int sourceMaxDimension = std::max(normalizedSurface->w, normalizedSurface->h); + if (sourceMaxDimension <= 0 || sourceMaxDimension >= MIN_SVG_RASTER_DIMENSION) { + return normalizedSurface; + } + + const int targetWidth = std::max(1, (normalizedSurface->w * MIN_SVG_RASTER_DIMENSION) / sourceMaxDimension); + const int targetHeight = std::max(1, (normalizedSurface->h * MIN_SVG_RASTER_DIMENSION) / sourceMaxDimension); + return create_scaled_surface_bilinear(normalizedSurface, targetWidth, targetHeight); + } + int report_shell_failure(logging::Logger &logger, const char *category, const std::string &message) { logger.log(logging::LogLevel::error, category, message); - debugPrint("%s\n", message.c_str()); - debugPrint("Holding failure screen for 5 seconds before exit.\n"); + logger.log(logging::LogLevel::warning, category, "Holding the failure screen for 5 seconds before exit."); Sleep(5000); return 1; } + bool host_matches_endpoint(const app::HostRecord &host, const std::string &address, uint16_t port) { + if (host.address != address) { + return false; + } + + const uint16_t effectivePort = app::effective_host_port(port); + if (app::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 append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + + return false; + } + void destroy_texture(SDL_Texture *texture) { if (texture != nullptr) { SDL_DestroyTexture(texture); } } + struct CoverArtTextureCache { + std::unordered_map textures; + std::unordered_map failedKeys; + }; + + struct AssetTextureCache { + std::unordered_map textures; + std::unordered_map failedKeys; + }; + + 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 &entry : cache->textures) { + destroy_texture(entry.second); + } + cache->textures.clear(); + cache->failedKeys.clear(); + } + + void clear_cover_art_texture(CoverArtTextureCache *cache, const std::string &cacheKey) { + if (cache == nullptr || cacheKey.empty()) { + return; + } + + const auto textureIterator = cache->textures.find(cacheKey); + if (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 &entry : cache->textures) { + destroy_texture(entry.second); + } + cache->textures.clear(); + cache->failedKeys.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) { + const unsigned char 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) { + sequenceLength = 2U; + } + else if ((character & 0xF0U) == 0xE0U) { + sequenceLength = 3U; + } + else if ((character & 0xF8U) == 0xF0U) { + sequenceLength = 4U; + } + + const bool sequenceAvailable = sequenceLength > 0U && index + sequenceLength <= text.size(); + bool sequenceValid = sequenceAvailable; + for (std::size_t continuationIndex = 1U; sequenceValid && continuationIndex < sequenceLength; ++continuationIndex) { + const unsigned char continuation = static_cast(text[index + continuationIndex]); + sequenceValid = (continuation & 0xC0U) == 0x80U; + } + + 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, + 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; + } + + const auto existingTexture = cache->textures.find(cacheKey); + if (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.emplace(cacheKey, texture); + return texture; + } + SDL_Texture *load_texture_from_asset(SDL_Renderer *renderer, const char *relativePath) { if (renderer == nullptr || relativePath == nullptr) { return nullptr; @@ -73,11 +484,39 @@ namespace { return nullptr; } + surface = prepare_asset_surface(surface, relativePath); + if (surface == nullptr) { + return nullptr; + } + SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); SDL_FreeSurface(surface); return texture; } + SDL_Texture *load_cached_asset_texture(SDL_Renderer *renderer, AssetTextureCache *cache, const std::string &relativePath) { + if (renderer == nullptr || cache == nullptr || relativePath.empty()) { + return nullptr; + } + + const auto existingTexture = cache->textures.find(relativePath); + if (existingTexture != cache->textures.end()) { + return existingTexture->second; + } + if (cache->failedKeys.find(relativePath) != cache->failedKeys.end()) { + return nullptr; + } + + SDL_Texture *texture = load_texture_from_asset(renderer, relativePath.c_str()); + if (texture == nullptr) { + cache->failedKeys[relativePath] = true; + return nullptr; + } + + cache->textures.emplace(relativePath, 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); @@ -98,14 +537,22 @@ namespace { int maxWidth, int *drawnHeight = nullptr ) { - if (text.empty()) { + 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, text.c_str(), color, static_cast(maxWidth)); + SDL_Surface *surface = TTF_RenderUTF8_Blended_Wrapped(font, renderText.c_str(), color, static_cast(maxWidth)); if (surface == nullptr) { return false; } @@ -128,22 +575,82 @@ namespace { return renderResult == 0; } - int measure_wrapped_text_height(TTF_Font *font, const std::string &text, int maxWidth) { - if (text.empty()) { - return TTF_FontLineSkip(font); + 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(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( + 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_RenderUTF8_Blended_Wrapped(font, text.c_str(), {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, static_cast(maxWidth)); + SDL_Surface *surface = TTF_RenderText_Blended(font, renderText.c_str(), color); if (surface == nullptr) { - return TTF_FontLineSkip(font); + return false; } - const int height = surface->h; + 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); - return height; + const int renderResult = SDL_RenderCopy(renderer, texture, nullptr, &destination); + destroy_texture(texture); + + if (drawnHeight != nullptr) { + *drawnHeight = destination.h; + } + + return renderResult == 0; } - bool render_text_centered( + bool render_text_centered_simple( SDL_Renderer *renderer, TTF_Font *font, const std::string &text, @@ -151,25 +658,719 @@ namespace { 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_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); + 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(renderer, font, text, color, x, y, rect.w, drawnHeight); + return render_text_line_simple(renderer, font, renderText, color, x, y, rect.w, drawnHeight); } - 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); + 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; + } + + 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); + 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, + 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) { + SDL_SetRenderDrawColor(renderer, red, green, blue, alpha); + SDL_RenderDrawLine(renderer, x1, y1, x2, y2); + } + + void draw_host_icon(SDL_Renderer *renderer, const SDL_Rect &rect, const ui::ShellHostTile &tile) { + const Uint8 iconRed = tile.reachability == app::HostReachability::online ? TEXT_RED : MUTED_RED; + const Uint8 iconGreen = tile.reachability == app::HostReachability::online ? TEXT_GREEN : MUTED_GREEN; + const Uint8 iconBlue = tile.reachability == app::HostReachability::online ? TEXT_BLUE : MUTED_BLUE; + + const int iconWidth = std::min(rect.w, std::max(52, (rect.h * 10) / 9)); + const int iconHeight = std::min(rect.h, std::max(44, (iconWidth * 4) / 5)); + const SDL_Rect iconRect { + rect.x + std::max(0, (rect.w - iconWidth) / 2), + rect.y + std::max(0, (rect.h - iconHeight) / 2), + iconWidth, + iconHeight, + }; + const SDL_Rect monitorRect {iconRect.x + (iconRect.w / 10), iconRect.y + (iconRect.h / 14), (iconRect.w * 4) / 5, (iconRect.h * 3) / 5}; + const SDL_Rect standRect {iconRect.x + (iconRect.w * 7) / 16, monitorRect.y + monitorRect.h + std::max(4, iconRect.h / 20), std::max(6, iconRect.w / 8), std::max(6, iconRect.h / 10)}; + const SDL_Rect baseRect {iconRect.x + (iconRect.w * 5) / 16, standRect.y + standRect.h, iconRect.w / 4, std::max(4, iconRect.h / 18)}; + draw_rect(renderer, monitorRect, iconRed, iconGreen, iconBlue); + draw_rect(renderer, standRect, iconRed, iconGreen, iconBlue); + draw_rect(renderer, baseRect, iconRed, iconGreen, iconBlue); + + const int symbolMargin = std::max(4, std::min(monitorRect.w, monitorRect.h) / 5); + const SDL_Rect symbolRect { + monitorRect.x + symbolMargin, + monitorRect.y + symbolMargin, + std::max(10, monitorRect.w - (symbolMargin * 2)), + std::max(10, monitorRect.h - (symbolMargin * 2)), + }; + if (tile.reachability != app::HostReachability::online) { + const int centerX = symbolRect.x + (symbolRect.w / 2); + const int topY = symbolRect.y; + const int bottomY = symbolRect.y + symbolRect.h; + draw_line(renderer, centerX, topY, symbolRect.x, bottomY, iconRed, iconGreen, iconBlue); + draw_line(renderer, symbolRect.x, bottomY, symbolRect.x + symbolRect.w, bottomY, iconRed, iconGreen, iconBlue); + draw_line(renderer, symbolRect.x + symbolRect.w, bottomY, centerX, topY, iconRed, iconGreen, iconBlue); + fill_rect(renderer, {centerX - 2, symbolRect.y + std::max(4, symbolRect.h / 5), 4, std::max(6, symbolRect.h / 3)}, iconRed, iconGreen, iconBlue); + fill_rect(renderer, {centerX - 2, bottomY - std::max(6, symbolRect.h / 6), 4, 4}, iconRed, iconGreen, iconBlue); + return; + } + + if (tile.pairingState != app::PairingState::paired) { + const int bodyWidth = std::max(10, (symbolRect.w * 3) / 5); + const int bodyHeight = std::max(8, (symbolRect.h * 2) / 5); + const SDL_Rect bodyRect { + symbolRect.x + std::max(0, (symbolRect.w - bodyWidth) / 2), + symbolRect.y + std::max(4, symbolRect.h / 3), + bodyWidth, + bodyHeight, + }; + const SDL_Rect shackleRect { + bodyRect.x + std::max(1, bodyRect.w / 8), + symbolRect.y + 2, + std::max(8, (bodyRect.w * 3) / 4), + std::max(8, symbolRect.h / 2), + }; + draw_rect(renderer, shackleRect, iconRed, iconGreen, iconBlue); + draw_rect(renderer, bodyRect, iconRed, iconGreen, iconBlue); + return; + } + + draw_line(renderer, symbolRect.x + std::max(2, symbolRect.w / 10), symbolRect.y + (symbolRect.h / 2), symbolRect.x + (symbolRect.w / 2) - 1, symbolRect.y + symbolRect.h - std::max(2, symbolRect.h / 8), iconRed, iconGreen, iconBlue); + draw_line(renderer, symbolRect.x + (symbolRect.w / 2) - 1, symbolRect.y + symbolRect.h - std::max(2, symbolRect.h / 8), symbolRect.x + symbolRect.w - std::max(2, symbolRect.w / 10), symbolRect.y + std::max(2, symbolRect.h / 10), iconRed, iconGreen, iconBlue); + } + + bool render_app_cover( + SDL_Renderer *renderer, + TTF_Font *labelFont, + const ui::ShellAppTile &tile, + const SDL_Rect &rect, + CoverArtTextureCache *textureCache, + AssetTextureCache *assetCache + ) { + fill_rect(renderer, rect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xFF); + SDL_Texture *texture = tile.boxArtCached ? load_cover_art_texture(renderer, textureCache, tile.boxArtCacheKey) : nullptr; + if (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.logViewerPlacement) { + 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}; + } + + 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( + 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.modalTitle, {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.logViewerPath, {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}; + 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}, + }; + if (!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); + + struct LogViewerLayout { + std::vector visibleLines; + std::size_t firstVisibleIndex = 0U; + }; + + const std::size_t maxOffset = viewModel.logViewerLines.size() > 1U ? viewModel.logViewerLines.size() - 1U : 0U; + const std::size_t clampedOffset = std::min(viewModel.logViewerScrollOffset, maxOffset); + auto build_log_viewer_layout = [&](int availableWidth) { + LogViewerLayout layout {}; + if (viewModel.logViewerLines.empty()) { + layout.visibleLines.push_back(nullptr); + return layout; + } + + int usedHeight = 0; + std::size_t endIndex = viewModel.logViewerLines.size() > clampedOffset ? viewModel.logViewerLines.size() - clampedOffset : 0U; + layout.firstVisibleIndex = endIndex; + while (endIndex > 0U) { + const std::string renderedLine = truncate_text_for_render(viewModel.logViewerLines[endIndex - 1U], LOG_VIEWER_MAX_RENDER_CHARACTERS); + const int lineHeight = measure_wrapped_text_height(smallFont, renderedLine, std::max(1, availableWidth - 12)) + 4; + if (!layout.visibleLines.empty() && usedHeight + lineHeight > contentRect.h - 8) { + break; + } + layout.visibleLines.push_back(&viewModel.logViewerLines[endIndex - 1U]); + usedHeight += lineHeight; + --endIndex; + } + layout.firstVisibleIndex = endIndex; + std::reverse(layout.visibleLines.begin(), layout.visibleLines.end()); + return layout; + }; + + constexpr int logViewerScrollbarWidth = 10; + constexpr int logViewerScrollbarGap = 12; + LogViewerLayout logViewerLayout = build_log_viewer_layout(contentRect.w); + const bool overflow = !viewModel.logViewerLines.empty() && viewModel.logViewerLines.size() > logViewerLayout.visibleLines.size(); + if (overflow) { + logViewerLayout = build_log_viewer_layout(std::max(1, contentRect.w - logViewerScrollbarWidth - logViewerScrollbarGap)); + } + + const SDL_Rect textRect { + contentRect.x, + contentRect.y, + std::max(1, contentRect.w - (overflow ? logViewerScrollbarWidth + logViewerScrollbarGap : 0)), + contentRect.h, + }; + int contentCursorY = textRect.y + 6; + const bool olderLinesAvailable = logViewerLayout.firstVisibleIndex > 0U; + if (olderLinesAvailable) { + 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 (logViewerLayout.visibleLines.size() == 1U && logViewerLayout.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 : logViewerLayout.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.logViewerScrollOffset > 0U) { + if (!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 false; + } + } + + if (overflow) { + render_vertical_scrollbar( + renderer, + {contentRect.x + contentRect.w - logViewerScrollbarWidth, contentRect.y, logViewerScrollbarWidth, contentRect.h}, + static_cast(viewModel.logViewerLines.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 std::size_t 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; + const SDL_Color color = row.enabled + ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} + : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; + if (!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}; + const bool renderedIcon = !button.iconAssetPath.empty() && render_asset_icon(renderer, assetCache, button.iconAssetPath, iconRect); + if (!renderedIcon) { + if (!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)); + } + + 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) { + int labelWidth = 0; + int labelHeight = 0; + if (TTF_SizeUTF8(font, action.label.c_str(), &labelWidth, &labelHeight) != 0) { + labelWidth = static_cast(action.label.size()) * 8; + } + + const int iconSize = (action.iconAssetPath.empty() && action.secondaryIconAssetPath.empty()) ? 0 : std::max(18, chipHeight - 14); + const int iconCount = (action.iconAssetPath.empty() ? 0 : 1) + (action.secondaryIconAssetPath.empty() ? 0 : 1); + const int iconBlockWidth = iconCount == 0 ? 0 : (iconSize * iconCount) + ((iconCount - 1) * 4); + const int chipWidth = 18 + iconBlockWidth + (iconBlockWidth > 0 ? 8 : 0) + labelWidth + 18; + if (cursorX + chipWidth > availableRight) { + break; + } + + const SDL_Rect chipRect {cursorX, chipY, chipWidth, chipHeight}; + + int contentX = chipRect.x + 10; + if (iconSize > 0) { + if (!action.iconAssetPath.empty()) { + const SDL_Rect iconRect {contentX, chipRect.y + (chipRect.h - iconSize) / 2, iconSize, iconSize}; + render_asset_icon(renderer, assetCache, action.iconAssetPath, iconRect); + contentX += iconSize + 4; + } + if (!action.secondaryIconAssetPath.empty()) { + const SDL_Rect iconRect {contentX, chipRect.y + (chipRect.h - iconSize) / 2, iconSize, iconSize}; + render_asset_icon(renderer, assetCache, action.secondaryIconAssetPath, iconRect); + contentX += iconSize + 4; + } + contentX += 4; + } + + if (!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)) { + return false; + } + + cursorX += chipWidth + 12; + } + + return true; + } + + bool render_notification( + 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 int 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: @@ -193,221 +1394,1022 @@ namespace { } } - input::UiCommand translate_keyboard_key(SDL_Keycode key, Uint16 modifiers) { - const bool shiftPressed = (modifiers & KMOD_SHIFT) != 0; + 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; + } + + void log_app_update(logging::Logger &logger, const app::ClientState &state, const app::AppUpdate &update) { + if (!update.activatedItemId.empty()) { + logger.log(logging::LogLevel::info, "ui", "Activated menu item: " + update.activatedItemId); + } + if (update.screenChanged) { + logger.log(logging::LogLevel::info, "ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); + } + if (update.overlayVisibilityChanged) { + logger.log(logging::LogLevel::info, "overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + } + if (update.exitRequested) { + logger.log(logging::LogLevel::info, "app", "Exit requested from shell"); + } + } + + bool persist_hosts(logging::Logger &logger, app::ClientState &state) { + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(state.hosts); + if (saveResult.success) { + state.hostsDirty = false; + logger.log(logging::LogLevel::info, "hosts", "Saved host records"); + return true; + } + + logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); + return false; + } + + void persist_hosts_if_needed(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + if (!update.hostsChanged) { + return; + } + + persist_hosts(logger, state); + } + + void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { + for (app::HostRecord &host : state.hosts) { + if (!host_matches_endpoint(host, address, port)) { + continue; + } + + 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; + if (serverInfo.pairingStatusCurrentClientKnown) { + const app::PairingState resolvedPairingState = serverInfo.pairingStatusCurrentClient ? app::PairingState::paired : app::PairingState::not_paired; + persistedMetadataChanged = persistedMetadataChanged || host.pairingState != resolvedPairingState; + host.pairingState = resolvedPairingState; + if (!serverInfo.pairingStatusCurrentClient) { + host.apps.clear(); + host.appListState = app::HostAppListState::failed; + host.appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again from Sunshine."; + host.appListContentHash = 0; + host.lastAppListRefreshTick = 0; + state.selectedAppIndex = 0U; + if (state.activeScreen == app::ScreenId::apps && state.selectedHostIndex < state.hosts.size() && &host == &state.hosts[state.selectedHostIndex]) { + state.statusMessage = host.appListStatusMessage; + } + } + } + state.hostsDirty = state.hostsDirty || persistedMetadataChanged; + break; + } + } + + std::string display_name_for_saved_file(const app::ClientState &state, const std::string &path) { + for (const startup::SavedFileEntry &savedFile : state.savedFiles) { + if (savedFile.path == path) { + return savedFile.displayName; + } + } + return path; + } + + std::string cover_art_cache_key_from_path(const std::string &path) { + const std::string coverArtRoot = startup::default_cover_art_cache_root(); + if (coverArtRoot.empty() || path.size() <= coverArtRoot.size() || path.rfind(coverArtRoot, 0U) != 0U) { + return {}; + } + + const std::size_t fileNameStart = path.find_last_of("\\/"); + if (fileNameStart == std::string::npos || fileNameStart + 1U >= path.size()) { + return {}; + } + + const std::string fileName = path.substr(fileNameStart + 1U); + 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, const std::string &cacheKey) { + if (cacheKey.empty()) { + return; + } + + for (app::HostRecord &host : state.hosts) { + for (app::HostAppRecord &appRecord : host.apps) { + if (appRecord.boxArtCacheKey == cacheKey) { + appRecord.boxArtCached = false; + } + } + } + } + + void refresh_saved_files_if_needed(logging::Logger &logger, app::ClientState &state) { + if (state.activeScreen != app::ScreenId::settings || !state.savedFilesDirty) { + return; + } + + const startup::ListSavedFilesResult savedFiles = startup::list_saved_files(); + for (const std::string &warning : savedFiles.warnings) { + logger.log(logging::LogLevel::warning, "storage", warning); + } + app::replace_saved_files(state, savedFiles.files); + } + + void delete_saved_file_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + if (!update.savedFileDeleteRequested) { + return; + } + + std::string errorMessage; + if (!startup::delete_saved_file(update.savedFileDeletePath, &errorMessage)) { + state.statusMessage = errorMessage; + logger.log(logging::LogLevel::warning, "storage", errorMessage); + return; + } + + const std::string deletedDisplayName = display_name_for_saved_file(state, update.savedFileDeletePath); + const std::string deletedCoverArtCacheKey = cover_art_cache_key_from_path(update.savedFileDeletePath); + clear_deleted_cover_art_flag(state, deletedCoverArtCacheKey); + clear_cover_art_texture(coverArtTextureCache, deletedCoverArtCacheKey); + state.savedFilesDirty = true; + state.statusMessage = "Deleted saved file " + deletedDisplayName; + logger.log(logging::LogLevel::info, "storage", state.statusMessage); + } + + void factory_reset_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + if (!update.factoryResetRequested) { + return; + } + + std::string errorMessage; + if (!startup::delete_all_saved_files(&errorMessage)) { + state.statusMessage = errorMessage; + logger.log(logging::LogLevel::warning, "storage", errorMessage); + return; + } + + state.hosts.clear(); + state = app::create_initial_state(); + state.savedFiles.clear(); + state.savedFilesDirty = true; + state.statusMessage = "Factory reset completed"; + clear_cover_art_texture_cache(coverArtTextureCache); + app::set_log_file_path(state, logging::default_log_file_path()); + logger.log(logging::LogLevel::info, "storage", state.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 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 {}; + std::string errorMessage; + if (!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 TargetHost { + std::string address; + uint16_t port = 0; + }; + + struct ProbeResult { + std::string address; + uint16_t port = 0; + bool success = false; + network::HostPairingServerInfo serverInfo; + }; + + SDL_Thread *thread = nullptr; + std::atomic completed = false; + std::vector targets; + std::vector results; + }; + + 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) { + return task.activeAttempt != nullptr && 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(std::memory_order_acquire); + } + + void finalize_pairing_attempt(logging::Logger &logger, 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) { + logger.log(entry.level, "pairing", entry.message); + } + + if (discardResult || state == nullptr) { + logger.log(logging::LogLevel::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 + ); + + logger.log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); + if (hostsChanged) { + persist_hosts(logger, *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, std::memory_order_release); + task->retiredAttempts.push_back(std::move(task->activeAttempt)); + } + + void reap_retired_pairing_attempts(logging::Logger &logger, 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(logger, 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->thread = nullptr; + task->completed.store(false); + task->targets.clear(); + task->results.clear(); + } + + bool host_probe_task_is_active(const HostProbeTaskState &task) { + return task.thread != nullptr && !task.completed.load(); + } + + int run_pairing_task(void *context) { + PairingAttemptState *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, std::memory_order_release); + return 0; + } + + const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity); + if (!saveResult.success) { + task->result = {false, false, saveResult.errorMessage}; + task->completed.store(true, std::memory_order_release); + 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, std::memory_order_release); + return 0; + } + + void finish_pairing_task_if_ready(logging::Logger &logger, app::ClientState &state, PairingTaskState *task) { + if (task == nullptr) { + return; + } - 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_F3: - return input::map_keyboard_key_to_ui_command(input::KeyboardKey::f3, shiftPressed); - default: - return input::UiCommand::none; + reap_retired_pairing_attempts(logger, task); + if (!pairing_attempt_is_ready(task->activeAttempt.get())) { + return; } + + finalize_pairing_attempt(logger, &state, std::move(task->activeAttempt)); } - input::UiCommand translate_trigger_axis(const SDL_ControllerAxisEvent &event, bool *leftTriggerPressed, bool *rightTriggerPressed) { - if (leftTriggerPressed == nullptr || rightTriggerPressed == nullptr) { - return input::UiCommand::none; + void cancel_pairing_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + if (task == nullptr || !update.pairingCancelledRequested || task->activeAttempt == nullptr || task->activeAttempt->thread == nullptr) { + return; } - const bool thresholdCrossed = event.value >= TRIGGER_PAGE_SCROLL_THRESHOLD; + task->activeAttempt->discardResult.store(true); + task->activeAttempt->cancelRequested.store(true, std::memory_order_release); + retire_active_pairing_attempt(task, true); + state.statusMessage.clear(); + logger.log(logging::LogLevel::info, "pairing", "Cancelled the in-flight pairing attempt after leaving the pairing screen"); + } - switch (event.axis) { - case SDL_CONTROLLER_AXIS_TRIGGERLEFT: - if (thresholdCrossed && !*leftTriggerPressed) { - *leftTriggerPressed = true; - return input::UiCommand::previous_page; - } - if (!thresholdCrossed) { - *leftTriggerPressed = false; - } - break; - case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: - if (thresholdCrossed && !*rightTriggerPressed) { - *rightTriggerPressed = true; - return input::UiCommand::next_page; + void test_host_connection_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + if (!update.connectionTestRequested) { + return; + } + + const std::string address = update.connectionTestAddress; + const uint16_t port = update.connectionTestPort == 0 ? app::DEFAULT_HOST_PORT : update.connectionTestPort; + + if (address.empty()) { + app::apply_connection_test_result(state, false, "Connection test failed because the host address is invalid"); + logger.log(logging::LogLevel::warning, "hosts", state.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) { + if (host.address == address && app::effective_host_port(host.port) == port) { + host.reachability = app::HostReachability::offline; + host.manualAddress = address; + break; } - if (!thresholdCrossed) { - *rightTriggerPressed = false; + } + } + app::apply_connection_test_result(state, success, resultMessage); + logger.log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); + if (state.hostsDirty) { + persist_hosts(logger, state); + } + } + + void browse_host_apps_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + if (!update.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) { + if (mutableHost.address == address && app::effective_host_port(mutableHost.port) == app::effective_host_port(port)) { + mutableHost.reachability = app::HostReachability::offline; + mutableHost.manualAddress = address; + break; } - break; - default: - break; + } + state.statusMessage = resultMessage; + logger.log(logging::LogLevel::warning, "apps", resultMessage); + return; } - return input::UiCommand::none; - } + apply_server_info_to_host(state, address, port, serverInfo); + if (state.hostsDirty) { + persist_hosts(logger, state); + } - void log_app_update(logging::Logger &logger, const app::ClientState &state, const app::AppUpdate &update) { - if (!update.activatedItemId.empty()) { - logger.log(logging::LogLevel::info, "ui", "Activated menu item: " + update.activatedItemId); + host = app::selected_host(state); + if (host == nullptr || host->pairingState != app::PairingState::paired) { + state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() + ? host->appListStatusMessage + : "This host is no longer paired. Pair it again from Sunshine before opening apps."; + logger.log(logging::LogLevel::warning, "apps", state.statusMessage); + return; } - if (update.screenChanged) { - logger.log(logging::LogLevel::info, "ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); + + if (app::begin_selected_host_app_browse(state, update.appsBrowseShowHidden)) { + logger.log(logging::LogLevel::info, "apps", "Authorized host browse for " + host->displayName); + return; } - if (update.overlayVisibilityChanged) { - logger.log(logging::LogLevel::info, "overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + + logger.log(logging::LogLevel::warning, "apps", state.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); + } + + int run_host_probe_task(void *context) { + HostProbeTaskState *task = static_cast(context); + if (task == nullptr) { + return -1; } - if (update.exitRequested) { - logger.log(logging::LogLevel::info, "app", "Exit requested from shell"); + + network::PairingIdentity clientIdentity {}; + const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; + + task->results.clear(); + task->results.reserve(task->targets.size()); + for (const HostProbeTaskState::TargetHost &target : task->targets) { + HostProbeTaskState::ProbeResult result {}; + result.address = target.address; + result.port = target.port; + result.success = test_tcp_host_connection(target.address, target.port, clientIdentityPointer, nullptr, &result.serverInfo); + task->results.push_back(std::move(result)); } + + task->completed.store(true, std::memory_order_release); + return 0; } - bool persist_hosts(logging::Logger &logger, app::ClientState &state) { - const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(state.hosts); - if (saveResult.success) { - state.hostsDirty = false; - logger.log(logging::LogLevel::info, "hosts", "Saved host records"); - return true; + void finish_host_probe_task_if_ready(logging::Logger &logger, app::ClientState &state, HostProbeTaskState *task) { + if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { + return; } - logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); - return false; + SDL_Thread *thread = task->thread; + task->thread = nullptr; + int threadResult = 0; + SDL_WaitThread(thread, &threadResult); + (void) threadResult; + + const std::vector results = task->results; + reset_host_probe_task(task); + + std::size_t onlineCount = 0; + std::size_t offlineCount = 0; + bool metadataChanged = false; + for (const HostProbeTaskState::ProbeResult &result : results) { + if (result.success) { + apply_server_info_to_host(state, result.address, result.port, result.serverInfo); + metadataChanged = metadataChanged || state.hostsDirty; + ++onlineCount; + continue; + } + + for (app::HostRecord &host : state.hosts) { + if (host.address == result.address && app::effective_host_port(host.port) == app::effective_host_port(result.port)) { + host.reachability = app::HostReachability::offline; + host.manualAddress = result.address; + ++offlineCount; + break; + } + } + } + + logger.log(logging::LogLevel::info, "hosts", "Refreshed " + std::to_string(results.size()) + " saved host(s): " + std::to_string(onlineCount) + " online, " + std::to_string(offlineCount) + " offline"); + if (metadataChanged) { + persist_hosts(logger, state); + } } - void persist_hosts_if_needed(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { - if (!update.hostsChanged) { + void start_host_probe_task_if_needed(logging::Logger &logger, const app::ClientState &state, HostProbeTaskState *task, Uint32 now, Uint32 *nextHostProbeTick) { + if (task == nullptr || host_probe_task_is_active(*task) || state.activeScreen != app::ScreenId::hosts || !network::runtime_network_ready()) { + return; + } + if (nextHostProbeTick != nullptr && *nextHostProbeTick != 0U && now < *nextHostProbeTick) { return; } - persist_hosts(logger, state); + reset_host_probe_task(task); + for (const app::HostRecord &host : state.hosts) { + task->targets.push_back({host.address, app::effective_host_port(host.port)}); + } + if (task->targets.empty()) { + return; + } + + task->thread = SDL_CreateThread(run_host_probe_task, "probe-saved-hosts", task); + if (task->thread == nullptr) { + logger.log(logging::LogLevel::error, "hosts", std::string("Failed to start the saved-host refresh task: ") + SDL_GetError()); + reset_host_probe_task(task); + return; + } + + if (nextHostProbeTick != nullptr) { + *nextHostProbeTick = now + HOST_PROBE_REFRESH_INTERVAL_MILLISECONDS; + } } - bool test_tcp_host_connection(const std::string &address, uint16_t port, std::string *message) { - if (!network::runtime_network_ready()) { - if (message != nullptr) { - *message = network::runtime_network_status().summary; + void pair_host_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + if (!update.pairingRequested || task == nullptr) { + return; + } + + finish_pairing_task_if_ready(logger, state, task); + + if (pairing_task_is_active(*task)) { + retire_active_pairing_attempt(task, true); + logger.log(logging::LogLevel::info, "pairing", "Discarded the previous background pairing attempt and started a fresh one"); + } + + std::string reachabilityMessage; + network::HostPairingServerInfo serverInfo {}; + network::PairingIdentity clientIdentity {}; + const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; + if (!test_tcp_host_connection(update.pairingAddress, update.pairingPort, clientIdentityPointer, &reachabilityMessage, &serverInfo)) { + for (app::HostRecord &host : state.hosts) { + if (host.address == update.pairingAddress && app::effective_host_port(host.port) == app::effective_host_port(update.pairingPort)) { + host.reachability = app::HostReachability::offline; + host.manualAddress = update.pairingAddress; + break; + } } - return false; + state.pairingDraft.stage = app::PairingStage::failed; + state.pairingDraft.generatedPin.clear(); + state.pairingDraft.statusMessage = reachabilityMessage.empty() ? "The host could not be reached for pairing." : reachabilityMessage; + state.statusMessage = state.pairingDraft.statusMessage; + logger.log(logging::LogLevel::warning, "pairing", state.pairingDraft.statusMessage); + return; + } + + apply_server_info_to_host(state, update.pairingAddress, update.pairingPort, serverInfo); + if (state.hostsDirty) { + persist_hosts(logger, state); + } + + std::unique_ptr attempt = std::make_unique(); + reset_pairing_attempt(attempt.get()); + attempt->request = { + update.pairingAddress, + update.pairingPort, + update.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.pairingAddress, update.pairingPort, false, createThreadError); + state.pairingDraft.generatedPin.clear(); + logger.log(logging::LogLevel::error, "pairing", createThreadError); + return; + } + + task->activeAttempt = std::move(attempt); + + state.pairingDraft.stage = app::PairingStage::in_progress; + state.pairingDraft.statusMessage = "The host is reachable. If Sunshine prompts for a PIN, enter the code shown below and keep this screen open for the result."; + state.statusMessage.clear(); + logger.log(logging::LogLevel::info, "pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); + } + + int run_app_list_task(void *context) { + AppListTaskState *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, std::memory_order_release); + 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 Sunshine app list" : errorMessage; + task->completed.store(true, std::memory_order_release); + 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() + ? "Sunshine returned no launchable apps for this host" + : "Loaded " + std::to_string(task->apps.size()) + " Sunshine app(s)"; + task->completed.store(true, std::memory_order_release); + return 0; + } + + void finish_app_list_task_if_ready(logging::Logger &logger, app::ClientState &state, AppListTaskState *task) { + if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { + 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); } - network::HostPairingServerInfo serverInfo {}; - std::string errorMessage; - if (!network::query_server_info(address, port, &serverInfo, &errorMessage)) { - if (message != nullptr) { - *message = std::move(errorMessage); + if (success) { + app::apply_app_list_result(state, address, port, std::move(apps), appListContentHash, true, message); + logger.log(logging::LogLevel::info, "apps", "Fetched Sunshine app list from " + address + ":" + std::to_string(serverInfo.httpPort)); + if (state.hostsDirty) { + persist_hosts(logger, state); } - return false; + return; } - if (message != nullptr) { - *message = "Received /serverinfo from " + address + ":" + std::to_string(serverInfo.httpPort) - + " and discovered HTTPS pairing on port " + std::to_string(serverInfo.httpsPort); - } - return true; + app::apply_app_list_result(state, address, port, {}, 0, false, message); + logger.log(logging::LogLevel::warning, "apps", message); } - struct PairingTaskState { - SDL_Thread *thread; - std::atomic completed; - std::atomic discardResult; - network::HostPairingRequest request; - network::HostPairingResult result; - struct DeferredLogEntry { - logging::LogLevel level; - std::string message; - }; - std::vector deferredLogs; - }; + void start_app_list_task_if_needed(logging::Logger &logger, app::ClientState &state, AppListTaskState *task, Uint32 now) { + if (task == nullptr || app_list_task_is_active(*task) || state.activeScreen != app::ScreenId::apps) { + return; + } - void reset_pairing_task(PairingTaskState *task) { - if (task == nullptr) { + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr || host->pairingState != app::PairingState::paired || host->reachability == app::HostReachability::offline) { return; } - task->thread = nullptr; - task->completed.store(false); - task->discardResult.store(false); - task->request = {}; - task->result = {false, false, {}}; - task->deferredLogs.clear(); - } + if (host->appListState != app::HostAppListState::loading) { + if (host->lastAppListRefreshTick != 0U && now - host->lastAppListRefreshTick < APP_LIST_REFRESH_INTERVAL_MILLISECONDS) { + return; + } - bool pairing_task_is_active(const PairingTaskState &task) { - return task.thread != nullptr && !task.completed.load(); + if (state.selectedHostIndex < state.hosts.size()) { + app::HostRecord &mutableHost = state.hosts[state.selectedHostIndex]; + mutableHost.appListState = app::HostAppListState::loading; + mutableHost.appListStatusMessage = (mutableHost.apps.empty() ? "Loading apps for " : "Refreshing apps for ") + mutableHost.displayName + "..."; + state.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(); + logger.log(logging::LogLevel::error, "apps", errorMessage); + if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { + state.hosts[state.selectedHostIndex].appListState = app::HostAppListState::failed; + state.hosts[state.selectedHostIndex].appListStatusMessage = errorMessage; + state.statusMessage = errorMessage; + } + reset_app_list_task(task); + return; + } + + if (state.selectedHostIndex < state.hosts.size()) { + state.hosts[state.selectedHostIndex].lastAppListRefreshTick = now; + } } - int run_pairing_task(void *context) { - PairingTaskState *task = static_cast(context); + int run_app_art_task(void *context) { + AppArtTaskState *task = static_cast(context); if (task == nullptr) { return -1; } - debugPrint("[PAIRING] worker entered\n"); - - const startup::LoadClientIdentityResult loadedIdentity = startup::load_client_identity(); - debugPrint("[PAIRING] client identity load completed\n"); - for (const std::string &warning : loadedIdentity.warnings) { - task->deferredLogs.push_back({logging::LogLevel::warning, warning}); + network::PairingIdentity clientIdentity {}; + std::string identityError; + if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + task->failureCount = task->apps.size(); + task->completed.store(true, std::memory_order_release); + return 0; } - 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."}); + for (const app::HostAppRecord &appRecord : task->apps) { + if (appRecord.boxArtCached || appRecord.boxArtCacheKey.empty()) { + continue; } - std::string identityError; - debugPrint("[PAIRING] generating client identity\n"); - 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, std::memory_order_release); - return 0; + std::vector assetBytes; + std::string errorMessage; + if (!network::query_app_asset(task->address, task->port, &clientIdentity, appRecord.id, &assetBytes, &errorMessage)) { + ++task->failureCount; + continue; } - const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity); - debugPrint("[PAIRING] client identity save completed\n"); + const startup::SaveCoverArtResult saveResult = startup::save_cover_art(appRecord.boxArtCacheKey, assetBytes); if (!saveResult.success) { - task->result = {false, false, saveResult.errorMessage}; - task->completed.store(true, std::memory_order_release); - return 0; + ++task->failureCount; + continue; } - task->deferredLogs.push_back({logging::LogLevel::info, "Saved pairing identity"}); + task->cachedAppIds.push_back(appRecord.id); } - task->request.identity = std::move(identity); - debugPrint("[PAIRING] invoking network::pair_host\n"); - task->result = network::pair_host(task->request); - debugPrint("[PAIRING] network::pair_host returned\n"); task->completed.store(true, std::memory_order_release); return 0; } - void finish_pairing_task_if_ready(logging::Logger &logger, app::ClientState &state, PairingTaskState *task) { + void finish_app_art_task_if_ready(logging::Logger &logger, app::ClientState &state, AppArtTaskState *task, CoverArtTextureCache *textureCache) { if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { return; } @@ -418,101 +2420,95 @@ namespace { SDL_WaitThread(thread, &threadResult); (void) threadResult; - const network::HostPairingRequest request = task->request; - const network::HostPairingResult result = task->result; - const bool discardResult = task->discardResult.load(); - const std::vector deferredLogs = task->deferredLogs; - reset_pairing_task(task); + 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 (const PairingTaskState::DeferredLogEntry &entry : deferredLogs) { - logger.log(entry.level, "pairing", entry.message); + for (int appId : cachedAppIds) { + app::mark_cover_art_cached(state, address, port, appId); } - if (discardResult) { - logger.log(logging::LogLevel::info, "pairing", "Ignored a completed pairing result after leaving the pairing screen"); - return; + if (textureCache != nullptr) { + textureCache->failedKeys.clear(); } - const bool hostsChanged = app::apply_pairing_result( - state, - request.address, - request.httpPort, - result.success || result.alreadyPaired, - result.message - ); - - logger.log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); - if (hostsChanged) { - persist_hosts(logger, state); + if (!cachedAppIds.empty()) { + logger.log(logging::LogLevel::info, "apps", "Cached cover art for " + std::to_string(cachedAppIds.size()) + " app(s)"); + } + if (failureCount > 0U) { + logger.log(logging::LogLevel::warning, "apps", std::to_string(failureCount) + " app artwork request(s) fell back to placeholders"); } } - void ignore_pairing_result_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { - if (task == nullptr || update.activatedItemId != "cancel-pairing" || task->thread == nullptr) { + void start_app_art_task_if_needed(logging::Logger &logger, const app::ClientState &state, AppArtTaskState *task) { + if (task == nullptr || app_art_task_is_active(*task) || state.activeScreen != app::ScreenId::apps) { return; } - task->discardResult.store(true); - state.statusMessage = "Left the pairing screen. The active network attempt will finish in the background and its result will be ignored."; - logger.log(logging::LogLevel::info, "pairing", state.statusMessage); - } - - void test_host_connection_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { - if (!update.connectionTestRequested) { + const app::HostRecord *host = app::apps_host(state); + if (host == nullptr || host->appListState != app::HostAppListState::ready || host->apps.empty()) { return; } - const std::string address = update.connectionTestAddress; - const uint16_t port = update.connectionTestPort == 0 ? app::DEFAULT_HOST_PORT : update.connectionTestPort; - - if (address.empty()) { - app::apply_connection_test_result(state, false, "Connection test failed because the host address is invalid"); - logger.log(logging::LogLevel::warning, "hosts", state.statusMessage); + const bool missingArt = std::any_of(host->apps.begin(), host->apps.end(), [](const app::HostAppRecord &appRecord) { + return !appRecord.boxArtCached && !appRecord.boxArtCacheKey.empty(); + }); + if (!missingArt) { return; } - std::string resultMessage; - const bool success = test_tcp_host_connection(address, port, &resultMessage); - app::apply_connection_test_result(state, success, resultMessage); - logger.log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); + 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) { + logger.log(logging::LogLevel::error, "apps", std::string("Failed to start the cover-art fetch task: ") + SDL_GetError()); + reset_app_art_task(task); + } } - void pair_host_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { - if (!update.pairingRequested || task == nullptr) { + void show_log_file_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + if (!update.logViewRequested) { return; } - if (pairing_task_is_active(*task)) { - const std::string busyMessage = "A pairing attempt is already running in the background"; - app::apply_pairing_result(state, update.pairingAddress, update.pairingPort, false, busyMessage); - logger.log(logging::LogLevel::warning, "pairing", busyMessage); + const std::string filePath = state.logFilePath.empty() ? logging::default_log_file_path() : state.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); + logger.log(logging::LogLevel::warning, "logging", loadedLog.errorMessage); return; } - reset_pairing_task(task); - task->request = { - update.pairingAddress, - update.pairingPort, - update.pairingPin, - "MoonlightXboxOG", - {}, - }; - - task->thread = SDL_CreateThreadWithStackSize(run_pairing_task, "pair-host", PAIRING_THREAD_STACK_SIZE, task); - if (task->thread == nullptr) { - reset_pairing_task(task); - const std::string createThreadError = std::string("Failed to start the background pairing task: ") + SDL_GetError(); - app::apply_pairing_result(state, update.pairingAddress, update.pairingPort, false, createThreadError); - logger.log(logging::LogLevel::error, "pairing", createThreadError); - 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."}; } - state.pairingDraft.statusMessage = "Pairing is preparing the client identity and contacting the host in the background. Enter the PIN on the host if prompted and keep this screen open for the result."; - state.statusMessage = state.pairingDraft.statusMessage; - logger.log(logging::LogLevel::info, "pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); + 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); + logger.log(logging::LogLevel::info, "logging", statusMessage + ": " + loadedLog.filePath); } - bool draw_shell(SDL_Renderer *renderer, SDL_Texture *titleLogoTexture, TTF_Font *titleFont, TTF_Font *bodyFont, TTF_Font *smallFont, const ui::ShellViewModel &viewModel) { + bool draw_shell( + SDL_Renderer *renderer, + SDL_Texture *titleLogoTexture, + TTF_Font *titleFont, + TTF_Font *bodyFont, + TTF_Font *smallFont, + const ui::ShellViewModel &viewModel, + CoverArtTextureCache *textureCache, + AssetTextureCache *assetCache + ) { int screenWidth = 0; int screenHeight = 0; if (SDL_GetRendererOutputSize(renderer, &screenWidth, &screenHeight) != 0 || screenWidth <= 0 || screenHeight <= 0) { @@ -521,28 +2517,26 @@ namespace { const int outerMargin = std::max(18, screenHeight / 24); const int panelGap = std::max(14, screenWidth / 48); - const int menuPanelWidth = std::max(228, (screenWidth * 34) / 100); - const SDL_Rect menuPanel {outerMargin, outerMargin, menuPanelWidth, screenHeight - (outerMargin * 2)}; - const SDL_Rect contentPanel { - outerMargin + menuPanelWidth + panelGap, - outerMargin, - screenWidth - ((outerMargin * 2) + menuPanelWidth + panelGap), - screenHeight - (outerMargin * 2) - }; + 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; } - fill_rect(renderer, menuPanel, PANEL_RED, PANEL_GREEN, PANEL_BLUE); - fill_rect(renderer, contentPanel, PANEL_RED, PANEL_GREEN, PANEL_BLUE); - draw_rect(renderer, menuPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); - draw_rect(renderer, contentPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + 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 = menuPanel.x + 16; - const int titleTextY = menuPanel.y + 12; - int titleTextWidth = menuPanel.w - 32; + int titleTextX = headerRect.x + 16; + const int titleTextY = headerRect.y + 12; + int titleTextWidth = headerRect.w - 32; if (titleLogoTexture != nullptr) { int logoWidth = 0; @@ -551,8 +2545,8 @@ namespace { const int targetLogoHeight = std::max(32, TTF_FontLineSkip(titleFont)); const int targetLogoWidth = std::max(32, (logoWidth * targetLogoHeight) / logoHeight); const SDL_Rect logoRect { - menuPanel.x + 16, - menuPanel.y + 10, + headerRect.x + 16, + headerRect.y + 10, targetLogoWidth, targetLogoHeight, }; @@ -561,7 +2555,7 @@ namespace { } titleTextX = logoRect.x + logoRect.w + 12; - titleTextWidth = menuPanel.x + menuPanel.w - 16 - titleTextX; + titleTextWidth = (headerRect.w / 3) - (titleTextX - headerRect.x); } } @@ -569,40 +2563,212 @@ namespace { return false; } - int menuY = menuPanel.y + std::max(70, screenHeight / 6); - for (const ui::ShellMenuRow &row : viewModel.menuRows) { - const SDL_Rect rowRect {menuPanel.x + 12, menuY - 6, menuPanel.w - 24, std::max(36, screenHeight / 13)}; - 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 int pageTitleX = headerRect.x + (headerRect.w / 3); + const int pageTitleY = headerRect.y + 18; + const bool renderedPageTitle = viewModel.screen == app::ScreenId::apps + ? render_text_line_simple(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) + : render_text_line(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); + if (!viewModel.pageTitle.empty() && !renderedPageTitle) { + return false; + } + + if (viewModel.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.toolbarButtons.size())); + for (const ui::ShellToolbarButton &button : viewModel.toolbarButtons) { + const SDL_Rect buttonRect {buttonX, headerRect.y + 18, buttonWidth, buttonHeight}; + if (!render_toolbar_button(renderer, bodyFont, smallFont, assetCache, button, buttonRect)) { + return false; + } + buttonX += buttonWidth + 12; } + } - const SDL_Color rowColor = row.enabled - ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} - : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; - if (!render_text_line(renderer, bodyFont, row.label, rowColor, menuPanel.x + 24, menuY, menuPanel.w - 48)) { - return false; + int infoY = contentRect.y + 16; + if (viewModel.screen == app::ScreenId::hosts) { + for (const std::string &line : viewModel.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.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.hostColumnCount)); + const int tileGap = 16; + const SDL_Rect gridRect {contentRect.x + 16, gridTop, contentRect.w - 32, gridHeight}; + const GridViewport viewport = calculate_grid_viewport(viewModel.hostTiles.size(), viewModel.hostColumnCount, selected_host_tile_index(viewModel.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.hostColumnCount; + const std::size_t endIndex = std::min(viewModel.hostTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.hostColumnCount); + for (std::size_t index = startIndex; index < endIndex; ++index) { + const int row = static_cast(index / viewModel.hostColumnCount) - viewport.startRow; + const int column = static_cast(index % viewModel.hostColumnCount); + const SDL_Rect tileRect { + gridRect.x + (column * (tileWidth + tileGap)), + gridRect.y + (row * (tileHeight + tileGap)), + tileWidth, + tileHeight, + }; + const ui::ShellHostTile &tile = viewModel.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); + } + const SDL_Rect nameRect { + tileRect.x + 8, + tileRect.y + tileRect.h - statusHeight - nameHeight - 10, + tileRect.w - 16, + nameHeight, + }; + if (!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; + } } - menuY += rowRect.h + 4; + if (viewport.scrollbarWidth > 0) { + render_grid_scrollbar(renderer, {gridRect.x + gridRect.w - viewport.scrollbarWidth, gridRect.y, viewport.scrollbarWidth, gridRect.h}, viewport); + } } + else if (viewModel.screen == app::ScreenId::apps) { + const SDL_Rect gridRect { + contentRect.x + 16, + contentRect.y + 16, + contentRect.w - 32, + contentRect.h - 28, + }; + - int bodyY = contentPanel.y + 16; - for (const std::string &line : viewModel.bodyLines) { - int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentPanel.x + 20, bodyY, contentPanel.w - 40, &drawnHeight)) { + if (!viewModel.appTiles.empty()) { + const int columnCount = std::max(1, static_cast(viewModel.appColumnCount)); + const int tileGap = 16; + const int gridPadding = 10; + const GridViewport viewport = calculate_grid_viewport(viewModel.appTiles.size(), viewModel.appColumnCount, selected_app_tile_index(viewModel.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.appColumnCount; + const std::size_t endIndex = std::min(viewModel.appTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.appColumnCount); + for (std::size_t index = startIndex; index < endIndex; ++index) { + const int row = static_cast(index / viewModel.appColumnCount) - viewport.startRow; + const int column = static_cast(index % viewModel.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, + }; + const ui::ShellAppTile &tile = viewModel.appTiles[index]; + if (!render_app_cover(renderer, smallFont, tile, 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 + ); + } + } + else if (!viewModel.bodyLines.empty()) { + const int lineGap = 8; + int textHeight = 0; + for (std::size_t index = 0; index < viewModel.bodyLines.size(); ++index) { + textHeight += measure_wrapped_text_height(smallFont, viewModel.bodyLines[index], gridRect.w - 48); + if (index + 1U < viewModel.bodyLines.size()) { + textHeight += lineGap; + } + } + + int messageY = gridRect.y + std::max(16, (gridRect.h - textHeight) / 2); + for (const std::string &line : viewModel.bodyLines) { + int drawnHeight = 0; + if (!render_text_line(renderer, smallFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, gridRect.x + 24, messageY, gridRect.w - 48, &drawnHeight)) { + return false; + } + messageY += drawnHeight + lineGap; + } + } + } + else { + const int menuPanelWidth = std::max(228, (contentRect.w * 34) / 100); + const SDL_Rect menuPanel {contentRect.x, contentRect.y, menuPanelWidth, contentRect.h}; + const SDL_Rect bodyPanel {contentRect.x + menuPanelWidth + panelGap, contentRect.y, contentRect.w - menuPanelWidth - panelGap, contentRect.h}; + fill_rect(renderer, menuPanel, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xA0); + fill_rect(renderer, bodyPanel, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0x60); + draw_rect(renderer, menuPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + draw_rect(renderer, bodyPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); + if (!render_action_rows(renderer, bodyFont, viewModel.menuRows, {menuPanel.x + 12, menuPanel.y + 18, menuPanel.w - 24, menuPanel.h - 36}, std::max(36, screenHeight / 13))) { return false; } - bodyY += drawnHeight + 10; + int bodyY = bodyPanel.y + 16; + if (viewModel.screen == app::ScreenId::settings && !viewModel.selectedMenuRowLabel.empty()) { + int selectedLabelHeight = 0; + if (!render_text_line(renderer, bodyFont, "Selected: " + viewModel.selectedMenuRowLabel, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, bodyPanel.x + 16, bodyY, bodyPanel.w - 32, &selectedLabelHeight)) { + return false; + } + bodyY += selectedLabelHeight + 12; + } + if (viewModel.screen == app::ScreenId::settings && !viewModel.detailMenuRows.empty()) { + const int detailMenuHeight = std::min(std::max(88, bodyPanel.h / 3), std::max(88, 54 * static_cast(viewModel.detailMenuRows.size()))); + const SDL_Rect detailMenuRect {bodyPanel.x + 16, bodyY, bodyPanel.w - 32, detailMenuHeight}; + fill_rect(renderer, detailMenuRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xA8); + draw_rect(renderer, detailMenuRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD0); + if (!render_action_rows(renderer, bodyFont, viewModel.detailMenuRows, {detailMenuRect.x + 10, detailMenuRect.y + 10, detailMenuRect.w - 20, detailMenuRect.h - 20}, std::max(34, TTF_FontLineSkip(bodyFont) + 12))) { + return false; + } + bodyY = detailMenuRect.y + detailMenuRect.h + 16; + } + for (const std::string &line : viewModel.bodyLines) { + int drawnHeight = 0; + if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + 16, bodyY, bodyPanel.w - 32, &drawnHeight)) { + return false; + } + bodyY += drawnHeight + 8; + } + } + + if (!render_footer_actions(renderer, smallFont, assetCache, viewModel.footerActions, footerRect)) { + return false; } - int footerY = contentPanel.y + contentPanel.h - std::max(104, screenHeight / 5); - for (const std::string &line : viewModel.footerLines) { - int drawnHeight = 0; - if (!render_text_line(renderer, smallFont, line, {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, contentPanel.x + 20, footerY, contentPanel.w - 40, &drawnHeight)) { + if (viewModel.notificationVisible && !viewModel.notification.message.empty()) { + if (!render_notification(renderer, bodyFont, smallFont, assetCache, viewModel.notification, screenWidth, footerRect.y, outerMargin)) { return false; } - footerY += drawnHeight + 4; } if (viewModel.overlayVisible) { @@ -631,6 +2797,60 @@ namespace { } } + if (viewModel.modalVisible && viewModel.logViewerVisible) { + 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.modalVisible) { + 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.modalTitle, {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.modalLines) { + 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.modalActions.empty()) { + if (!render_action_rows(renderer, bodyFont, viewModel.modalActions, {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.modalFooterActions.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.modalFooterActions, modalFooterRect)) { + return false; + } + } + } + if (viewModel.keypadModalVisible) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); const SDL_Rect scrimRect {0, 0, screenWidth, screenHeight}; @@ -730,12 +2950,18 @@ namespace ui { IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG); + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "1"); + SDL_Renderer *renderer = SDL_CreateRenderer(window, -1, 0); if (renderer == nullptr) { IMG_Quit(); TTF_Quit(); return report_shell_failure(logger, "sdl", std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); } + SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); + SDL_SetRenderDrawColor(renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); + SDL_RenderClear(renderer); + SDL_RenderPresent(renderer); const std::string fontPath = build_asset_path("fonts\\vegur-regular.ttf"); TTF_Font *titleFont = TTF_OpenFont(fontPath.c_str(), std::max(24, videoMode.height / 16)); @@ -773,12 +2999,118 @@ namespace ui { bool running = true; bool leftTriggerPressed = false; bool rightTriggerPressed = false; + bool leftShoulderPressed = false; + bool rightShoulderPressed = false; + bool controllerAPressed = false; + bool controllerAContextTriggered = false; + bool controllerStartPressed = false; + bool controllerBackPressed = false; + bool controllerExitComboArmed = false; + bool controllerExitComboTriggered = false; + Uint32 controllerADownTick = 0; + Uint32 controllerStartDownTick = 0; + Uint32 controllerBackDownTick = 0; + Uint32 nextHostProbeTick = 0; + Uint32 leftShoulderRepeatTick = 0; + Uint32 rightShoulderRepeatTick = 0; + Uint32 leftTriggerRepeatTick = 0; + Uint32 rightTriggerRepeatTick = 0; PairingTaskState pairingTask {}; + AppListTaskState appListTask {}; + AppArtTaskState appArtTask {}; + HostProbeTaskState hostProbeTask {}; + CoverArtTextureCache coverArtTextureCache {}; + AssetTextureCache assetTextureCache {}; reset_pairing_task(&pairingTask); + reset_app_list_task(&appListTask); + reset_app_art_task(&appArtTask); + reset_host_probe_task(&hostProbeTask); + logger.set_minimum_level(state.loggingLevel); logger.log(logging::LogLevel::info, "app", "Entered interactive shell"); + const auto draw_current_shell = [&]() { + const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); + if (draw_shell(renderer, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { + return true; + } + + report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); + running = false; + state.shouldExit = true; + return false; + }; + + const auto process_command = [&](input::UiCommand command) { + if (command == input::UiCommand::none) { + return; + } + + const app::AppUpdate update = app::handle_command(state, command); + logger.set_minimum_level(state.loggingLevel); + log_app_update(logger, state, update); + if (update.screenChanged && !draw_current_shell()) { + return; + } + show_log_file_if_requested(logger, state, update); + cancel_pairing_if_requested(logger, state, update, &pairingTask); + test_host_connection_if_requested(logger, state, update); + browse_host_apps_if_requested(logger, state, update); + pair_host_if_requested(logger, state, update, &pairingTask); + delete_saved_file_if_requested(logger, state, update, &coverArtTextureCache); + factory_reset_if_requested(logger, state, update, &coverArtTextureCache); + refresh_saved_files_if_needed(logger, state); + persist_hosts_if_needed(logger, state, update); + }; + while (running && !state.shouldExit) { finish_pairing_task_if_ready(logger, state, &pairingTask); + finish_app_list_task_if_ready(logger, state, &appListTask); + finish_app_art_task_if_ready(logger, state, &appArtTask, &coverArtTextureCache); + finish_host_probe_task_if_ready(logger, state, &hostProbeTask); + refresh_saved_files_if_needed(logger, state); + start_host_probe_task_if_needed(logger, state, &hostProbeTask, SDL_GetTicks(), &nextHostProbeTick); + start_app_list_task_if_needed(logger, state, &appListTask, SDL_GetTicks()); + start_app_art_task_if_needed(logger, state, &appArtTask); + + if ( + !controllerExitComboTriggered + && controllerStartPressed + && controllerBackPressed + && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) + ) { + controllerExitComboArmed = true; + const Uint32 comboStartTick = controllerStartDownTick > controllerBackDownTick ? controllerStartDownTick : controllerBackDownTick; + if (SDL_GetTicks() - comboStartTick >= EXIT_COMBO_HOLD_MILLISECONDS) { + controllerExitComboTriggered = true; + state.shouldExit = true; + logger.log(logging::LogLevel::info, "app", "Exit requested from held Start+Back on the hosts screen"); + } + } + + if (controllerAPressed && !controllerAContextTriggered && SDL_GetTicks() - controllerADownTick >= CONTEXT_HOLD_MILLISECONDS) { + controllerAContextTriggered = true; + process_command(input::UiCommand::open_context_menu); + } + + if (state.modal.id == app::ModalId::log_viewer) { + const Uint32 now = SDL_GetTicks(); + if (leftShoulderPressed && now - leftShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { + leftShoulderRepeatTick = now; + process_command(input::UiCommand::previous_page); + } + if (rightShoulderPressed && now - rightShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { + rightShoulderRepeatTick = now; + process_command(input::UiCommand::next_page); + } + if (leftTriggerPressed && now - leftTriggerRepeatTick >= LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS) { + leftTriggerRepeatTick = now; + process_command(input::UiCommand::fast_previous_page); + } + if (rightTriggerPressed && now - rightTriggerRepeatTick >= LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS) { + rightTriggerRepeatTick = now; + process_command(input::UiCommand::fast_next_page); + } + } SDL_Event event; while (SDL_PollEvent(&event)) { @@ -802,14 +3134,99 @@ namespace ui { controller = nullptr; leftTriggerPressed = false; rightTriggerPressed = false; + leftShoulderPressed = false; + rightShoulderPressed = false; + controllerAPressed = false; + controllerAContextTriggered = false; + controllerStartPressed = false; + controllerBackPressed = false; + controllerExitComboArmed = false; + controllerExitComboTriggered = false; logger.log(logging::LogLevel::warning, "input", "Controller disconnected"); } break; case SDL_CONTROLLERBUTTONDOWN: - command = translate_controller_button(event.cbutton.button); + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A) { + if (!controllerAPressed) { + controllerAPressed = true; + controllerAContextTriggered = false; + controllerADownTick = SDL_GetTicks(); + } + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START) { + if (!controllerStartPressed) { + controllerStartPressed = true; + controllerStartDownTick = SDL_GetTicks(); + } + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) { + if (!controllerBackPressed) { + controllerBackPressed = true; + controllerBackDownTick = SDL_GetTicks(); + } + } + else { + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + leftShoulderPressed = true; + leftShoulderRepeatTick = SDL_GetTicks(); + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + rightShoulderPressed = true; + rightShoulderRepeatTick = SDL_GetTicks(); + } + command = translate_controller_button(event.cbutton.button); + } + if ( + controllerStartPressed + && controllerBackPressed + && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) + ) { + controllerExitComboArmed = true; + } + break; + case SDL_CONTROLLERBUTTONUP: + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A && controllerAPressed) { + controllerAPressed = false; + if (!controllerAContextTriggered) { + command = input::UiCommand::activate; + } + controllerAContextTriggered = false; + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START && controllerStartPressed) { + controllerStartPressed = false; + if (!controllerExitComboArmed && !controllerExitComboTriggered) { + command = input::map_gamepad_button_to_ui_command(input::GamepadButton::start); + } + if (!controllerStartPressed && !controllerBackPressed) { + controllerExitComboArmed = false; + controllerExitComboTriggered = false; + } + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK && controllerBackPressed) { + controllerBackPressed = false; + if (!controllerExitComboArmed && !controllerExitComboTriggered) { + command = input::map_gamepad_button_to_ui_command(input::GamepadButton::back); + } + if (!controllerStartPressed && !controllerBackPressed) { + controllerExitComboArmed = false; + controllerExitComboTriggered = false; + } + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + leftShoulderPressed = false; + } + else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + rightShoulderPressed = false; + } break; case SDL_CONTROLLERAXISMOTION: command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); + if (command == input::UiCommand::fast_previous_page) { + leftTriggerRepeatTick = SDL_GetTicks(); + } + else if (command == input::UiCommand::fast_next_page) { + rightTriggerRepeatTick = SDL_GetTicks(); + } break; case SDL_KEYDOWN: if (event.key.repeat == 0) { @@ -820,20 +3237,20 @@ namespace ui { break; } - if (command != input::UiCommand::none) { - const app::AppUpdate update = app::handle_command(state, command); - log_app_update(logger, state, update); - ignore_pairing_result_if_requested(logger, state, update, &pairingTask); - test_host_connection_if_requested(logger, state, update); - pair_host_if_requested(logger, state, update, &pairingTask); - persist_hosts_if_needed(logger, state, update); - } + process_command(command); } finish_pairing_task_if_ready(logger, state, &pairingTask); + finish_app_list_task_if_ready(logger, state, &appListTask); + finish_app_art_task_if_ready(logger, state, &appArtTask, &coverArtTextureCache); + finish_host_probe_task_if_ready(logger, state, &hostProbeTask); + const Uint32 backgroundTaskTick = SDL_GetTicks(); + start_host_probe_task_if_needed(logger, state, &hostProbeTask, backgroundTaskTick, &nextHostProbeTick); + start_app_list_task_if_needed(logger, state, &appListTask, backgroundTaskTick); + start_app_art_task_if_needed(logger, state, &appArtTask); const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); - if (!draw_shell(renderer, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel)) { + if (!draw_shell(renderer, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); running = false; break; @@ -842,14 +3259,37 @@ namespace ui { SDL_Delay(16); } - if (pairingTask.thread != nullptr) { - pairingTask.discardResult.store(true); + if (pairingTask.activeAttempt != nullptr) { + pairingTask.activeAttempt->discardResult.store(true); + finalize_pairing_attempt(logger, nullptr, std::move(pairingTask.activeAttempt)); + } + while (!pairingTask.retiredAttempts.empty()) { + std::unique_ptr attempt = std::move(pairingTask.retiredAttempts.back()); + pairingTask.retiredAttempts.pop_back(); + if (attempt != nullptr) { + attempt->discardResult.store(true); + } + finalize_pairing_attempt(logger, nullptr, std::move(attempt)); + } + if (appListTask.thread != nullptr) { + int threadResult = 0; + SDL_WaitThread(appListTask.thread, &threadResult); + (void) threadResult; + } + if (appArtTask.thread != nullptr) { + int threadResult = 0; + SDL_WaitThread(appArtTask.thread, &threadResult); + (void) threadResult; + } + if (hostProbeTask.thread != nullptr) { int threadResult = 0; - SDL_WaitThread(pairingTask.thread, &threadResult); + SDL_WaitThread(hostProbeTask.thread, &threadResult); (void) threadResult; } close_controller(controller); + clear_cover_art_texture_cache(&coverArtTextureCache); + clear_asset_texture_cache(&assetTextureCache); destroy_texture(titleLogoTexture); TTF_CloseFont(smallFont); TTF_CloseFont(bodyFont); diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index d9ed427..fe3d06f 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -4,186 +4,513 @@ // standard includes #include #include +#include namespace { - std::string screen_title(app::ScreenId screen) { - switch (screen) { - case app::ScreenId::home: - return "Xbox"; - case app::ScreenId::hosts: - return "Hosts"; - case app::ScreenId::add_host: - return "Add Host"; - case app::ScreenId::pair_host: - return "Pair Host"; - case app::ScreenId::settings: - return "Settings"; - } - - return "Xbox"; - } - - 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 (47984)" - : state.addHostDraft.keypad.stagedInput; - } - - return state.addHostDraft.activeField == app::AddHostField::address - ? state.addHostDraft.addressInput - : (state.addHostDraft.portInput.empty() ? "default (47984)" : state.addHostDraft.portInput); - } - - 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.push_back({ - 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 to choose a key, A to enter it, X to delete, Start to accept, and B to cancel.", - }; - - 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."); - } - - return lines; - } - - std::vector screen_body_lines(const app::ClientState &state) { - switch (state.activeScreen) { - case app::ScreenId::home: - { - std::vector lines = { - "Hello, Moonlight!", - "Saved hosts available: " + std::to_string(state.hosts.size()), - }; - - if (!state.statusMessage.empty()) { - lines.push_back("Status: " + state.statusMessage); - } - - return lines; - } - case app::ScreenId::hosts: - { - std::vector lines; - if (state.hosts.empty()) { - lines = { - "No saved hosts yet.", - "Select Add Host to create a manual IPv4 entry.", - "Discovery and real host-driven pairing will build on this saved-host list next.", - }; - } - else { - lines.push_back("Saved hosts: " + std::to_string(state.hosts.size())); - if (const app::HostRecord *host = app::selected_host(state); host != nullptr) { - lines.push_back("Selected host: " + host->displayName); - lines.push_back("Address: " + host->address); - lines.push_back("Port: " + std::to_string(app::effective_host_port(host->port))); - lines.push_back(std::string("Pairing: ") + (host->pairingState == app::PairingState::paired ? "Paired" : "Not paired yet")); - lines.emplace_back(host->pairingState == app::PairingState::paired - ? "This host is already paired." - : "Select Pair Selected Host to start the pairing handshake in the background."); - } - else { - lines.emplace_back("Select a saved host to inspect its address and pairing state."); - } - } - - if (!state.statusMessage.empty()) { - lines.push_back("Status: " + state.statusMessage); - } - - return lines; - } - case app::ScreenId::add_host: - { - std::vector lines = { - "Manual host entry.", - std::string("Current address: ") + app::current_add_host_address(state), - std::string("Current port: ") + std::to_string(app::current_add_host_port(state)), - std::string("Selected field: ") + active_add_host_field_label(state), - }; - - if (!state.addHostDraft.validationMessage.empty()) { - lines.push_back("Validation: " + state.addHostDraft.validationMessage); - } - - if (!state.addHostDraft.connectionMessage.empty()) { - lines.push_back("Connection: " + state.addHostDraft.connectionMessage); - } - - return lines; - } - case app::ScreenId::pair_host: - { - std::vector lines = { - std::string("Target host: ") + state.pairingDraft.targetAddress, - std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort), - std::string("PIN: ") + app::current_pairing_pin(state), - "Enter the PIN on the host if prompted and wait for the status below.", - "Cancel leaves this screen, but the active network request may need a few seconds to unwind.", - }; - - if (!state.pairingDraft.statusMessage.empty()) { - lines.push_back("Status: " + state.pairingDraft.statusMessage); - } - - return lines; - } - case app::ScreenId::settings: - return { - "Display, input, overlay, and logging settings land here next.", - "The renderer already supports a log overlay toggle.", - "Use Back to return to the home screen.", - }; - } - - return {}; - } - - std::vector footer_lines(const app::ClientState &state) { - std::vector lines = { - "D-pad / Arrows: move", - "A / Enter: select", - "B / Esc: back", - "Y / F3: toggle overlay", - "Black/White/LT/RT / PgUp/PgDn: scroll logs", - }; - - if (state.activeScreen == app::ScreenId::add_host) { - lines.insert(lines.begin() + 1, state.addHostDraft.keypad.visible - ? "Keypad open: D-pad moves, A enters, X deletes, Start accepts, B cancels" - : "Select Address or Port to open the keypad modal"); - } - - return lines; + 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.activeScreen == app::ScreenId::home + || state.activeScreen == app::ScreenId::hosts + || state.activeScreen == app::ScreenId::apps + || state.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.statusMessage.empty()) { + return true; + } + + if (starts_with(state.statusMessage, "Loaded recent log file lines") + || starts_with(state.statusMessage, "No log file has been written yet") + || starts_with(state.statusMessage, "Testing connection to ") + || starts_with(state.statusMessage, "Editing host ") + || starts_with(state.statusMessage, "Updated host ") + || starts_with(state.statusMessage, "Cancelled host ") + || starts_with(state.statusMessage, "Using default Moonlight host port") + || starts_with(state.statusMessage, "Loading apps for ") + || starts_with(state.statusMessage, "Pairing is preparing the client identity")) { + return true; + } + + if (state.activeScreen == app::ScreenId::apps) { + if (const app::HostRecord *host = app::apps_host(state); host != nullptr) { + return host->appListState == app::HostAppListState::loading && state.statusMessage == host->appListStatusMessage; + } + } + + return false; + } + + std::string page_title(const app::ClientState &state) { + switch (state.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.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::toolbar && state.selectedToolbarButtonIndex % 3U == 0U}, + {"support", "Support", "?", "icons\\support.svg", state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::toolbar && state.selectedToolbarButtonIndex % 3U == 1U}, + {"add-host", "Add Host", "+", "icons\\add-host.svg", state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::toolbar && state.selectedToolbarButtonIndex % 3U == 2U}, + }; + } + + std::vector host_tiles(const app::ClientState &state) { + std::vector tiles; + tiles.reserve(state.hosts.size()); + for (std::size_t index = 0; index < state.hosts.size(); ++index) { + const app::HostRecord &host = state.hosts[index]; + tiles.push_back({ + host.address, + host.displayName, + host_tile_status(host), + host_tile_icon(host), + host.pairingState, + host.reachability, + state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::grid && index == state.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.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.push_back({ + std::to_string(appRecord.id), + appRecord.name, + detail, + badgeLabel, + appRecord.boxArtCacheKey, + appRecord.hidden, + appRecord.favorite, + appRecord.boxArtCached, + appRecord.running, + state.activeScreen == app::ScreenId::apps && visibleIndex == state.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; + } + + return state.addHostDraft.activeField == app::AddHostField::address + ? state.addHostDraft.addressInput + : (state.addHostDraft.portInput.empty() ? "default (47989)" : state.addHostDraft.portInput); + } + + 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.push_back({ + 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 to choose a key, A to enter it, X to delete, Start to accept, and B to cancel.", + }; + + 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 body_lines(const app::ClientState &state) { + switch (state.activeScreen) { + case app::ScreenId::home: + case app::ScreenId::hosts: + if (state.hosts.empty()) { + return { + "No PCs have been added yet.", + "Use Add Host to save a Sunshine host manually.", + "A Moonlight-style discovery grid now owns the home screen.", + }; + } + return { + "Select a PC to pair or browse its apps.", + "Long-press A on a controller, or press Y/I, for host actions.", + }; + case app::ScreenId::apps: + { + 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 {}; + } + case app::ScreenId::add_host: + { + 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.push_back("Validation: " + state.addHostDraft.validationMessage); + } + if (!state.addHostDraft.connectionMessage.empty()) { + lines.push_back("Connection: " + state.addHostDraft.connectionMessage); + } + return lines; + } + case app::ScreenId::pair_host: + { + std::vector lines = { + std::string("Target host: ") + state.pairingDraft.targetAddress, + }; + if (state.pairingDraft.stage == app::PairingStage::idle) { + lines.push_back("Checking whether the host is reachable before showing a PIN."); + } + else { + lines.push_back(std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort)); + if (!state.pairingDraft.generatedPin.empty()) { + lines.push_back(std::string("PIN: ") + app::current_pairing_pin(state)); + lines.push_back("Enter the PIN on the host only if Sunshine prompts for it."); + } + } + if (!state.pairingDraft.statusMessage.empty()) { + lines.push_back("Status: " + state.pairingDraft.statusMessage); + } + return lines; + } + case app::ScreenId::settings: + { + std::vector lines = { + std::string("Category: ") + (state.selectedSettingsCategory == app::SettingsCategory::logging + ? "Logging" + : state.selectedSettingsCategory == app::SettingsCategory::display + ? "Display" + : state.selectedSettingsCategory == app::SettingsCategory::input ? "Input" : "Reset"), + }; + if (state.selectedSettingsCategory == app::SettingsCategory::logging) { + lines.push_back(std::string("Log file: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); + lines.push_back(std::string("Current logging level: ") + logging::to_string(state.loggingLevel)); + lines.push_back("Use View Log File to inspect persisted startup and applist diagnostics."); + } + else if (state.selectedSettingsCategory == app::SettingsCategory::reset) { + if (state.savedFiles.empty()) { + lines.push_back("Saved files: none found."); + return lines; + } + lines.push_back("Saved files on disk:"); + for (const startup::SavedFileEntry &savedFile : state.savedFiles) { + lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); + } + } + else if (state.selectedSettingsCategory == app::SettingsCategory::display) { + lines.push_back("Display options will be added here."); + } + else { + lines.push_back("Input options will be added here."); + } + return lines; + } + } + + 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.push_back({ + 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.push_back({ + item.id, + item.label, + item.enabled, + state.settingsFocusArea == app::SettingsFocusArea::options && selectedItem != nullptr && selectedItem->id == item.id, + false, + }); + } + return rows; + } + + ui::ShellNotification notification(const app::ClientState &state) { + return { + "Notification", + state.statusMessage, + { + {"dismiss-notification", "Dismiss", "icons\\button-x.svg", {}, false}, + }, + }; + } + + void fill_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + if (!state.modal.active()) { + return; + } + + viewModel->modalVisible = true; + switch (state.modal.id) { + case app::ModalId::support: + viewModel->modalTitle = "Support"; + viewModel->modalLines = { + "Moonlight Xbox OG prototype UI", + "A / Start: close", + "B: close", + "Y or I: open context menus on hosts and apps", + }; + return; + case app::ModalId::host_actions: + viewModel->modalTitle = "Host Actions"; + viewModel->modalActions = { + {"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}, + }; + return; + case app::ModalId::host_details: + { + viewModel->modalTitle = "Host Details"; + if (const app::HostRecord *host = app::selected_host(state); host != nullptr) { + viewModel->modalLines = { + "Name: " + host->displayName, + std::string("State: ") + (host->reachability == app::HostReachability::online ? "ONLINE" : host->reachability == app::HostReachability::offline ? "OFFLINE" : "UNKNOWN"), + 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)), + }; + } + return; + } + case app::ModalId::app_actions: + if (const app::HostAppRecord *appRecord = app::selected_app(state); appRecord != nullptr) { + viewModel->modalTitle = appRecord->name; + viewModel->modalActions = { + {"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}, + }; + } + return; + case app::ModalId::app_details: + if (const app::HostAppRecord *appRecord = app::selected_app(state); appRecord != nullptr) { + viewModel->modalTitle = "App Details"; + viewModel->modalLines = { + "Name: " + appRecord->name, + std::string("HDR Supported: ") + (appRecord->hdrSupported ? "YES" : "NO"), + "ID: " + std::to_string(appRecord->id), + }; + } + return; + case app::ModalId::log_viewer: + viewModel->modalTitle = "Log File"; + viewModel->logViewerVisible = true; + viewModel->logViewerPath = state.logFilePath.empty() ? "not configured" : state.logFilePath; + viewModel->logViewerLines = state.logViewerLines; + viewModel->logViewerScrollOffset = state.logViewerScrollOffset; + viewModel->logViewerPlacement = state.logViewerPlacement; + return; + case app::ModalId::confirmation: + viewModel->modalTitle = state.confirmation.title; + viewModel->modalLines = state.confirmation.lines; + viewModel->modalFooterActions = { + {"confirm", "OK", "icons\\button-a.svg", {}, state.modal.selectedActionIndex == 0U}, + {"cancel", "Cancel", "icons\\button-b.svg", {}, state.modal.selectedActionIndex != 0U}, + }; + return; + case app::ModalId::none: + return; + } + } + + std::vector footer_actions(const app::ClientState &state) { + switch (state.activeScreen) { + case app::ScreenId::home: + case app::ScreenId::hosts: + { + std::vector actions = { + {"open", state.hostsFocusArea == app::HostsFocusArea::toolbar ? "Select" : (app::selected_host(state) != nullptr && app::selected_host(state)->pairingState == app::PairingState::paired ? "Open" : "Pair"), "icons\\button-a.svg", {}, true}, + }; + if (state.hostsFocusArea == app::HostsFocusArea::grid && app::selected_host(state) != nullptr) { + actions.push_back({"host-menu", "Host Menu", "icons\\button-y.svg", {}, false}); + } + actions.push_back({"exit", "Exit", "icons\\button-start.svg", "icons\\button-select.svg", false}); + return actions; + } + case app::ScreenId::apps: + { + std::vector actions; + if (app::selected_app(state) != nullptr) { + actions.push_back({"launch", "Launch", "icons\\button-a.svg", {}, true}); + actions.push_back({"app-menu", "App Menu", "icons\\button-y.svg", {}, false}); + } + actions.push_back({"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.settingsFocusArea == app::SettingsFocusArea::options ? "Categories" : "Back", "icons\\button-b.svg", {}, false}, + }; + } + + return {}; } } // namespace @@ -191,59 +518,77 @@ namespace { namespace ui { ShellViewModel build_shell_view_model( - const app::ClientState &state, - const std::vector &logEntries, - const std::vector &statsLines + const app::ClientState &state, + const std::vector &logEntries, + const std::vector &statsLines ) { - ShellViewModel viewModel {}; - viewModel.title = screen_title(state.activeScreen); - viewModel.bodyLines = screen_body_lines(state); - viewModel.footerLines = footer_lines(state); - viewModel.overlayVisible = state.overlayVisible; - viewModel.overlayTitle = "Diagnostics"; - viewModel.keypadModalVisible = state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible; - viewModel.keypadModalTitle = state.addHostDraft.activeField == app::AddHostField::address ? "Address Keypad" : "Port Keypad"; - viewModel.keypadModalColumnCount = 3; - - const ui::MenuItem *selectedItem = state.menu.selected_item(); - for (const MenuItem &item : state.menu.items()) { - viewModel.menuRows.push_back({ - item.id, - item.label, - item.enabled, - selectedItem != nullptr && item.id == selectedItem->id, - }); - } - - if (viewModel.keypadModalVisible) { - viewModel.keypadModalLines = keypad_modal_lines(state); - viewModel.keypadModalButtons = keypad_buttons(state); - } - - if (viewModel.overlayVisible) { - if (!statsLines.empty()) { - viewModel.overlayLines.insert(viewModel.overlayLines.end(), statsLines.begin(), statsLines.end()); - } - else { - viewModel.overlayLines.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.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.overlayLines.push_back(logging::format_entry(logEntries[index])); - } - - if (clampedOffset > 0) { - viewModel.overlayLines.insert(viewModel.overlayLines.begin(), "Showing earlier log entries"); - } - } - - return viewModel; + ShellViewModel viewModel {}; + viewModel.screen = state.activeScreen; + viewModel.title = "Moonlight"; + viewModel.pageTitle = page_title(state); + viewModel.statusMessage = state.statusMessage; + viewModel.notificationVisible = screen_supports_notifications(state) + && !state.statusMessage.empty() + && !is_minor_status_message(state) + && !state.modal.active() + && !(state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible); + if (viewModel.notificationVisible) { + viewModel.notification = notification(state); + } + viewModel.hostColumnCount = 3U; + viewModel.appColumnCount = 4U; + viewModel.toolbarButtons = toolbar_buttons(state); + viewModel.hostTiles = host_tiles(state); + viewModel.appTiles = app_tiles(state); + viewModel.bodyLines = body_lines(state); + viewModel.menuRows = menu_rows(state); + viewModel.detailMenuRows = detail_menu_rows(state); + if (state.activeScreen == app::ScreenId::settings) { + if (state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { + viewModel.selectedMenuRowLabel = state.detailMenu.selected_item()->label; + } + else if (state.menu.selected_item() != nullptr) { + viewModel.selectedMenuRowLabel = state.menu.selected_item()->label; + } + } + viewModel.footerActions = footer_actions(state); + viewModel.overlayVisible = state.overlayVisible; + viewModel.overlayTitle = "Diagnostics"; + viewModel.keypadModalVisible = state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible; + viewModel.keypadModalTitle = state.addHostDraft.activeField == app::AddHostField::address ? "Address Keypad" : "Port Keypad"; + viewModel.keypadModalColumnCount = 3; + + if (viewModel.keypadModalVisible) { + viewModel.keypadModalLines = keypad_modal_lines(state); + viewModel.keypadModalButtons = keypad_buttons(state); + } + + fill_modal_view(state, &viewModel); + + if (viewModel.overlayVisible) { + if (!statsLines.empty()) { + viewModel.overlayLines.insert(viewModel.overlayLines.end(), statsLines.begin(), statsLines.end()); + } + else { + viewModel.overlayLines.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.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.overlayLines.push_back(logging::format_entry(logEntries[index])); + } + + if (clampedOffset > 0) { + viewModel.overlayLines.insert(viewModel.overlayLines.begin(), "Showing earlier log entries"); + } + } + + return viewModel; } } // namespace ui diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h index 6a335f7..14be8e0 100644 --- a/src/ui/shell_view.h +++ b/src/ui/shell_view.h @@ -12,40 +12,123 @@ namespace ui { /** - * @brief Render-ready menu row for the SDL shell. + * @brief Render-ready button shown in the hosts toolbar. */ - struct ShellMenuRow { + struct ShellToolbarButton { std::string id; std::string label; - bool enabled; - bool selected; + std::string glyph; + std::string iconAssetPath; + bool selected = false; }; /** - * @brief Render-ready button in the add-host keypad modal. + * @brief Render-ready host tile for the Moonlight-style hosts page. + */ + struct ShellHostTile { + std::string id; + std::string displayName; + std::string statusLabel; + std::string iconAssetPath; + app::PairingState pairingState = app::PairingState::not_paired; + app::HostReachability reachability = app::HostReachability::unknown; + bool selected = false; + }; + + /** + * @brief Render-ready app tile for the per-host apps page. + */ + struct ShellAppTile { + std::string id; + std::string name; + std::string detail; + std::string badgeLabel; + std::string boxArtCacheKey; + bool hidden = false; + bool favorite = false; + bool boxArtCached = false; + bool running = false; + bool selected = false; + }; + + /** + * @brief Render-ready vertical action row used by menus and modals. + */ + struct ShellActionRow { + std::string id; + std::string label; + bool enabled = true; + bool selected = false; + bool checked = false; + }; + + /** + * @brief Render-ready footer chip pairing a button icon with an action label. + */ + struct ShellFooterAction { + std::string id; + std::string label; + std::string iconAssetPath; + std::string secondaryIconAssetPath; + bool emphasized = false; + }; + + /** + * @brief Render-ready bottom-right notification shown above the shell content. + */ + struct ShellNotification { + std::string title; + std::string message; + std::vector actions; + }; + + /** + * @brief Render-ready button in the keypad modal. */ struct ShellModalButton { std::string label; - bool enabled; - bool selected; + bool enabled = true; + bool selected = false; }; /** * @brief Render-ready shell state derived from the app model. */ struct ShellViewModel { + app::ScreenId screen = app::ScreenId::hosts; std::string title; + std::string pageTitle; + std::string statusMessage; + bool notificationVisible = false; + ShellNotification notification; + std::vector toolbarButtons; + std::vector hostTiles; + std::size_t hostColumnCount = 3; + std::vector appTiles; + std::size_t appColumnCount = 4; std::vector bodyLines; - std::vector menuRows; - std::vector footerLines; - bool overlayVisible; + std::vector menuRows; + std::vector detailMenuRows; + std::string selectedMenuRowLabel; + std::vector footerActions; + bool overlayVisible = false; std::string overlayTitle; std::vector overlayLines; - bool keypadModalVisible; + bool modalVisible = false; + std::string modalTitle; + std::vector modalLines; + std::vector modalActions; + std::vector modalFooterActions; + bool logViewerVisible = false; + std::string logViewerPath; + std::vector logViewerLines; + std::size_t logViewerScrollOffset = 0U; + app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; + bool keypadModalVisible = false; std::string keypadModalTitle; std::vector keypadModalLines; std::vector keypadModalButtons; - std::size_t keypadModalColumnCount; + std::size_t keypadModalColumnCount = 0; }; /** diff --git a/test-output/logging/long-lines.log b/test-output/logging/long-lines.log new file mode 100644 index 0000000..cb1a034 --- /dev/null +++ b/test-output/logging/long-lines.log @@ -0,0 +1 @@ +[2026-04-05 13:00:43.210] [INFO] app: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/test-output/logging/moonlight.log b/test-output/logging/moonlight.log new file mode 100644 index 0000000..efcd447 --- /dev/null +++ b/test-output/logging/moonlight.log @@ -0,0 +1,3 @@ +[2026-04-05 13:00:01.234] [INFO] app: first +[2026-04-05 13:00:02.345] [WARN] net: second +[2026-04-05 13:00:03.456] [ERROR] ui: third diff --git a/test-output/logging/reset.log b/test-output/logging/reset.log new file mode 100644 index 0000000..fbea71f --- /dev/null +++ b/test-output/logging/reset.log @@ -0,0 +1 @@ +[2026-04-05 13:00:03.000] [ERROR] net: fresh diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 1c08b06..83bf088 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -1,23 +1,21 @@ // class header include #include "src/app/client_state.h" -// standard includes -#include - // lib includes #include namespace { - TEST(ClientStateTest, StartsOnTheHomeScreenWithTheHostsEntrySelected) { + TEST(ClientStateTest, StartsOnTheHostsScreenWithTheToolbarSelected) { const app::ClientState state = app::create_initial_state(); - EXPECT_EQ(state.activeScreen, app::ScreenId::home); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.hostsFocusArea, app::HostsFocusArea::toolbar); + EXPECT_EQ(state.selectedToolbarButtonIndex, 2U); EXPECT_FALSE(state.overlayVisible); EXPECT_FALSE(state.shouldExit); EXPECT_FALSE(state.hostsDirty); - ASSERT_NE(state.menu.selected_item(), nullptr); - EXPECT_EQ(state.menu.selected_item()->id, "hosts"); + EXPECT_TRUE(state.menu.items().empty()); } TEST(ClientStateTest, ReplacesHostsFromPersistenceWithoutMarkingThemDirty) { @@ -31,36 +29,53 @@ namespace { ASSERT_EQ(state.hosts.size(), 2U); EXPECT_FALSE(state.hostsDirty); EXPECT_EQ(state.statusMessage, "Loaded 2 saved host(s)"); + EXPECT_EQ(state.hostsFocusArea, app::HostsFocusArea::grid); + EXPECT_EQ(state.selectedHostIndex, 0U); } - TEST(ClientStateTest, ActivatingHomeEntriesTransitionsBetweenTopLevelScreens) { + 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.screenChanged); - EXPECT_EQ(update.activatedItemId, "hosts"); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(update.activatedItemId, "settings-button"); + EXPECT_EQ(state.activeScreen, app::ScreenId::settings); update = app::handle_command(state, input::UiCommand::back); EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::home); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - state.menu.handle_command(input::UiCommand::move_down); update = app::handle_command(state, input::UiCommand::activate); EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(update.activatedItemId, "add-host"); + EXPECT_EQ(update.activatedItemId, "add-host-button"); EXPECT_EQ(state.activeScreen, app::ScreenId::add_host); + } - update = app::handle_command(state, input::UiCommand::back); - EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::home); + TEST(ClientStateTest, SettingsCanRequestLogViewingAndCycleLoggingLevel) { + 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.activeScreen, app::ScreenId::settings); - state.menu.handle_command(input::UiCommand::move_down); - state.menu.handle_command(input::UiCommand::move_down); update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(update.activatedItemId, "settings"); - EXPECT_EQ(state.activeScreen, app::ScreenId::settings); + EXPECT_EQ(state.selectedSettingsCategory, app::SettingsCategory::logging); + EXPECT_EQ(state.settingsFocusArea, app::SettingsFocusArea::options); + + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(update.logViewRequested); + EXPECT_EQ(update.activatedItemId, "view-log-file"); + + app::handle_command(state, input::UiCommand::move_down); + update = app::handle_command(state, input::UiCommand::activate); + EXPECT_FALSE(update.logViewRequested); + EXPECT_EQ(state.loggingLevel, logging::LogLevel::debug); + EXPECT_EQ(state.statusMessage, "Logging level set to DEBUG"); } TEST(ClientStateTest, TogglingAndScrollingTheOverlayUpdatesTheVisibleState) { @@ -74,33 +89,28 @@ namespace { update = app::handle_command(state, input::UiCommand::previous_page); EXPECT_TRUE(update.overlayChanged); - EXPECT_FALSE(update.overlayVisibilityChanged); EXPECT_GT(state.overlayScrollOffset, 0U); update = app::handle_command(state, input::UiCommand::next_page); EXPECT_TRUE(update.overlayChanged); - EXPECT_FALSE(update.overlayVisibilityChanged); EXPECT_EQ(state.overlayScrollOffset, 0U); } - TEST(ClientStateTest, ActivatingExitMarksTheClientForShutdown) { + TEST(ClientStateTest, BackFromHostsDoesNotRequestShutdown) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); - state.menu.handle_command(input::UiCommand::move_down); - state.menu.handle_command(input::UiCommand::move_down); - - const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); - EXPECT_TRUE(update.exitRequested); - EXPECT_EQ(update.activatedItemId, "exit"); - EXPECT_TRUE(state.shouldExit); + EXPECT_FALSE(update.exitRequested); + EXPECT_FALSE(state.shouldExit); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); } TEST(ClientStateTest, CanSaveAManualHostEntryWithACustomPort) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); + state.hostsFocusArea = app::HostsFocusArea::toolbar; + state.selectedToolbarButtonIndex = 2U; app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); @@ -115,14 +125,14 @@ namespace { EXPECT_EQ(state.hosts.front().address, "193.168.1.10"); EXPECT_EQ(state.hosts.front().port, 48000); EXPECT_EQ(state.hosts.front().displayName, "Host 193.168.1.10"); - ASSERT_NE(state.menu.selected_item(), nullptr); - EXPECT_EQ(state.menu.selected_item()->id, "host:193.168.1.10:48000"); + EXPECT_EQ(state.selectedHostIndex, 0U); } TEST(ClientStateTest, RejectsDuplicateHostEntriesAndAllowsCancellationBackToHosts) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); + state.hostsFocusArea = app::HostsFocusArea::toolbar; + state.selectedToolbarButtonIndex = 2U; app::handle_command(state, input::UiCommand::activate); state.addHostDraft.addressInput = "192.168.0.10"; state.menu.select_item_by_id("save-host"); @@ -131,7 +141,8 @@ namespace { ASSERT_EQ(state.hosts.size(), 1U); ASSERT_EQ(state.activeScreen, app::ScreenId::hosts); - state.menu.handle_command(input::UiCommand::move_down); + state.hostsFocusArea = app::HostsFocusArea::toolbar; + state.selectedToolbarButtonIndex = 2U; app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); EXPECT_TRUE(update.screenChanged); ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); @@ -141,76 +152,452 @@ namespace { update = app::handle_command(state, input::UiCommand::activate); EXPECT_FALSE(update.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::add_host); 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.screenChanged); EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - ASSERT_NE(state.menu.selected_item(), nullptr); - EXPECT_EQ(state.menu.selected_item()->id, "add-host"); } - TEST(ClientStateTest, StartsPairingForTheSelectedUnpairedHost) { + TEST(ClientStateTest, SelectingAnUnpairedHostStartsPairing) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, }); - app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - - const auto pairItem = std::find_if(state.menu.items().begin(), state.menu.items().end(), [](const ui::MenuItem &item) { - return item.id == "pair-host"; - }); - ASSERT_NE(pairItem, state.menu.items().end()); - EXPECT_TRUE(pairItem->enabled); - EXPECT_TRUE(state.menu.select_item_by_id("pair-host")); - - update = app::handle_command(state, input::UiCommand::activate); + app::handle_command(state, input::UiCommand::move_down); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); EXPECT_TRUE(update.screenChanged); - EXPECT_FALSE(update.hostsChanged); EXPECT_TRUE(update.pairingRequested); EXPECT_EQ(update.pairingAddress, "192.168.1.20"); EXPECT_EQ(update.pairingPort, app::DEFAULT_HOST_PORT); EXPECT_TRUE(app::is_valid_pairing_pin(update.pairingPin)); EXPECT_EQ(state.activeScreen, app::ScreenId::pair_host); - EXPECT_EQ(state.pairingDraft.targetAddress, "192.168.1.20"); - EXPECT_EQ(state.pairingDraft.targetPort, app::DEFAULT_HOST_PORT); - EXPECT_EQ(state.pairingDraft.generatedPin, update.pairingPin); - EXPECT_EQ(state.pairingDraft.stage, app::PairingStage::in_progress); - ASSERT_EQ(state.hosts.size(), 1U); + } + + TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.1.20", 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.screenChanged); + EXPECT_FALSE(update.pairingRequested); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.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", "192.168.1.20", 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.activeScreen, app::ScreenId::pair_host); + ASSERT_TRUE(update.pairingRequested); + + update = app::handle_command(state, input::UiCommand::back); + + EXPECT_TRUE(update.screenChanged); + EXPECT_TRUE(update.pairingCancelledRequested); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + } + + TEST(ClientStateTest, HostGridNavigationMatchesTheRenderedThreeColumnLayout) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + }); + + EXPECT_EQ(state.selectedHostIndex, 0U); + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.selectedHostIndex, 1U); + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.selectedHostIndex, 2U); + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.selectedHostIndex, 3U); + } + + TEST(ClientStateTest, HostGridCanMoveDownIntoAPartialNextRowFromAnyColumn) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.selectedHostIndex, 1U); + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.selectedHostIndex, 3U); + + state.selectedHostIndex = 2U; + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.selectedHostIndex, 3U); + } + + TEST(ClientStateTest, HostGridWrapsRightToTheNextRowAndLeftToThePreviousRow) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host E", "192.168.0.14", 0, app::PairingState::paired, app::HostReachability::online}, + }); + + state.selectedHostIndex = 2U; + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.selectedHostIndex, 3U); + + app::handle_command(state, input::UiCommand::move_left); + EXPECT_EQ(state.selectedHostIndex, 2U); + } + + TEST(ClientStateTest, SelectingAPairedHostOpensTheAppsScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, 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.screenChanged); + EXPECT_TRUE(update.appsBrowseRequested); + EXPECT_FALSE(update.appsBrowseShowHidden); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + } + + TEST(ClientStateTest, SelectingAnOfflinePairedHostDoesNotOpenTheAppsScreen) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, 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.screenChanged); + EXPECT_TRUE(update.appsBrowseRequested); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.statusMessage.empty()); + } + + TEST(ClientStateTest, AppliesFetchedAppListsAndPreservesPerAppFlags) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + state.hosts.front().runningGameId = 101; + state.hosts.front().apps = { + {"Steam", 101, false, true, true, "cached-steam", true, false}, + }; + + app::apply_app_list_result(state, "10.0.0.25", 48000, { + {"Steam", 101, true, false, false, "cached-steam", false, false}, + {"Desktop", 102, false, false, false, "cached-desktop", true, false}, + }, 0x55AAU, true, "Loaded 2 Sunshine app(s)"); + + ASSERT_EQ(state.hosts.front().apps.size(), 2U); + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); + EXPECT_TRUE(state.hosts.front().apps[0].hidden); + EXPECT_TRUE(state.hosts.front().apps[0].favorite); + EXPECT_TRUE(state.hosts.front().apps[0].boxArtCached); + EXPECT_TRUE(state.hosts.front().apps[0].running); + EXPECT_TRUE(state.hosts.front().apps[1].boxArtCached); + EXPECT_TRUE(state.statusMessage.empty()); + } + + TEST(ClientStateTest, AppliesFetchedAppListsWhenBackgroundTasksReportTheResolvedHttpPort) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + + state.hosts.front().resolvedHttpPort = 47989; + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + + app::apply_app_list_result(state, "10.0.0.25", 47989, { + {"Steam", 101, true, false, false, "steam-cover", true, false}, + }, 0xBEEFU, true, "Loaded 1 Sunshine app(s)"); + + ASSERT_EQ(state.hosts.front().apps.size(), 1U); + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); + EXPECT_EQ(state.hosts.front().apps.front().name, "Steam"); + EXPECT_TRUE(state.statusMessage.empty()); + } + + TEST(ClientStateTest, MarksCoverArtCachedWhenBackgroundTasksReportTheResolvedHttpsPort) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + + state.hosts.front().httpsPort = 47990; + state.hosts.front().apps = { + {"Steam", 101, true, false, false, "steam-cover", false, false}, + }; + + app::mark_cover_art_cached(state, "10.0.0.25", 47990, 101); + + ASSERT_EQ(state.hosts.front().apps.size(), 1U); + EXPECT_TRUE(state.hosts.front().apps.front().boxArtCached); + } + + TEST(ClientStateTest, FailedRefreshKeepsCachedAppsAvailable) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.front().apps = { + {"Steam", 101, false, false, false, "cached-steam", true, false}, + }; + state.hosts.front().appListContentHash = 0x1234U; + + app::apply_app_list_result(state, "10.0.0.25", 48000, {}, 0, false, "Timed out while refreshing apps"); + + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); + ASSERT_EQ(state.hosts.front().apps.size(), 1U); + EXPECT_EQ(state.hosts.front().apps.front().name, "Steam"); + EXPECT_EQ(state.statusMessage, "Timed out while refreshing apps"); + } + + TEST(ClientStateTest, ExplicitUnpairedAppListFailureMarksTheHostAsNotPaired) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, 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, + "10.0.0.25", + 48000, + {}, + 0, + false, + "The host reports that this client is no longer paired. Pair the host again from Sunshine." + ); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::not_paired); - EXPECT_FALSE(state.hostsDirty); + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::failed); } - TEST(ClientStateTest, CanDeleteTheSelectedHostAndKeepFocusOnTheRemainingList) { + TEST(ClientStateTest, ExplicitUnpairedAppListFailureClearsCachedApps) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + {"Office PC", "10.0.0.25", 48000, 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, + "10.0.0.25", + 48000, + {}, + 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", "10.0.0.25", 48000, 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, "10.0.0.25", 48000, {}, 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", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + app::apply_app_list_result(state, "10.0.0.25", 48000, { + {"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 Sunshine app(s)"); + + state.selectedAppIndex = 3U; + app::handle_command(state, input::UiCommand::move_right); + EXPECT_EQ(state.selectedAppIndex, 4U); + + app::handle_command(state, input::UiCommand::move_left); + EXPECT_EQ(state.selectedAppIndex, 3U); + + state.selectedAppIndex = 2U; + app::handle_command(state, input::UiCommand::move_down); + EXPECT_EQ(state.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.statusMessage, "Loaded log file preview"); + EXPECT_EQ(state.logViewerPlacement, app::LogViewerPlacement::full); + EXPECT_EQ(state.logViewerScrollOffset, 0U); + + app::handle_command(state, input::UiCommand::previous_page); + EXPECT_EQ(state.logViewerScrollOffset, 1U); + + app::handle_command(state, input::UiCommand::fast_previous_page); + EXPECT_EQ(state.logViewerScrollOffset, 3U); + + app::handle_command(state, input::UiCommand::next_page); + EXPECT_EQ(state.logViewerScrollOffset, 2U); + + app::handle_command(state, input::UiCommand::delete_character); + EXPECT_EQ(state.logViewerPlacement, app::LogViewerPlacement::left); + + app::handle_command(state, input::UiCommand::open_context_menu); + EXPECT_EQ(state.logViewerPlacement, app::LogViewerPlacement::right); + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.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", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, }); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + ASSERT_EQ(state.activeScreen, app::ScreenId::apps); + EXPECT_TRUE(state.statusMessage.empty()); + + app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.statusMessage.empty()); + + app::apply_app_list_result(state, "10.0.0.25", 48000, { + {"Steam", 101, false, false, false, "steam-cover", false, false}, + }, 0, false, "The host applist response did not contain any app entries"); + + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.statusMessage.empty()); + EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::failed); + EXPECT_EQ(state.hosts.front().appListStatusMessage, "The host applist response did not contain any app entries"); + } + + 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); - state.menu.select_item_by_id("delete-host"); + ASSERT_EQ(state.activeScreen, app::ScreenId::settings); + + app::replace_saved_files(state, { + {"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.selectedSettingsCategory, app::SettingsCategory::reset); + ASSERT_EQ(state.settingsFocusArea, 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_FALSE(update.screenChanged); + EXPECT_TRUE(update.modalOpened); + EXPECT_EQ(state.modal.id, app::ModalId::confirmation); + + update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_TRUE(update.savedFileDeleteRequested); + EXPECT_EQ(update.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.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.selectedSettingsCategory, app::SettingsCategory::reset); + ASSERT_EQ(state.settingsFocusArea, app::SettingsFocusArea::options); + + const app::AppUpdate openUpdate = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(openUpdate.modalOpened); + ASSERT_EQ(state.modal.id, app::ModalId::confirmation); + + const app::AppUpdate confirmUpdate = app::handle_command(state, input::UiCommand::activate); + EXPECT_TRUE(confirmUpdate.factoryResetRequested); + } + + TEST(ClientStateTest, HostContextMenuCanDeleteTheSelectedHost) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + {"Office PC", "10.0.0.25", 48000, 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.hostsChanged); ASSERT_EQ(state.hosts.size(), 1U); - EXPECT_EQ(state.hosts.front().address, "10.0.0.25"); - ASSERT_NE(state.menu.selected_item(), nullptr); - EXPECT_EQ(state.menu.selected_item()->id, "host:10.0.0.25:48000"); - EXPECT_TRUE(state.hostsDirty); + EXPECT_EQ(state.hosts.front().displayName, "Office PC"); } TEST(ClientStateTest, RequestsAConnectionTestFromTheAddHostScreen) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); state.addHostDraft.addressInput = "192.168.0.10"; state.addHostDraft.portInput = "48000"; @@ -227,7 +614,6 @@ namespace { TEST(ClientStateTest, StagesCancelsDeletesAndAcceptsAddHostKeypadEdits) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); state.addHostDraft.addressInput = "192.168.0.10"; @@ -238,7 +624,6 @@ namespace { EXPECT_EQ(state.addHostDraft.keypad.stagedInput, "192.168.0.10"); app::handle_command(state, input::UiCommand::activate); - EXPECT_EQ(state.addHostDraft.addressInput, "192.168.0.10"); EXPECT_EQ(state.addHostDraft.keypad.stagedInput, "192.168.0.101"); app::handle_command(state, input::UiCommand::delete_character); @@ -247,8 +632,6 @@ namespace { app::handle_command(state, input::UiCommand::back); EXPECT_FALSE(state.addHostDraft.keypad.visible); EXPECT_EQ(state.addHostDraft.addressInput, "192.168.0.10"); - ASSERT_NE(state.menu.selected_item(), nullptr); - EXPECT_EQ(state.menu.selected_item()->id, "edit-address"); app::handle_command(state, input::UiCommand::activate); EXPECT_TRUE(state.addHostDraft.keypad.visible); @@ -259,4 +642,19 @@ namespace { EXPECT_EQ(state.addHostDraft.addressInput, "192.168.0.101"); } + TEST(ClientStateTest, SuccessfulPairingReturnsToHostsAndKeepsTheHostSelected) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::online}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + + state.selectedHostIndex = 1U; + EXPECT_TRUE(app::apply_pairing_result(state, "192.168.1.20", app::DEFAULT_HOST_PORT, true, "Paired successfully")); + + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.selectedHostIndex, 0U); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::paired); + } + } // namespace diff --git a/tests/unit/app/pairing_flow_test.cpp b/tests/unit/app/pairing_flow_test.cpp index 2d4fe6a..aa50ad4 100644 --- a/tests/unit/app/pairing_flow_test.cpp +++ b/tests/unit/app/pairing_flow_test.cpp @@ -11,9 +11,9 @@ namespace { EXPECT_EQ(draft.targetAddress, "192.168.1.20"); EXPECT_EQ(draft.targetPort, 47984); - EXPECT_EQ(draft.stage, app::PairingStage::pin_ready); + EXPECT_EQ(draft.stage, app::PairingStage::idle); EXPECT_EQ(draft.generatedPin, "4821"); - EXPECT_FALSE(draft.statusMessage.empty()); + EXPECT_EQ(draft.statusMessage, "Checking whether the host is reachable before pairing begins."); } TEST(PairingFlowTest, AcceptsOnlyFourDigitPins) { diff --git a/tests/unit/input/navigation_input_test.cpp b/tests/unit/input/navigation_input_test.cpp index d033a44..0fbe51f 100644 --- a/tests/unit/input/navigation_input_test.cpp +++ b/tests/unit/input/navigation_input_test.cpp @@ -13,7 +13,7 @@ namespace { 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::y), input::UiCommand::toggle_overlay); + EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::y), input::UiCommand::open_context_menu); } TEST(NavigationInputTest, MapsKeyboardKeysToNavigationCommands) { @@ -24,6 +24,8 @@ namespace { 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::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::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); } diff --git a/tests/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp new file mode 100644 index 0000000..7bb5bd1 --- /dev/null +++ b/tests/unit/logging/log_file_test.cpp @@ -0,0 +1,72 @@ +// class header include +#include "src/logging/log_file.h" + +// standard includes +#include +#include + +// lib includes +#include + +namespace { + + std::string test_log_file_path(const char *name) { + return std::string("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); + } + +} // namespace + diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp index 8d71904..efdf88e 100644 --- a/tests/unit/logging/logger_test.cpp +++ b/tests/unit/logging/logger_test.cpp @@ -10,8 +10,15 @@ 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, StoresEntriesAboveTheConfiguredMinimumLevel) { - logging::Logger logger(4); + logging::Logger logger(4, []() { + return logging::LogTimestamp {2026, 4, 5, 13, 7, 9, 42}; + }); logger.set_minimum_level(logging::LogLevel::debug); EXPECT_FALSE(logger.log(logging::LogLevel::trace, "streaming", "ignored")); @@ -23,6 +30,13 @@ namespace { 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) { diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index a93425d..85cb0ea 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -1,5 +1,7 @@ #include "src/network/host_pairing.h" +#include + #include namespace { @@ -15,10 +17,35 @@ namespace { EXPECT_NE(identity.privateKeyPem.find("BEGIN PRIVATE KEY"), std::string::npos); } + 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({ + "192.168.1.20", + 47984, + "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" + "192.168.1.25" + "203.0.113.7" + "fe80::1234" + "00:11:22:33:44:55" + "42" "47989" "47990" "1" @@ -32,6 +59,14 @@ namespace { EXPECT_EQ(serverInfo.httpPort, 47989); EXPECT_EQ(serverInfo.httpsPort, 47990); EXPECT_TRUE(serverInfo.paired); + EXPECT_EQ(serverInfo.hostName, "Sunshine-PC"); + EXPECT_EQ(serverInfo.uuid, "host-uuid-123"); + EXPECT_EQ(serverInfo.activeAddress, "192.168.1.25"); + EXPECT_EQ(serverInfo.localAddress, "192.168.1.25"); + EXPECT_EQ(serverInfo.remoteAddress, "203.0.113.7"); + EXPECT_EQ(serverInfo.ipv6Address, "fe80::1234"); + EXPECT_EQ(serverInfo.macAddress, "00:11:22:33:44:55"); + EXPECT_EQ(serverInfo.runningGameId, 42U); } TEST(HostPairingTest, RejectsServerInfoResponsesMissingRequiredFields) { @@ -42,6 +77,151 @@ namespace { EXPECT_FALSE(errorMessage.empty()); } + TEST(HostPairingTest, PrefersRequestedAddressForFollowUpRequests) { + const std::string xml = + "" + "Sunshine-PC" + "7.1.431.0" + "host-uuid-123" + "127.0.0.1" + "192.168.0.50" + "47990" + "1" + ""; + + network::HostPairingServerInfo serverInfo {}; + std::string errorMessage; + + ASSERT_TRUE(network::parse_server_info_response(xml, 47984, &serverInfo, &errorMessage)) << errorMessage; + EXPECT_EQ(serverInfo.activeAddress, "127.0.0.1"); + EXPECT_EQ(network::resolve_reachable_address("10.0.2.2", serverInfo), "10.0.2.2"); + } + + TEST(HostPairingTest, FallsBackToReportedAddressWhenRequestedAddressIsMissing) { + network::HostPairingServerInfo serverInfo {}; + serverInfo.activeAddress = "192.168.0.50"; + serverInfo.localAddress = "192.168.0.51"; + serverInfo.remoteAddress = "203.0.113.9"; + + EXPECT_EQ(network::resolve_reachable_address({}, serverInfo), "192.168.0.50"); + } + + TEST(HostPairingTest, ParsesSunshineAppLists) { + 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, ParsesAttributeBasedSunshineAppLists) { + const std::string xml = + "" + "" + ""; + + 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 = + "" + "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, ParsesJsonAppLists) { + const std::string json = + "{" + "\"apps\":[" + "{\"name\":\"Steam\",\"id\":401,\"hdrSupported\":true}," + "{\"title\":\"Desktop\",\"appid\":\"402\",\"hidden\":false}" + "]" + "}"; + + std::vector apps; + std::string errorMessage; + + ASSERT_TRUE(network::parse_app_list_response(json, &apps, &errorMessage)) << errorMessage; + ASSERT_EQ(apps.size(), 2U); + EXPECT_EQ(apps[0].name, "Steam"); + EXPECT_EQ(apps[0].id, 401); + EXPECT_TRUE(apps[0].hdrSupported); + EXPECT_EQ(apps[1].name, "Desktop"); + EXPECT_EQ(apps[1].id, 402); + EXPECT_FALSE(apps[1].hidden); + } + + 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 from Sunshine.")); + 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_FALSE(network::error_indicates_unpaired_client("Timed out while refreshing apps")); + } + } // 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..78875fa --- /dev/null +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -0,0 +1,57 @@ +#include "src/startup/cover_art_cache.h" + +#include +#include + +extern "C" { +#include +} + +#include + +namespace { + + void remove_if_present(const std::string &path) { + std::remove(path.c_str()); + } + + void remove_directory_if_present(const std::string &path) { + _rmdir(path.c_str()); + } + + class CoverArtCacheTest : public ::testing::Test { + protected: + void TearDown() override { + remove_if_present(testFilePath); + remove_directory_if_present(testDirectory); + } + + std::string testDirectory = "cover-art-cache-test"; + std::string cacheKey = startup::build_cover_art_cache_key("host-uuid-123", "192.168.0.10", 42); + std::string testFilePath = 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)); + } + +} // 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..0fe1e53 --- /dev/null +++ b/tests/unit/startup/saved_files_test.cpp @@ -0,0 +1,125 @@ +#include "src/startup/saved_files.h" + +#include +#include + +extern "C" { +#include +} + +#include + +namespace { + + void remove_if_present(const std::string &path) { + std::remove(path.c_str()); + } + + void remove_directory_if_present(const std::string &path) { + _rmdir(path.c_str()); + } + + 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 { + protected: + std::string testDirectory = "saved-files-test"; + std::string hostStoragePath = testDirectory + "\\moonlight-hosts.tsv"; + std::string logFilePath = testDirectory + "\\moonlight.log"; + std::string pairingDirectory = testDirectory + "\\pairing"; + std::string pairingUniqueIdPath = pairingDirectory + "\\uniqueid.dat"; + std::string pairingCertificatePath = pairingDirectory + "\\client.pem"; + std::string pairingKeyPath = pairingDirectory + "\\key.pem"; + std::string coverArtDirectory = testDirectory + "\\cover-art-cache"; + std::string coverArtFilePath = coverArtDirectory + "\\cover-101.bin"; + startup::SavedFileCatalogConfig config { + hostStoragePath, + logFilePath, + pairingDirectory, + coverArtDirectory, + }; + + void SetUp() override { + ASSERT_EQ(_mkdir(testDirectory.c_str()), 0); + ASSERT_EQ(_mkdir(pairingDirectory.c_str()), 0); + ASSERT_EQ(_mkdir(coverArtDirectory.c_str()), 0); + } + + void TearDown() override { + remove_if_present(coverArtFilePath); + remove_if_present(pairingKeyPath); + remove_if_present(pairingCertificatePath); + remove_if_present(pairingUniqueIdPath); + remove_if_present(logFilePath); + remove_if_present(hostStoragePath); + remove_directory_if_present(coverArtDirectory); + remove_directory_if_present(pairingDirectory); + remove_directory_if_present(testDirectory); + } + }; + + TEST_F(SavedFilesTest, ListsMoonlightManagedFilesThatExistOnDisk) { + write_file_bytes(hostStoragePath, {'h', 'o', 's', '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(), 6U); + EXPECT_EQ(result.files[0].displayName, "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, "pairing\\client.pem"); + EXPECT_EQ(result.files[4].displayName, "pairing\\key.pem"); + EXPECT_EQ(result.files[5].displayName, "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()); + + remove_if_present(outsidePath); + } + + TEST_F(SavedFilesTest, FactoryResetDeletesAllManagedSavedFiles) { + write_file_bytes(hostStoragePath, {'h', 'o', 's', '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()); + } + +} // namespace + + + diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 7e9fffe..3d34221 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -6,43 +6,66 @@ namespace { - TEST(ShellViewTest, BuildsHomeScreenContentFromTheInitialState) { + TEST(ShellViewTest, BuildsHostsScreenContentFromTheInitialState) { const app::ClientState state = app::create_initial_state(); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_EQ(viewModel.title, "Xbox"); + EXPECT_EQ(viewModel.title, "Moonlight"); + EXPECT_TRUE(viewModel.pageTitle.empty()); + ASSERT_EQ(viewModel.toolbarButtons.size(), 3U); + EXPECT_TRUE(viewModel.toolbarButtons[2].selected); + EXPECT_EQ(viewModel.toolbarButtons[2].label, "Add Host"); + EXPECT_EQ(viewModel.toolbarButtons[2].iconAssetPath, "icons\\add-host.svg"); ASSERT_FALSE(viewModel.bodyLines.empty()); - EXPECT_EQ(viewModel.bodyLines.front(), "Controller-first Moonlight shell prototype."); - EXPECT_EQ(viewModel.bodyLines.back(), "Saved hosts available: 0"); - ASSERT_EQ(viewModel.menuRows.size(), 4U); - EXPECT_TRUE(viewModel.menuRows.front().selected); - EXPECT_EQ(viewModel.menuRows.front().label, "Hosts"); + EXPECT_EQ(viewModel.bodyLines.front(), "No PCs have been added yet."); + ASSERT_EQ(viewModel.footerActions.size(), 2U); + EXPECT_EQ(viewModel.footerActions[0].label, "Select"); + EXPECT_EQ(viewModel.footerActions[0].iconAssetPath, "icons\\button-a.svg"); + EXPECT_EQ(viewModel.footerActions[1].label, "Exit"); + EXPECT_EQ(viewModel.footerActions[1].iconAssetPath, "icons\\button-start.svg"); + EXPECT_EQ(viewModel.footerActions[1].secondaryIconAssetPath, "icons\\button-select.svg"); EXPECT_FALSE(viewModel.overlayVisible); } - TEST(ShellViewTest, ShowsSavedHostDetailsOnTheHostsScreen) { + TEST(ShellViewTest, ShowsSavedHostsAsTiles) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired}, - }, "Loaded 1 saved host(s)"); - app::handle_command(state, input::UiCommand::activate); + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired, app::HostReachability::offline}, + {"Office PC", "10.0.0.25", 48000, 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.pageTitle.empty()); + EXPECT_EQ(viewModel.hostColumnCount, 3U); + ASSERT_EQ(viewModel.hostTiles.size(), 2U); + EXPECT_EQ(viewModel.hostTiles[0].displayName, "Living Room PC"); + EXPECT_EQ(viewModel.hostTiles[0].statusLabel, "Offline"); + EXPECT_EQ(viewModel.hostTiles[0].iconAssetPath, "icons\\host-monitor-offline.svg"); + EXPECT_EQ(viewModel.hostTiles[1].displayName, "Office PC"); + EXPECT_EQ(viewModel.hostTiles[1].statusLabel, "Online"); + EXPECT_EQ(viewModel.hostTiles[1].iconAssetPath, "icons\\host-monitor-online.svg"); + EXPECT_TRUE(viewModel.hostTiles[1].selected); + } + + TEST(ShellViewTest, HidesHostMenuFooterActionWhenToolbarIsSelected) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + state.hostsFocusArea = app::HostsFocusArea::toolbar; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_EQ(viewModel.title, "Hosts"); - ASSERT_GE(viewModel.bodyLines.size(), 6U); - EXPECT_EQ(viewModel.bodyLines[0], "Saved hosts: 1"); - EXPECT_EQ(viewModel.bodyLines[1], "Selected host: Living Room PC"); - EXPECT_EQ(viewModel.bodyLines[2], "Address: 192.168.0.10"); - EXPECT_EQ(viewModel.bodyLines[3], "Port: 48000"); - EXPECT_EQ(viewModel.bodyLines[4], "Pairing: Not paired yet"); - EXPECT_NE(viewModel.bodyLines[5].find("Pair Selected Host"), std::string::npos); + ASSERT_EQ(viewModel.footerActions.size(), 2U); + EXPECT_EQ(viewModel.footerActions[0].label, "Select"); + EXPECT_EQ(viewModel.footerActions[1].label, "Exit"); } TEST(ShellViewTest, ShowsKeypadBasedHostEntryInstructionsAndValidation) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); state.addHostDraft.activeField = app::AddHostField::port; state.addHostDraft.portInput = "48000"; @@ -51,23 +74,62 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_EQ(viewModel.title, "Add Host"); - ASSERT_GE(viewModel.bodyLines.size(), 8U); - EXPECT_EQ(viewModel.bodyLines[0], "Manual host entry with a popup keypad."); - EXPECT_EQ(viewModel.bodyLines[1], "Current address: 192.168.0.10"); - EXPECT_EQ(viewModel.bodyLines[2], "Current port: 48000"); - EXPECT_EQ(viewModel.bodyLines[3], "Selected field: Port"); - EXPECT_EQ(viewModel.bodyLines[4], "Select Host Address or Port to open the keypad modal."); - EXPECT_EQ(viewModel.bodyLines[5], "Use Clear Current Field to erase the selected field."); - EXPECT_EQ(viewModel.bodyLines[7], "Validation: That host is already saved"); - EXPECT_EQ(viewModel.bodyLines[8], "Connection: Connected to 192.168.0.10:48000"); - ASSERT_GE(viewModel.footerLines.size(), 6U); - EXPECT_EQ(viewModel.footerLines[1], "Select Address or Port to open the keypad modal"); + EXPECT_EQ(viewModel.pageTitle, "Add Host"); + ASSERT_GE(viewModel.bodyLines.size(), 4U); + EXPECT_EQ(viewModel.bodyLines[0], "Manual host entry with a keypad modal."); + EXPECT_EQ(viewModel.bodyLines[1], "Address: 192.168.0.10"); + EXPECT_EQ(viewModel.bodyLines[2], "Port: 48000"); + EXPECT_EQ(viewModel.bodyLines[3], "Press A to edit either field with the keypad modal."); + ASSERT_EQ(viewModel.footerActions.size(), 2U); + EXPECT_EQ(viewModel.footerActions[0].label, "Select"); + EXPECT_EQ(viewModel.footerActions[1].label, "Back"); + } + + TEST(ShellViewTest, ShowsLoggingDetailsOnTheSettingsScreen) { + 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.activeScreen, app::ScreenId::settings); + app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); + state.loggingLevel = logging::LogLevel::debug; + app::replace_saved_files(state, { + {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 2048U}, + {"E:\\UDATA\\12345678\\pairing\\client.pem", "pairing\\client.pem", 1536U}, + }); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_GE(viewModel.bodyLines.size(), 4U); + EXPECT_EQ(viewModel.bodyLines[0], "Category: Logging"); + EXPECT_EQ(viewModel.bodyLines[1], "Log file: E:\\UDATA\\12345678\\moonlight.log"); + EXPECT_EQ(viewModel.bodyLines[2], "Current logging level: DEBUG"); + EXPECT_EQ(viewModel.bodyLines[3], "Use View Log File to inspect persisted startup and applist diagnostics."); + ASSERT_EQ(viewModel.detailMenuRows.size(), 2U); + EXPECT_EQ(viewModel.detailMenuRows[0].label, "View Log File"); + } + + 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.activeScreen, app::ScreenId::settings); + + state.selectedSettingsCategory = app::SettingsCategory::reset; + app::replace_saved_files(state, { + {"E:\\UDATA\\12345678\\pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", "pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", 1536U}, + }); + state.settingsFocusArea = 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.selectedMenuRowLabel, "Delete pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin"); } TEST(ShellViewTest, BuildsTheAddHostKeypadModalAsANumberPad) { app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); state.addHostDraft.addressInput.clear(); app::handle_command(state, input::UiCommand::activate); @@ -84,34 +146,175 @@ namespace { EXPECT_TRUE(viewModel.keypadModalButtons[0].selected); EXPECT_EQ(viewModel.keypadModalButtons[9].label, "."); EXPECT_EQ(viewModel.keypadModalButtons[10].label, "0"); - EXPECT_EQ(viewModel.footerLines[1], "Keypad open: D-pad moves, A enters, X deletes, Start accepts, B cancels"); } - TEST(ShellViewTest, ShowsThatPairingCanBeStartedForAnUnpairedHost) { + TEST(ShellViewTest, BuildsTheAppsPageForASelectedPairedHost) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.hosts.front().runningGameId = 101; + app::apply_app_list_result(state, "10.0.0.25", 48000, { + {"Steam", 101, true, false, false, "steam-cover", true, false}, + {"Desktop", 102, false, false, false, "desktop-cover", false, false}, + }, 0x4242U, true, "Loaded 2 Sunshine app(s)"); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_EQ(viewModel.screen, app::ScreenId::apps); + EXPECT_EQ(viewModel.pageTitle, "Office PC"); + ASSERT_FALSE(viewModel.appTiles.empty()); + EXPECT_EQ(viewModel.appTiles[0].name, "Steam"); + EXPECT_TRUE(viewModel.appTiles[0].selected); + EXPECT_TRUE(viewModel.bodyLines.empty()); + EXPECT_EQ(viewModel.appTiles[0].detail, "Running now"); + EXPECT_EQ(viewModel.appTiles[0].badgeLabel, "HDR"); + EXPECT_TRUE(viewModel.appTiles[0].boxArtCached); + EXPECT_TRUE(viewModel.appTiles[1].detail.empty()); + ASSERT_EQ(viewModel.footerActions.size(), 3U); + EXPECT_EQ(viewModel.footerActions[0].label, "Launch"); + EXPECT_EQ(viewModel.footerActions[1].label, "App Menu"); + EXPECT_EQ(viewModel.footerActions[2].label, "Back"); + } + + TEST(ShellViewTest, HidesCachedAppTilesWhenTheSelectedHostIsNoLongerPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::not_paired, app::HostReachability::online}, }); + state.activeScreen = app::ScreenId::apps; + state.hosts.front().apps = { + {"Steam", 101, false, false, false, "steam-cover", true, false}, + }; + state.hosts.front().appListState = app::HostAppListState::failed; + state.hosts.front().appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again from Sunshine."; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.appTiles.empty()); + ASSERT_FALSE(viewModel.bodyLines.empty()); + EXPECT_EQ(viewModel.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", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.statusMessage = "Loading apps for Office PC..."; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.bodyLines.empty()); + EXPECT_FALSE(viewModel.notificationVisible); + } + + TEST(ShellViewTest, ShowsOnlyBackOnAppsScreenWhenNoVisibleAppIsSelected) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, 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.footerActions.size(), 1U); + EXPECT_EQ(viewModel.footerActions[0].label, "Back"); + } + + TEST(ShellViewTest, BuildsHostDetailsModalContent) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online, "192.168.0.10", "uuid-123", "192.168.0.10", "203.0.113.7", {}, "192.168.0.10", "00:11:22:33:44:55", 47990, 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_EQ(viewModel.title, "Hosts"); - ASSERT_GE(viewModel.bodyLines.size(), 5U); - EXPECT_EQ(viewModel.bodyLines[0], "Saved hosts: 1"); - EXPECT_EQ(viewModel.bodyLines[1], "Selected host: Living Room PC"); - EXPECT_EQ(viewModel.bodyLines[4], "Pairing: Not paired yet"); - EXPECT_EQ(viewModel.bodyLines[5], "Select Pair Selected Host to start the Sunshine pairing handshake in the background."); + EXPECT_TRUE(viewModel.modalVisible); + EXPECT_EQ(viewModel.modalTitle, "Host Details"); + ASSERT_GE(viewModel.modalLines.size(), 5U); + EXPECT_EQ(viewModel.modalLines[0], "Name: Living Room PC"); + EXPECT_EQ(viewModel.modalLines[1], "State: ONLINE"); + EXPECT_EQ(viewModel.modalLines[2], "Active Address: 192.168.0.10"); + } + + TEST(ShellViewTest, BuildsDedicatedLogViewerModalState) { + app::ClientState state = app::create_initial_state(); + app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); + state.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.logViewerScrollOffset = 1; - bool foundPairingAction = false; - for (const ui::ShellMenuRow &row : viewModel.menuRows) { - if (row.id == "pair-host") { - foundPairingAction = true; - EXPECT_TRUE(row.enabled); - EXPECT_EQ(row.label, "Pair Selected Host"); - } + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.modalVisible); + EXPECT_TRUE(viewModel.logViewerVisible); + EXPECT_EQ(viewModel.modalTitle, "Log File"); + EXPECT_EQ(viewModel.logViewerPath, "E:\\UDATA\\12345678\\moonlight.log"); + EXPECT_EQ(viewModel.logViewerPlacement, app::LogViewerPlacement::right); + EXPECT_EQ(viewModel.logViewerScrollOffset, 1U); + ASSERT_EQ(viewModel.logViewerLines.size(), 2U); + EXPECT_EQ(viewModel.logViewerLines[0], "[000001] [INFO] app: Entered shell"); + } + + TEST(ShellViewTest, BuildsConfirmationModalFooterActionsForResetDialogs) { + app::ClientState state = app::create_initial_state(); + state.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.modalVisible); + EXPECT_EQ(viewModel.modalTitle, "Factory Reset"); + ASSERT_EQ(viewModel.modalFooterActions.size(), 2U); + EXPECT_EQ(viewModel.modalFooterActions[0].label, "OK"); + EXPECT_EQ(viewModel.modalFooterActions[0].iconAssetPath, "icons\\button-a.svg"); + EXPECT_EQ(viewModel.modalFooterActions[1].label, "Cancel"); + EXPECT_EQ(viewModel.modalFooterActions[1].iconAssetPath, "icons\\button-b.svg"); + } + + TEST(ShellViewTest, ShowsNotificationsOnTheSettingsScreen) { + app::ClientState state = app::create_initial_state(); + state.activeScreen = app::ScreenId::settings; + state.statusMessage = "Deleted saved file moonlight.log"; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.notificationVisible); + EXPECT_EQ(viewModel.notification.message, "Deleted saved file moonlight.log"); + } + + TEST(ShellViewTest, HidesThePairingPinUntilReachabilityHasBeenConfirmed) { + app::ClientState state = app::create_initial_state(); + state.activeScreen = app::ScreenId::pair_host; + state.pairingDraft = {"192.168.0.10", 47984, "1234", app::PairingStage::idle, "Checking whether the host is reachable before pairing begins."}; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + ASSERT_GE(viewModel.bodyLines.size(), 2U); + EXPECT_EQ(viewModel.bodyLines[0], "Target host: 192.168.0.10"); + EXPECT_EQ(viewModel.bodyLines[1], "Checking whether the host is reachable before showing a PIN."); + for (const std::string &line : viewModel.bodyLines) { + EXPECT_EQ(line.find("PIN:"), std::string::npos); } - EXPECT_TRUE(foundPairingAction); + EXPECT_FALSE(viewModel.notificationVisible); } TEST(ShellViewTest, AddsStatsAndRecentLogsToTheOverlayWhenVisible) { @@ -155,19 +358,6 @@ namespace { EXPECT_EQ(viewModel.overlayLines.front(), "Showing earlier log entries"); } - TEST(ShellViewTest, UsesScreenSpecificTextForTheSettingsScreen) { - app::ClientState state = app::create_initial_state(); - state.menu.handle_command(input::UiCommand::move_down); - state.menu.handle_command(input::UiCommand::move_down); - app::handle_command(state, input::UiCommand::activate); - - const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - - EXPECT_EQ(state.activeScreen, app::ScreenId::settings); - EXPECT_EQ(viewModel.title, "Settings"); - ASSERT_FALSE(viewModel.bodyLines.empty()); - EXPECT_NE(viewModel.bodyLines.front().find("Display, input, overlay, and logging settings"), std::string::npos); - } } // namespace diff --git a/xbe/assets/icons/add-host.svg b/xbe/assets/icons/add-host.svg new file mode 100644 index 0000000..4a9a64b --- /dev/null +++ b/xbe/assets/icons/add-host.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/xbe/assets/icons/button-a.svg b/xbe/assets/icons/button-a.svg new file mode 100644 index 0000000..e5eb9bb --- /dev/null +++ b/xbe/assets/icons/button-a.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/xbe/assets/icons/button-b.svg b/xbe/assets/icons/button-b.svg new file mode 100644 index 0000000..5438d17 --- /dev/null +++ b/xbe/assets/icons/button-b.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/xbe/assets/icons/button-lb.svg b/xbe/assets/icons/button-lb.svg new file mode 100644 index 0000000..3a3a99e --- /dev/null +++ b/xbe/assets/icons/button-lb.svg @@ -0,0 +1,7 @@ + + + + + LB + + diff --git a/xbe/assets/icons/button-lt.svg b/xbe/assets/icons/button-lt.svg new file mode 100644 index 0000000..e1f794b --- /dev/null +++ b/xbe/assets/icons/button-lt.svg @@ -0,0 +1,7 @@ + + + + + LT + + diff --git a/xbe/assets/icons/button-rb.svg b/xbe/assets/icons/button-rb.svg new file mode 100644 index 0000000..9c04df0 --- /dev/null +++ b/xbe/assets/icons/button-rb.svg @@ -0,0 +1,7 @@ + + + + + RB + + diff --git a/xbe/assets/icons/button-rt.svg b/xbe/assets/icons/button-rt.svg new file mode 100644 index 0000000..13e7822 --- /dev/null +++ b/xbe/assets/icons/button-rt.svg @@ -0,0 +1,8 @@ + + + + + + RT + + diff --git a/xbe/assets/icons/button-select.svg b/xbe/assets/icons/button-select.svg new file mode 100644 index 0000000..e7bd90c --- /dev/null +++ b/xbe/assets/icons/button-select.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xbe/assets/icons/button-start.svg b/xbe/assets/icons/button-start.svg new file mode 100644 index 0000000..d2d86c9 --- /dev/null +++ b/xbe/assets/icons/button-start.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/xbe/assets/icons/button-x.svg b/xbe/assets/icons/button-x.svg new file mode 100644 index 0000000..6b3f388 --- /dev/null +++ b/xbe/assets/icons/button-x.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/xbe/assets/icons/button-y.svg b/xbe/assets/icons/button-y.svg new file mode 100644 index 0000000..117fadf --- /dev/null +++ b/xbe/assets/icons/button-y.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/xbe/assets/icons/gear.svg b/xbe/assets/icons/gear.svg new file mode 100644 index 0000000..a3adfc3 --- /dev/null +++ b/xbe/assets/icons/gear.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/xbe/assets/icons/host-monitor-offline.svg b/xbe/assets/icons/host-monitor-offline.svg new file mode 100644 index 0000000..6d02c2a --- /dev/null +++ b/xbe/assets/icons/host-monitor-offline.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/xbe/assets/icons/host-monitor-online.svg b/xbe/assets/icons/host-monitor-online.svg new file mode 100644 index 0000000..2456bd3 --- /dev/null +++ b/xbe/assets/icons/host-monitor-online.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/xbe/assets/icons/host-monitor-pairing.svg b/xbe/assets/icons/host-monitor-pairing.svg new file mode 100644 index 0000000..1ae6a19 --- /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..820b0f5 --- /dev/null +++ b/xbe/assets/icons/support.svg @@ -0,0 +1,10 @@ + + + + + + + + + + From 64d95e07944ed07803833a2c377f6a5c88c7f6eb Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 11:27:42 -0400 Subject: [PATCH 04/35] style: clang format --- src/_nxdk_compat/openssl_compat.h | 103 +++++------- src/_nxdk_compat/poll_compat.cpp | 14 +- src/_nxdk_compat/stat_compat.cpp | 47 +++--- src/app/client_state.cpp | 46 ++---- src/app/client_state.h | 1 + src/app/host_records.cpp | 19 +-- src/app/host_records.h | 1 - src/app/pairing_flow.cpp | 1 - src/app/pairing_flow.h | 1 - src/logging/log_file.cpp | 1 - src/logging/log_file.h | 1 - src/logging/logger.cpp | 28 +--- src/logging/logger.h | 6 +- src/main.cpp | 5 +- src/network/host_pairing.cpp | 148 ++++++----------- src/network/runtime_network.cpp | 10 +- src/network/runtime_network.h | 1 - src/splash/splash_screen.h | 2 +- src/startup/cover_art_cache.cpp | 25 ++- src/startup/cover_art_cache.h | 1 - src/startup/host_storage.cpp | 23 ++- src/startup/host_storage.h | 14 +- src/startup/saved_files.cpp | 5 +- src/startup/saved_files.h | 2 - src/startup/video_mode.cpp | 10 +- src/ui/menu_model.cpp | 8 +- src/ui/menu_model.h | 5 +- src/ui/shell_screen.cpp | 132 +++++----------- src/ui/shell_view.cpp | 75 +++------ tests/unit/app/client_state_test.cpp | 149 ++++++++++-------- tests/unit/logging/log_file_test.cpp | 1 - tests/unit/network/host_pairing_test.cpp | 16 +- .../startup/client_identity_storage_test.cpp | 7 +- tests/unit/startup/cover_art_cache_test.cpp | 5 +- tests/unit/startup/host_storage_test.cpp | 4 +- tests/unit/startup/saved_files_test.cpp | 7 +- tests/unit/streaming/stats_overlay_test.cpp | 4 +- tests/unit/ui/menu_model_test.cpp | 1 - tests/unit/ui/shell_view_test.cpp | 62 ++++---- 39 files changed, 387 insertions(+), 604 deletions(-) diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h index 9a0f5a5..ae0a040 100644 --- a/src/_nxdk_compat/openssl_compat.h +++ b/src/_nxdk_compat/openssl_compat.h @@ -1,39 +1,39 @@ #pragma once #ifndef __STDC_WANT_LIB_EXT1__ -#define __STDC_WANT_LIB_EXT1__ 1 + #define __STDC_WANT_LIB_EXT1__ 1 #endif #include +#include #include #include -#include #ifdef __cplusplus extern "C" { #endif -ssize_t lwip_recv(int s, void *mem, size_t len, int flags); -ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags); + ssize_t lwip_recv(int s, void *mem, size_t len, int flags); + ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags); #ifndef F_OK -#define F_OK 0 + #define F_OK 0 #endif #ifndef R_OK -#define R_OK 4 + #define R_OK 4 #endif #ifndef W_OK -#define W_OK 2 + #define W_OK 2 #endif #ifndef X_OK -#define X_OK 1 + #define X_OK 1 #endif #ifndef AF_UNIX -#define AF_UNIX (-1) + #define AF_UNIX (-1) #endif #define access moonlight_nxdk_openssl_access @@ -53,103 +53,86 @@ ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags); #define getgid moonlight_nxdk_openssl_getgid #define getegid moonlight_nxdk_openssl_getegid -static inline int moonlight_nxdk_openssl_access(const char *path, int mode) -{ + static inline int moonlight_nxdk_openssl_access(const char *path, int mode) { (void) path; (void) mode; return -1; -} + } -static inline int moonlight_nxdk_openssl_fileno(FILE *stream) -{ + static inline int moonlight_nxdk_openssl_fileno(FILE *stream) { (void) stream; return -1; -} + } -static inline ssize_t moonlight_nxdk_openssl_read(int fd, void *buffer, size_t count) -{ + static inline ssize_t moonlight_nxdk_openssl_read(int fd, void *buffer, size_t count) { return lwip_recv(fd, buffer, count, 0); -} + } -static inline ssize_t moonlight_nxdk_openssl_write(int fd, const void *buffer, size_t count) -{ + static inline ssize_t moonlight_nxdk_openssl_write(int fd, const void *buffer, size_t count) { return lwip_send(fd, buffer, count, 0); -} + } -static inline int moonlight_nxdk_openssl_close(int fd) -{ + static inline int moonlight_nxdk_openssl_close(int fd) { (void) fd; return -1; -} + } -static inline int moonlight_nxdk_openssl__close(int fd) -{ + static inline int moonlight_nxdk_openssl__close(int fd) { return moonlight_nxdk_openssl_close(fd); -} + } -static inline int moonlight_nxdk_openssl_open(const char *path, int flags, ...) -{ + static inline int moonlight_nxdk_openssl_open(const char *path, int flags, ...) { (void) path; (void) flags; return -1; -} + } -static inline int moonlight_nxdk_openssl__open(const char *path, int flags, ...) -{ + static inline int moonlight_nxdk_openssl__open(const char *path, int flags, ...) { (void) path; (void) flags; return -1; -} + } -static inline FILE *moonlight_nxdk_openssl_fdopen(int fd, const char *mode) -{ + static inline FILE *moonlight_nxdk_openssl_fdopen(int fd, const char *mode) { (void) fd; (void) mode; return NULL; -} + } -static inline FILE *moonlight_nxdk_openssl__fdopen(int fd, const char *mode) -{ + static inline FILE *moonlight_nxdk_openssl__fdopen(int fd, const char *mode) { return moonlight_nxdk_openssl_fdopen(fd, mode); -} + } -static inline int moonlight_nxdk_openssl__unlink(const char *path) -{ + static inline int moonlight_nxdk_openssl__unlink(const char *path) { (void) path; return -1; -} + } -static inline int moonlight_nxdk_openssl_chmod(const char *path, int mode) -{ + static inline int moonlight_nxdk_openssl_chmod(const char *path, int mode) { (void) path; (void) mode; return -1; -} + } -static inline unsigned int moonlight_nxdk_openssl_getuid(void) -{ + static inline unsigned int moonlight_nxdk_openssl_getuid(void) { return 0; -} + } -static inline unsigned int moonlight_nxdk_openssl_geteuid(void) -{ + static inline unsigned int moonlight_nxdk_openssl_geteuid(void) { return 0; -} + } -static inline unsigned int moonlight_nxdk_openssl_getgid(void) -{ + static inline unsigned int moonlight_nxdk_openssl_getgid(void) { return 0; -} + } -static inline unsigned int moonlight_nxdk_openssl_getegid(void) -{ + static inline unsigned int moonlight_nxdk_openssl_getegid(void) { return 0; -} + } -static inline int moonlight_nxdk_openssl_gmtime_s(struct tm *result, const time_t *timer) -{ + static inline int moonlight_nxdk_openssl_gmtime_s(struct tm *result, const time_t *timer) { return gmtime_s(timer, result); -} + } #define gmtime_s moonlight_nxdk_openssl_gmtime_s diff --git a/src/_nxdk_compat/poll_compat.cpp b/src/_nxdk_compat/poll_compat.cpp index a1a383e..97a2ffd 100644 --- a/src/_nxdk_compat/poll_compat.cpp +++ b/src/_nxdk_compat/poll_compat.cpp @@ -1,14 +1,12 @@ #ifdef NXDK -#include -#include + #include + #include + #include + #include + #include -#include -#include -#include - -extern "C" int poll(struct pollfd *fds, nfds_t nfds, int timeout) -{ +extern "C" int poll(struct pollfd *fds, nfds_t nfds, int timeout) { if (fds == nullptr && nfds != 0) { errno = EINVAL; return -1; diff --git a/src/_nxdk_compat/stat_compat.cpp b/src/_nxdk_compat/stat_compat.cpp index 26146ea..385c9fd 100644 --- a/src/_nxdk_compat/stat_compat.cpp +++ b/src/_nxdk_compat/stat_compat.cpp @@ -1,42 +1,37 @@ #ifdef NXDK -#include - -#include + #include + #include extern "C" { -int stat(const char *path, struct stat *status) -{ - (void) path; + int stat(const char *path, struct stat *status) { + (void) path; + + if (status != nullptr) { + std::memset(status, 0, sizeof(*status)); + } - if (status != nullptr) { - std::memset(status, 0, sizeof(*status)); + return -1; } - return -1; -} + int fstat(int fd, struct stat *status) { + (void) fd; -int fstat(int fd, struct stat *status) -{ - (void) fd; + if (status != nullptr) { + std::memset(status, 0, sizeof(*status)); + } - if (status != nullptr) { - std::memset(status, 0, sizeof(*status)); + return 0; } - return 0; -} - -int _stat(const char *path, struct stat *status) -{ - return stat(path, status); -} + int _stat(const char *path, struct stat *status) { + return stat(path, status); + } -int _fstat(int fd, struct stat *status) -{ - return fstat(fd, status); -} + int _fstat(int fd, struct stat *status) { + return fstat(fd, status); + } } // extern "C" diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 5c7e546..a0db14f 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -2,14 +2,14 @@ #include "src/app/client_state.h" // standard includes +#include "src/network/host_pairing.h" + #include #include #include #include #include -#include "src/network/host_pairing.h" - namespace { constexpr std::size_t OVERLAY_SCROLL_STEP = 4U; @@ -287,9 +287,7 @@ namespace { } 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 {}; + 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; @@ -298,9 +296,7 @@ namespace { state.menu.select_item_by_id(previousSelection); } - const std::string previousDetailSelection = preserveSelection && state.detailMenu.selected_item() != nullptr - ? state.detailMenu.selected_item()->id - : std::string {}; + 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; @@ -409,8 +405,7 @@ namespace { if (state.addHostDraft.activeField == app::AddHostField::address) { state.addHostDraft.addressInput = state.addHostDraft.keypad.stagedInput; state.statusMessage = "Updated host address"; - } - else { + } else { state.addHostDraft.portInput = state.addHostDraft.keypad.stagedInput; state.statusMessage = state.addHostDraft.portInput.empty() ? "Using default Moonlight host port 47989" : "Updated host port"; } @@ -510,9 +505,7 @@ namespace { void move_toolbar_selection(app::ClientState &state, int direction) { const std::size_t current = state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT; - state.selectedToolbarButtonIndex = direction < 0 - ? (current + HOST_TOOLBAR_BUTTON_COUNT - 1U) % HOST_TOOLBAR_BUTTON_COUNT - : (current + 1U) % HOST_TOOLBAR_BUTTON_COUNT; + state.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) { @@ -806,8 +799,7 @@ namespace { if (host->pairingState == app::PairingState::paired) { update->appsBrowseRequested = true; update->appsBrowseShowHidden = true; - } - else { + } else { if (enter_pair_host_screen(state, host->address, host->port)) { update->screenChanged = true; update->pairingRequested = true; @@ -1079,8 +1071,7 @@ namespace app { mergedApps.push_back(std::move(appRecord)); } host->apps = std::move(mergedApps); - } - else { + } else { refresh_running_flags(host); } @@ -1261,14 +1252,11 @@ namespace app { update.activatedItemId = categoryUpdate.activatedItemId; if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::logging)) { state.selectedSettingsCategory = SettingsCategory::logging; - } - else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::display)) { + } else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::display)) { state.selectedSettingsCategory = SettingsCategory::display; - } - else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::input)) { + } else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::input)) { state.selectedSettingsCategory = SettingsCategory::input; - } - else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::reset)) { + } else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::reset)) { state.selectedSettingsCategory = SettingsCategory::reset; } rebuild_menu(state); @@ -1335,16 +1323,14 @@ namespace app { case input::UiCommand::move_left: if (state.hostsFocusArea == HostsFocusArea::toolbar) { move_toolbar_selection(state, -1); - } - else { + } else { move_host_grid_selection(state, 0, -1); } return update; case input::UiCommand::move_right: if (state.hostsFocusArea == HostsFocusArea::toolbar) { move_toolbar_selection(state, 1); - } - else { + } else { move_host_grid_selection(state, 0, 1); } return update; @@ -1353,8 +1339,7 @@ namespace app { if (!state.hosts.empty()) { state.hostsFocusArea = HostsFocusArea::grid; } - } - else { + } else { move_host_grid_selection(state, 1, 0); } return update; @@ -1395,8 +1380,7 @@ namespace app { if (host->pairingState == PairingState::paired) { update.appsBrowseRequested = true; update.appsBrowseShowHidden = false; - } - else { + } else { if (enter_pair_host_screen(state, host->address, host->port)) { update.screenChanged = true; update.pairingRequested = true; diff --git a/src/app/client_state.h b/src/app/client_state.h index de9040d..8439ae8 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -119,6 +119,7 @@ namespace app { struct ModalState { ModalId id = ModalId::none; std::size_t selectedActionIndex = 0; + bool active() const { return id != ModalId::none; } diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index 3f1f561..e254ca6 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -172,8 +172,7 @@ namespace app { 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) { + } else if (normalizedAddress != record.address) { return append_error(errorMessage, "Host address must already be normalized before saving"); } @@ -215,9 +214,7 @@ namespace app { 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); + 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); @@ -227,8 +224,7 @@ namespace app { const std::vector fields = split_string_view(line, '\t'); if (fields.size() != 3 && fields.size() != 4) { result.errors.push_back("Line " + std::to_string(lineNumber) + " must contain three or four tab-separated fields"); - } - else { + } else { uint16_t port = 0; PairingState pairingState = PairingState::not_paired; const std::string_view pairingField = fields.size() == 4 ? fields[3] : fields[2]; @@ -239,11 +235,9 @@ namespace app { if (pairingField == "not_paired") { pairingState = PairingState::not_paired; - } - else if (pairingField == "paired") { + } else if (pairingField == "paired") { pairingState = PairingState::paired; - } - else { + } else { result.errors.push_back("Line " + std::to_string(lineNumber) + " uses an unknown pairing state"); pairingState = PairingState::not_paired; } @@ -258,8 +252,7 @@ namespace app { std::string errorMessage; if (validate_host_record(record, &errorMessage)) { result.records.push_back(std::move(record)); - } - else { + } else { result.errors.push_back("Line " + std::to_string(lineNumber) + ": " + errorMessage); } } diff --git a/src/app/host_records.h b/src/app/host_records.h index 8d0c367..51107bb 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -178,4 +178,3 @@ namespace app { ParseHostRecordsResult parse_host_records(std::string_view serializedRecords); } // namespace app - diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp index 2cc3a06..86955ad 100644 --- a/src/app/pairing_flow.cpp +++ b/src/app/pairing_flow.cpp @@ -29,4 +29,3 @@ namespace app { } } // namespace app - diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h index f253d36..c21456c 100644 --- a/src/app/pairing_flow.h +++ b/src/app/pairing_flow.h @@ -47,4 +47,3 @@ namespace app { bool is_valid_pairing_pin(const std::string &pin); } // namespace app - diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp index e178a0c..c7fb130 100644 --- a/src/logging/log_file.cpp +++ b/src/logging/log_file.cpp @@ -205,4 +205,3 @@ namespace logging { } } // namespace logging - diff --git a/src/logging/log_file.h b/src/logging/log_file.h index d281e19..2bc68d0 100644 --- a/src/logging/log_file.h +++ b/src/logging/log_file.h @@ -26,4 +26,3 @@ namespace logging { 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 index 41d913c..d6ec87e 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -3,12 +3,12 @@ // standard includes #include -#include #include +#include #include #if defined(_WIN32) -#include + #include #endif namespace { @@ -34,11 +34,11 @@ namespace { 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) + #if defined(_MSC_VER) localtime_s(&localTime, &nowTime); -#else + #else localtime_r(&nowTime, &localTime); -#endif + #endif const auto milliseconds = std::chrono::duration_cast(now.time_since_epoch()) % 1000; return { localTime.tm_year + 1900, @@ -53,19 +53,7 @@ namespace { } 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; + 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 @@ -115,8 +103,8 @@ namespace logging { return std::string("[") + to_string(entry.level) + "] " + entry.category + ": " + entry.message; } - Logger::Logger(std::size_t capacity, TimestampProvider timestampProvider) - : capacity_(capacity == 0 ? 1 : capacity), + Logger::Logger(std::size_t capacity, TimestampProvider timestampProvider): + capacity_(capacity == 0 ? 1 : capacity), minimumLevel_(LogLevel::info), nextSequence_(1), timestampProvider_(timestampProvider ? std::move(timestampProvider) : TimestampProvider(current_local_timestamp)) {} diff --git a/src/logging/logger.h b/src/logging/logger.h index ec74e6d..43a7b79 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -73,13 +73,13 @@ namespace logging { * @brief Small in-memory logger with a ring buffer and optional sinks. */ class Logger { - public: + public: /** * @brief Construct a logger with the provided entry capacity. * * @param capacity Maximum number of retained entries. */ - explicit Logger(std::size_t capacity = 256, TimestampProvider timestampProvider = {}); + explicit Logger(std::size_t capacity = 256, TimestampProvider timestampProvider = {}); /** * @brief Return the maximum number of retained entries. @@ -136,7 +136,7 @@ namespace logging { */ std::vector snapshot(LogLevel minimumLevel = LogLevel::trace) const; - private: + private: std::size_t capacity_; LogLevel minimumLevel_; uint64_t nextSequence_; diff --git a/src/main.cpp b/src/main.cpp index 199d6ce..50cfc36 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,6 @@ // nxdk includes -#include #include +#include #include // standard includes @@ -144,8 +144,7 @@ int main() { if (saveResult.success) { logger.log(logging::LogLevel::info, "hosts", "Saved host records before exit"); clientState.hostsDirty = false; - } - else { + } else { logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); } } diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index f888125..5b76a25 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -2,13 +2,13 @@ #include "src/network/host_pairing.h" // standard includes -#include #include #include +#include #include #include -#include #include +#include #include #include #include @@ -17,27 +17,27 @@ // platform includes #ifdef NXDK -#include -#include -#include + #include + #include + #include #else -#include -#include + #include + #include #endif // nxdk includes #ifdef NXDK -#include + #include using SOCKET = int; -#ifndef INVALID_SOCKET -#define INVALID_SOCKET (-1) -#endif + #ifndef INVALID_SOCKET + #define INVALID_SOCKET (-1) + #endif -#ifndef SOCKET_ERROR -#define SOCKET_ERROR (-1) -#endif + #ifndef SOCKET_ERROR + #define SOCKET_ERROR (-1) + #endif #endif #define OPENSSL_SUPPRESS_DEPRECATED @@ -57,7 +57,7 @@ using SOCKET = int; #include "src/network/runtime_network.h" #ifdef NXDK -#define _CRT_RAND_S + #define _CRT_RAND_S #endif #ifdef NXDK @@ -85,8 +85,8 @@ namespace { constexpr std::string_view UNPAIRED_CLIENT_ERROR_MESSAGE = "The host reports that this client is no longer paired. Pair the host again from Sunshine."; struct WsaGuard { - WsaGuard() - : initialized(false) { + WsaGuard(): + initialized(false) { #ifdef NXDK initialized = true; #else @@ -107,8 +107,8 @@ namespace { }; struct SocketGuard { - SocketGuard() - : handle(INVALID_SOCKET) { + SocketGuard(): + handle(INVALID_SOCKET) { } ~SocketGuard() { @@ -305,17 +305,13 @@ namespace { const unsigned char character = static_cast(text[index]); if (character >= 0x20U && character <= 0x7EU) { preview.push_back(static_cast(character)); - } - else if (character == '\r') { + } else if (character == '\r') { preview += "\\r"; - } - else if (character == '\n') { + } else if (character == '\n') { preview += "\\n"; - } - else if (character == '\t') { + } else if (character == '\t') { preview += "\\t"; - } - else { + } else { char buffer[5] = {}; std::snprintf(buffer, sizeof(buffer), "\\x%02X", character); preview += buffer; @@ -528,8 +524,7 @@ namespace { if (!hasContentLength) { return append_error(errorMessage, "Received an invalid Content-Length header while pairing"); } - } - else if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked")) { + } else if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked")) { isChunked = true; } } @@ -1157,11 +1152,9 @@ namespace { if (inString) { if (escaped) { escaped = false; - } - else if (character == '\\') { + } else if (character == '\\') { escaped = true; - } - else if (character == '"') { + } else if (character == '"') { inString = false; } continue; @@ -1298,20 +1291,17 @@ namespace { return false; } valueEnd = keyCursor; - } - else if (object[keyCursor] == '{') { + } else if (object[keyCursor] == '{') { if (!find_matching_json_delimiter(object, keyCursor, '{', '}', &valueEnd)) { return false; } keyCursor = valueEnd + 1U; - } - else if (object[keyCursor] == '[') { + } else if (object[keyCursor] == '[') { if (!find_matching_json_delimiter(object, keyCursor, '[', ']', &valueEnd)) { return false; } keyCursor = valueEnd + 1U; - } - else { + } else { while (keyCursor < object.size() && object[keyCursor] != ',' && object[keyCursor] != '}') { ++keyCursor; } @@ -1354,8 +1344,7 @@ namespace { } return true; } - } - else if (!rawValue.empty()) { + } else if (!rawValue.empty()) { if (value != nullptr) { *value = std::string(trim_ascii_whitespace(rawValue)); } @@ -1397,8 +1386,7 @@ namespace { std::string_view appArray; if (!trimmed.empty() && trimmed.front() == '[') { appArray = trimmed; - } - else { + } else { for (std::string_view fieldName : {std::string_view("apps"), std::string_view("Applications"), std::string_view("applications"), std::string_view("games"), std::string_view("Games"), std::string_view("applist"), std::string_view("data")}) { if (extract_json_named_array(trimmed, fieldName, &appArray)) { break; @@ -1458,9 +1446,7 @@ namespace { uint32_t statusCode = 200; if (!statusCodeText.empty() && try_parse_uint32(trim_ascii_whitespace(statusCodeText), &statusCode) && statusCode != 200U) { - const std::string normalizedStatusMessage = statusMessage.empty() - ? "The host returned Sunshine status " + std::to_string(statusCode) + " while requesting /applist" - : statusMessage; + const std::string normalizedStatusMessage = statusMessage.empty() ? "The host returned Sunshine status " + std::to_string(statusCode) + " while requesting /applist" : statusMessage; return append_error(errorMessage, normalizedStatusMessage); } @@ -1679,9 +1665,7 @@ namespace { 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)); + 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; @@ -1725,9 +1709,7 @@ namespace { } 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) + ")"); + 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, buffer + bytesRead); @@ -1769,9 +1751,7 @@ namespace { 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"); + 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, buffer + bytesRead); @@ -1941,8 +1921,11 @@ namespace { } const std::string request = - "GET " + std::string(pathAndQuery) + " HTTP/1.1\r\n" - "Host: " + address + ":" + std::to_string(port) + "\r\n" + "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"; @@ -1952,8 +1935,7 @@ namespace { if (!send_all_plain(socketGuard.handle, request, errorMessage, cancelRequested) || !recv_all_plain(socketGuard.handle, &rawResponse, errorMessage, cancelRequested)) { return false; } - } - else { + } else { if (pairing_cancel_requested(cancelRequested)) { return append_cancelled_pairing_error(errorMessage); } @@ -2223,8 +2205,7 @@ namespace network { std::string activeAddress; if (!localAddress.empty()) { activeAddress = localAddress; - } - else if (!remoteAddress.empty()) { + } else if (!remoteAddress.empty()) { activeAddress = remoteAddress; } @@ -2267,24 +2248,8 @@ namespace network { 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); + 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) { @@ -2295,18 +2260,8 @@ namespace network { 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); + 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); @@ -2380,12 +2335,7 @@ namespace network { 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; + 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) { @@ -2534,9 +2484,7 @@ namespace network { return true; } - result.message = errorMessage != nullptr && !errorMessage->empty() - ? *errorMessage - : "Failed to generate the UUID used for pairing"; + result.message = errorMessage != nullptr && !errorMessage->empty() ? *errorMessage : "Failed to generate the UUID used for pairing"; return false; }; diff --git a/src/network/runtime_network.cpp b/src/network/runtime_network.cpp index adb293e..b876bab 100644 --- a/src/network/runtime_network.cpp +++ b/src/network/runtime_network.cpp @@ -1,12 +1,11 @@ // class header include #include "src/network/runtime_network.h" - // nxdk includes #ifdef NXDK -#include -#include -#include + #include + #include + #include extern "C" struct netif *g_pnetif; #endif @@ -93,8 +92,7 @@ namespace network { 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) { + } 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"; diff --git a/src/network/runtime_network.h b/src/network/runtime_network.h index 9c4622b..aaa3363 100644 --- a/src/network/runtime_network.h +++ b/src/network/runtime_network.h @@ -23,7 +23,6 @@ namespace network { bool runtime_network_ready(); - std::string describe_runtime_network_initialization_code(int initializationCode); std::vector format_runtime_network_status_lines(const RuntimeNetworkStatus &status); diff --git a/src/splash/splash_screen.h b/src/splash/splash_screen.h index c2b173c..16299ad 100644 --- a/src/splash/splash_screen.h +++ b/src/splash/splash_screen.h @@ -14,4 +14,4 @@ namespace splash { void show_splash_screen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing); -} +} // namespace splash diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp index 90f658e..bdc8274 100644 --- a/src/startup/cover_art_cache.cpp +++ b/src/startup/cover_art_cache.cpp @@ -3,9 +3,9 @@ // standard includes #include +#include #include #include -#include #include #include @@ -15,15 +15,15 @@ extern "C" { // nxdk includes #if defined(__has_include) -#if __has_include() -#include -#include -#define MOONLIGHT_HAS_NXDK_XBE 1 -#endif -#if __has_include() -#include -#define MOONLIGHT_HAS_NXDK_MOUNT 1 -#endif + #if __has_include() + #include + #include + #define MOONLIGHT_HAS_NXDK_XBE 1 + #endif + #if __has_include() + #include + #define MOONLIGHT_HAS_NXDK_MOUNT 1 + #endif #endif namespace { @@ -90,11 +90,11 @@ namespace { std::string title_scoped_storage_root() { #ifdef MOONLIGHT_HAS_NXDK_XBE -#ifdef MOONLIGHT_HAS_NXDK_MOUNT + #ifdef MOONLIGHT_HAS_NXDK_MOUNT if (!nxIsDriveMounted('E') && !nxMountDrive('E', "\\Device\\Harddisk0\\Partition1\\")) { return {}; } -#endif + #endif char titleIdBuffer[9] = {}; std::snprintf(titleIdBuffer, sizeof(titleIdBuffer), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); @@ -222,4 +222,3 @@ namespace startup { } } // namespace startup - diff --git a/src/startup/cover_art_cache.h b/src/startup/cover_art_cache.h index c973d4f..2300daa 100644 --- a/src/startup/cover_art_cache.h +++ b/src/startup/cover_art_cache.h @@ -29,4 +29,3 @@ namespace startup { 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 index 671c84c..5c3aefb 100644 --- a/src/startup/host_storage.cpp +++ b/src/startup/host_storage.cpp @@ -14,15 +14,15 @@ extern "C" { // nxdk includes #if defined(__has_include) -#if __has_include() -#include -#include -#define MOONLIGHT_HAS_NXDK_XBE 1 -#endif -#if __has_include() -#include -#define MOONLIGHT_HAS_NXDK_MOUNT 1 -#endif + #if __has_include() + #include + #include + #define MOONLIGHT_HAS_NXDK_XBE 1 + #endif + #if __has_include() + #include + #define MOONLIGHT_HAS_NXDK_MOUNT 1 + #endif #endif namespace { @@ -107,11 +107,11 @@ namespace { std::string title_scoped_storage_root() { #ifdef MOONLIGHT_HAS_NXDK_XBE -#ifdef MOONLIGHT_HAS_NXDK_MOUNT + #ifdef MOONLIGHT_HAS_NXDK_MOUNT if (!nxIsDriveMounted('E') && !nxMountDrive('E', "\\Device\\Harddisk0\\Partition1\\")) { return {}; } -#endif + #endif char titleIdBuffer[9] = {}; std::snprintf(titleIdBuffer, sizeof(titleIdBuffer), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); @@ -185,4 +185,3 @@ namespace startup { } } // namespace startup - diff --git a/src/startup/host_storage.h b/src/startup/host_storage.h index 0cc232a..3c022c7 100644 --- a/src/startup/host_storage.h +++ b/src/startup/host_storage.h @@ -10,14 +10,14 @@ namespace startup { struct LoadSavedHostsResult { - std::vector hosts; - std::vector warnings; - bool fileFound; + std::vector hosts; + std::vector warnings; + bool fileFound; }; struct SaveSavedHostsResult { - bool success; - std::string errorMessage; + bool success; + std::string errorMessage; }; std::string default_host_storage_path(); @@ -25,8 +25,8 @@ namespace startup { LoadSavedHostsResult load_saved_hosts(const std::string &filePath = default_host_storage_path()); SaveSavedHostsResult save_saved_hosts( - const std::vector &hosts, - const std::string &filePath = default_host_storage_path() + const std::vector &hosts, + const std::string &filePath = default_host_storage_path() ); } // namespace startup diff --git a/src/startup/saved_files.cpp b/src/startup/saved_files.cpp index 1caa8ad..2616854 100644 --- a/src/startup/saved_files.cpp +++ b/src/startup/saved_files.cpp @@ -3,8 +3,8 @@ // standard includes #include -#include #include +#include #include #include #include @@ -257,6 +257,3 @@ namespace startup { } } // namespace startup - - - diff --git a/src/startup/saved_files.h b/src/startup/saved_files.h index 5a5a3cd..020c6d0 100644 --- a/src/startup/saved_files.h +++ b/src/startup/saved_files.h @@ -69,5 +69,3 @@ namespace startup { ); } // namespace startup - - diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index 467e34a..924c0d5 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -58,17 +58,11 @@ namespace startup { lines.emplace_back("Available video modes:"); for (const VIDEO_MODE &availableVideoMode : selection.availableVideoModes) { 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) + "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) + "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; } diff --git a/src/ui/menu_model.cpp b/src/ui/menu_model.cpp index 737d5a8..0d91748 100644 --- a/src/ui/menu_model.cpp +++ b/src/ui/menu_model.cpp @@ -6,8 +6,8 @@ namespace ui { - MenuModel::MenuModel(std::vector items) - : selectedIndex_(npos) { + MenuModel::MenuModel(std::vector items): + selectedIndex_(npos) { set_items(std::move(items)); } @@ -93,9 +93,7 @@ namespace ui { std::size_t candidateIndex = selectedIndex_; for (std::size_t visited = 0; visited < itemCount; ++visited) { - candidateIndex = direction < 0 - ? (candidateIndex + itemCount - 1) % itemCount - : (candidateIndex + 1) % itemCount; + candidateIndex = direction < 0 ? (candidateIndex + itemCount - 1) % itemCount : (candidateIndex + 1) % itemCount; if (items_[candidateIndex].enabled) { const bool changed = candidateIndex != selectedIndex_; diff --git a/src/ui/menu_model.h b/src/ui/menu_model.h index e70b38d..28f1126 100644 --- a/src/ui/menu_model.h +++ b/src/ui/menu_model.h @@ -37,7 +37,7 @@ namespace ui { * @brief Menu state that supports controller and keyboard navigation. */ class MenuModel { - public: + public: static constexpr std::size_t npos = std::numeric_limits::max(); /** @@ -85,7 +85,7 @@ namespace ui { */ MenuUpdate handle_command(input::UiCommand command); - private: + private: bool move_selection(int direction); std::size_t find_first_enabled_index() const; @@ -94,4 +94,3 @@ namespace ui { }; } // namespace ui - diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 6e378d0..2837c2a 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -14,10 +14,10 @@ #include // nxdk includes +#include #include #include #include -#include #include // local includes @@ -26,8 +26,8 @@ #include "src/network/host_pairing.h" #include "src/network/runtime_network.h" #include "src/os.h" -#include "src/startup/cover_art_cache.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/shell_view.h" @@ -355,11 +355,9 @@ namespace { std::size_t sequenceLength = 0U; if ((character & 0xE0U) == 0xC0U) { sequenceLength = 2U; - } - else if ((character & 0xF0U) == 0xE0U) { + } else if ((character & 0xF0U) == 0xE0U) { sequenceLength = 3U; - } - else if ((character & 0xF8U) == 0xF0U) { + } else if ((character & 0xF8U) == 0xF0U) { sequenceLength = 4U; } @@ -731,8 +729,7 @@ namespace { 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 { + } else { destination.w = std::max(1, (textureWidth * rect.h) / textureHeight); destination.x = rect.x + std::max(0, (rect.w - destination.w) / 2); } @@ -755,8 +752,7 @@ namespace { 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 { + } else { source.h = std::max(1, (textureWidth * rect.h) / rect.w); source.y = std::max(0, (textureHeight - source.h) / 2); } @@ -899,8 +895,7 @@ namespace { if (!render_texture_fill(renderer, texture, rect)) { return false; } - } - else { + } else { if (!render_default_app_cover(renderer, labelFont, tile, rect, assetCache)) { return false; } @@ -923,8 +918,7 @@ namespace { if (tile.selected) { draw_rect(renderer, rect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); - } - else { + } else { draw_rect(renderer, rect, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x28); } return true; @@ -949,11 +943,7 @@ namespace { 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 + renderer == nullptr || trackRect.w <= 0 || trackRect.h <= 0 || totalItemCount <= visibleItemCount || visibleItemCount <= 0 ) { return; } @@ -1078,8 +1068,7 @@ namespace { 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 { + } else { for (const std::string *line : logViewerLayout.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)) { @@ -1156,9 +1145,7 @@ namespace { } const std::string label = row.checked ? "[x] " + row.label : row.label; - const SDL_Color color = row.enabled - ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} - : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; + const SDL_Color color = row.enabled ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; if (!render_text_line_simple(renderer, font, label, color, rowRect.x + 12, rowRect.y + 8, rowRect.w - 24)) { return false; } @@ -1190,8 +1177,7 @@ namespace { 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 { + } 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); } @@ -1693,12 +1679,9 @@ namespace { } if (message != nullptr) { - *message = "Received /serverinfo from " + address + ":" + std::to_string(serverInfo.httpPort) - + " and discovered HTTPS pairing on port " + std::to_string(serverInfo.httpsPort); + *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"; + *message += serverInfo.pairingStatusCurrentClient ? "; the current client is paired and authorized" : "; the current client is no longer paired or authorized"; } } return true; @@ -1711,10 +1694,12 @@ namespace { std::atomic cancelRequested; network::HostPairingRequest request; network::HostPairingResult result; + struct DeferredLogEntry { logging::LogLevel level; std::string message; }; + std::vector deferredLogs; }; @@ -1940,9 +1925,7 @@ namespace { task->result = { false, false, - identityError.empty() - ? "Failed to generate a valid client pairing identity" - : "Failed to generate a valid client pairing identity: " + identityError, + identityError.empty() ? "Failed to generate a valid client pairing identity" : "Failed to generate a valid client pairing identity: " + identityError, }; task->completed.store(true, std::memory_order_release); return 0; @@ -2011,8 +1994,7 @@ namespace { const bool success = test_tcp_host_connection(address, port, clientIdentityPointer, &resultMessage, &serverInfo); if (success) { apply_server_info_to_host(state, address, port, serverInfo); - } - else { + } else { for (app::HostRecord &host : state.hosts) { if (host.address == address && app::effective_host_port(host.port) == port) { host.reachability = app::HostReachability::offline; @@ -2065,9 +2047,7 @@ namespace { host = app::selected_host(state); if (host == nullptr || host->pairingState != app::PairingState::paired) { - state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() - ? host->appListStatusMessage - : "This host is no longer paired. Pair it again from Sunshine before opening apps."; + state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again from Sunshine before opening apps."; logger.log(logging::LogLevel::warning, "apps", state.statusMessage); return; } @@ -2281,9 +2261,7 @@ namespace { }); } - task->message = task->apps.empty() - ? "Sunshine returned no launchable apps for this host" - : "Loaded " + std::to_string(task->apps.size()) + " Sunshine app(s)"; + task->message = task->apps.empty() ? "Sunshine returned no launchable apps for this host" : "Loaded " + std::to_string(task->apps.size()) + " Sunshine app(s)"; task->completed.store(true, std::memory_order_release); return 0; } @@ -2487,14 +2465,11 @@ namespace { std::vector lines = loadedLog.lines; if (!loadedLog.fileFound) { lines = {"The log file does not exist yet."}; - } - else if (lines.empty()) { + } 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"; + 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); logger.log(logging::LogLevel::info, "logging", statusMessage + ": " + loadedLog.filePath); } @@ -2565,9 +2540,7 @@ namespace { const int pageTitleX = headerRect.x + (headerRect.w / 3); const int pageTitleY = headerRect.y + 18; - const bool renderedPageTitle = viewModel.screen == app::ScreenId::apps - ? render_text_line_simple(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) - : render_text_line(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); + const bool renderedPageTitle = viewModel.screen == app::ScreenId::apps ? render_text_line_simple(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) : render_text_line(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); if (!viewModel.pageTitle.empty() && !renderedPageTitle) { return false; } @@ -2657,8 +2630,7 @@ namespace { if (viewport.scrollbarWidth > 0) { render_grid_scrollbar(renderer, {gridRect.x + gridRect.w - viewport.scrollbarWidth, gridRect.y, viewport.scrollbarWidth, gridRect.h}, viewport); } - } - else if (viewModel.screen == app::ScreenId::apps) { + } else if (viewModel.screen == app::ScreenId::apps) { const SDL_Rect gridRect { contentRect.x + 16, contentRect.y + 16, @@ -2666,7 +2638,6 @@ namespace { contentRect.h - 28, }; - if (!viewModel.appTiles.empty()) { const int columnCount = std::max(1, static_cast(viewModel.appColumnCount)); const int tileGap = 16; @@ -2702,8 +2673,7 @@ namespace { viewport ); } - } - else if (!viewModel.bodyLines.empty()) { + } else if (!viewModel.bodyLines.empty()) { const int lineGap = 8; int textHeight = 0; for (std::size_t index = 0; index < viewModel.bodyLines.size(); ++index) { @@ -2722,8 +2692,7 @@ namespace { messageY += drawnHeight + lineGap; } } - } - else { + } else { const int menuPanelWidth = std::max(228, (contentRect.w * 34) / 100); const SDL_Rect menuPanel {contentRect.x, contentRect.y, menuPanelWidth, contentRect.h}; const SDL_Rect bodyPanel {contentRect.x + menuPanelWidth + panelGap, contentRect.y, contentRect.w - menuPanelWidth - panelGap, contentRect.h}; @@ -2813,8 +2782,7 @@ namespace { 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.modalVisible) { + } else if (viewModel.modalVisible) { SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); fill_rect(renderer, {0, 0, screenWidth, screenHeight}, 0x00, 0x00, 0x00, 0xA6); const SDL_Rect modalRect { @@ -2842,8 +2810,7 @@ namespace { if (!render_action_rows(renderer, bodyFont, viewModel.modalActions, {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.modalFooterActions.empty()) { + } else if (!viewModel.modalFooterActions.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.modalFooterActions, modalFooterRect)) { return false; @@ -2910,15 +2877,12 @@ namespace { if (button.selected) { fill_rect(renderer, buttonRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0x55); - } - else { + } 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}; + 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 (!render_text_centered(renderer, bodyFont, button.label, buttonColor, buttonRect)) { return false; } @@ -3073,10 +3037,7 @@ namespace ui { start_app_art_task_if_needed(logger, state, &appArtTask); if ( - !controllerExitComboTriggered - && controllerStartPressed - && controllerBackPressed - && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) + !controllerExitComboTriggered && controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) ) { controllerExitComboArmed = true; const Uint32 comboStartTick = controllerStartDownTick > controllerBackDownTick ? controllerStartDownTick : controllerBackDownTick; @@ -3152,34 +3113,28 @@ namespace ui { controllerAContextTriggered = false; controllerADownTick = SDL_GetTicks(); } - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START) { if (!controllerStartPressed) { controllerStartPressed = true; controllerStartDownTick = SDL_GetTicks(); } - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) { if (!controllerBackPressed) { controllerBackPressed = true; controllerBackDownTick = SDL_GetTicks(); } - } - else { + } else { if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { leftShoulderPressed = true; leftShoulderRepeatTick = SDL_GetTicks(); - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { rightShoulderPressed = true; rightShoulderRepeatTick = SDL_GetTicks(); } command = translate_controller_button(event.cbutton.button); } if ( - controllerStartPressed - && controllerBackPressed - && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) + controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) ) { controllerExitComboArmed = true; } @@ -3191,8 +3146,7 @@ namespace ui { command = input::UiCommand::activate; } controllerAContextTriggered = false; - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START && controllerStartPressed) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START && controllerStartPressed) { controllerStartPressed = false; if (!controllerExitComboArmed && !controllerExitComboTriggered) { command = input::map_gamepad_button_to_ui_command(input::GamepadButton::start); @@ -3201,8 +3155,7 @@ namespace ui { controllerExitComboArmed = false; controllerExitComboTriggered = false; } - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK && controllerBackPressed) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK && controllerBackPressed) { controllerBackPressed = false; if (!controllerExitComboArmed && !controllerExitComboTriggered) { command = input::map_gamepad_button_to_ui_command(input::GamepadButton::back); @@ -3211,11 +3164,9 @@ namespace ui { controllerExitComboArmed = false; controllerExitComboTriggered = false; } - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { leftShoulderPressed = false; - } - else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { rightShoulderPressed = false; } break; @@ -3223,8 +3174,7 @@ namespace ui { command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); if (command == input::UiCommand::fast_previous_page) { leftTriggerRepeatTick = SDL_GetTicks(); - } - else if (command == input::UiCommand::fast_next_page) { + } else if (command == input::UiCommand::fast_next_page) { rightTriggerRepeatTick = SDL_GetTicks(); } break; diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index fe3d06f..e1ce454 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -13,10 +13,7 @@ namespace { } bool screen_supports_notifications(const app::ClientState &state) { - return state.activeScreen == app::ScreenId::home - || state.activeScreen == app::ScreenId::hosts - || state.activeScreen == app::ScreenId::apps - || state.activeScreen == app::ScreenId::settings; + return state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts || state.activeScreen == app::ScreenId::apps || state.activeScreen == app::ScreenId::settings; } std::string format_file_size(std::uint64_t sizeBytes) { @@ -34,15 +31,7 @@ namespace { return true; } - if (starts_with(state.statusMessage, "Loaded recent log file lines") - || starts_with(state.statusMessage, "No log file has been written yet") - || starts_with(state.statusMessage, "Testing connection to ") - || starts_with(state.statusMessage, "Editing host ") - || starts_with(state.statusMessage, "Updated host ") - || starts_with(state.statusMessage, "Cancelled host ") - || starts_with(state.statusMessage, "Using default Moonlight host port") - || starts_with(state.statusMessage, "Loading apps for ") - || starts_with(state.statusMessage, "Pairing is preparing the client identity")) { + if (starts_with(state.statusMessage, "Loaded recent log file lines") || starts_with(state.statusMessage, "No log file has been written yet") || starts_with(state.statusMessage, "Testing connection to ") || starts_with(state.statusMessage, "Editing host ") || starts_with(state.statusMessage, "Updated host ") || starts_with(state.statusMessage, "Cancelled host ") || starts_with(state.statusMessage, "Using default Moonlight host port") || starts_with(state.statusMessage, "Loading apps for ") || starts_with(state.statusMessage, "Pairing is preparing the client identity")) { return true; } @@ -143,11 +132,9 @@ namespace { std::string badgeLabel; if (appRecord.favorite) { badgeLabel = "Favorite"; - } - else if (appRecord.hdrSupported) { + } else if (appRecord.hdrSupported) { badgeLabel = "HDR"; - } - else if (appRecord.hidden) { + } else if (appRecord.hidden) { badgeLabel = "Hidden"; } @@ -175,20 +162,14 @@ namespace { 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; + return state.addHostDraft.keypad.stagedInput.empty() && state.addHostDraft.activeField == app::AddHostField::port ? "default (47989)" : state.addHostDraft.keypad.stagedInput; } - return state.addHostDraft.activeField == app::AddHostField::address - ? state.addHostDraft.addressInput - : (state.addHostDraft.portInput.empty() ? "default (47989)" : state.addHostDraft.portInput); + return state.addHostDraft.activeField == app::AddHostField::address ? state.addHostDraft.addressInput : (state.addHostDraft.portInput.empty() ? "default (47989)" : state.addHostDraft.portInput); } 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"}; + 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()); @@ -212,8 +193,7 @@ namespace { if (state.addHostDraft.activeField == app::AddHostField::address) { lines.emplace_back("Enter a dotted IPv4 address such as 192.168.0.10."); - } - else { + } else { lines.emplace_back("Enter digits for a custom TCP port, or leave it empty to keep the default of 47989."); } @@ -250,9 +230,7 @@ namespace { } if (host->appListState == app::HostAppListState::failed) { return { - host->appListStatusMessage.empty() - ? "The app list could not be refreshed." - : host->appListStatusMessage, + host->appListStatusMessage.empty() ? "The app list could not be refreshed." : host->appListStatusMessage, }; } if (host->apps.empty()) { @@ -285,8 +263,7 @@ namespace { }; if (state.pairingDraft.stage == app::PairingStage::idle) { lines.push_back("Checking whether the host is reachable before showing a PIN."); - } - else { + } else { lines.push_back(std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort)); if (!state.pairingDraft.generatedPin.empty()) { lines.push_back(std::string("PIN: ") + app::current_pairing_pin(state)); @@ -301,18 +278,15 @@ namespace { case app::ScreenId::settings: { std::vector lines = { - std::string("Category: ") + (state.selectedSettingsCategory == app::SettingsCategory::logging - ? "Logging" - : state.selectedSettingsCategory == app::SettingsCategory::display - ? "Display" - : state.selectedSettingsCategory == app::SettingsCategory::input ? "Input" : "Reset"), + std::string("Category: ") + (state.selectedSettingsCategory == app::SettingsCategory::logging ? "Logging" : state.selectedSettingsCategory == app::SettingsCategory::display ? "Display" : + state.selectedSettingsCategory == app::SettingsCategory::input ? "Input" : + "Reset"), }; if (state.selectedSettingsCategory == app::SettingsCategory::logging) { lines.push_back(std::string("Log file: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); lines.push_back(std::string("Current logging level: ") + logging::to_string(state.loggingLevel)); lines.push_back("Use View Log File to inspect persisted startup and applist diagnostics."); - } - else if (state.selectedSettingsCategory == app::SettingsCategory::reset) { + } else if (state.selectedSettingsCategory == app::SettingsCategory::reset) { if (state.savedFiles.empty()) { lines.push_back("Saved files: none found."); return lines; @@ -321,11 +295,9 @@ namespace { for (const startup::SavedFileEntry &savedFile : state.savedFiles) { lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); } - } - else if (state.selectedSettingsCategory == app::SettingsCategory::display) { + } else if (state.selectedSettingsCategory == app::SettingsCategory::display) { lines.push_back("Display options will be added here."); - } - else { + } else { lines.push_back("Input options will be added here."); } return lines; @@ -406,7 +378,8 @@ namespace { if (const app::HostRecord *host = app::selected_host(state); host != nullptr) { viewModel->modalLines = { "Name: " + host->displayName, - std::string("State: ") + (host->reachability == app::HostReachability::online ? "ONLINE" : host->reachability == app::HostReachability::offline ? "OFFLINE" : "UNKNOWN"), + std::string("State: ") + (host->reachability == app::HostReachability::online ? "ONLINE" : host->reachability == app::HostReachability::offline ? "OFFLINE" : + "UNKNOWN"), 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), @@ -527,11 +500,7 @@ namespace ui { viewModel.title = "Moonlight"; viewModel.pageTitle = page_title(state); viewModel.statusMessage = state.statusMessage; - viewModel.notificationVisible = screen_supports_notifications(state) - && !state.statusMessage.empty() - && !is_minor_status_message(state) - && !state.modal.active() - && !(state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible); + viewModel.notificationVisible = screen_supports_notifications(state) && !state.statusMessage.empty() && !is_minor_status_message(state) && !state.modal.active() && !(state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible); if (viewModel.notificationVisible) { viewModel.notification = notification(state); } @@ -546,8 +515,7 @@ namespace ui { if (state.activeScreen == app::ScreenId::settings) { if (state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { viewModel.selectedMenuRowLabel = state.detailMenu.selected_item()->label; - } - else if (state.menu.selected_item() != nullptr) { + } else if (state.menu.selected_item() != nullptr) { viewModel.selectedMenuRowLabel = state.menu.selected_item()->label; } } @@ -568,8 +536,7 @@ namespace ui { if (viewModel.overlayVisible) { if (!statsLines.empty()) { viewModel.overlayLines.insert(viewModel.overlayLines.end(), statsLines.begin(), statsLines.end()); - } - else { + } else { viewModel.overlayLines.emplace_back("No active stream"); } diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 83bf088..cfa7ca8 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -22,9 +22,10 @@ namespace { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, - }, "Loaded 2 saved host(s)"); + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + }, + "Loaded 2 saved host(s)"); ASSERT_EQ(state.hosts.size(), 2U); EXPECT_FALSE(state.hostsDirty); @@ -163,8 +164,8 @@ namespace { TEST(ClientStateTest, SelectingAnUnpairedHostStartsPairing) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, - }); + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + }); app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); @@ -180,8 +181,8 @@ namespace { TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::offline}, - }); + {"Living Room PC", "192.168.1.20", 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); @@ -195,8 +196,8 @@ namespace { TEST(ClientStateTest, BackingOutOfThePairingScreenRequestsPairingCancellation) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::online}, - }); + {"Living Room PC", "192.168.1.20", 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); @@ -213,11 +214,11 @@ namespace { TEST(ClientStateTest, HostGridNavigationMatchesTheRenderedThreeColumnLayout) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, - }); + {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + }); EXPECT_EQ(state.selectedHostIndex, 0U); app::handle_command(state, input::UiCommand::move_right); @@ -231,11 +232,11 @@ namespace { TEST(ClientStateTest, HostGridCanMoveDownIntoAPartialNextRowFromAnyColumn) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, - }); + {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + }); app::handle_command(state, input::UiCommand::move_right); EXPECT_EQ(state.selectedHostIndex, 1U); @@ -250,12 +251,12 @@ namespace { TEST(ClientStateTest, HostGridWrapsRightToTheNextRowAndLeftToThePreviousRow) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host E", "192.168.0.14", 0, app::PairingState::paired, app::HostReachability::online}, - }); + {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + {"Host E", "192.168.0.14", 0, app::PairingState::paired, app::HostReachability::online}, + }); state.selectedHostIndex = 2U; app::handle_command(state, input::UiCommand::move_right); @@ -268,8 +269,8 @@ namespace { TEST(ClientStateTest, SelectingAPairedHostOpensTheAppsScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, 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); @@ -283,8 +284,8 @@ namespace { TEST(ClientStateTest, SelectingAnOfflinePairedHostDoesNotOpenTheAppsScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::offline}, - }); + {"Office PC", "10.0.0.25", 48000, 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); @@ -298,8 +299,8 @@ namespace { TEST(ClientStateTest, AppliesFetchedAppListsAndPreservesPerAppFlags) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); @@ -309,9 +310,12 @@ namespace { }; app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"Steam", 101, true, false, false, "cached-steam", false, false}, - {"Desktop", 102, false, false, false, "cached-desktop", true, false}, - }, 0x55AAU, true, "Loaded 2 Sunshine app(s)"); + {"Steam", 101, true, false, false, "cached-steam", false, false}, + {"Desktop", 102, false, false, false, "cached-desktop", true, false}, + }, + 0x55AAU, + true, + "Loaded 2 Sunshine app(s)"); ASSERT_EQ(state.hosts.front().apps.size(), 2U); EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); @@ -326,16 +330,19 @@ namespace { TEST(ClientStateTest, AppliesFetchedAppListsWhenBackgroundTasksReportTheResolvedHttpPort) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); state.hosts.front().resolvedHttpPort = 47989; app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); app::apply_app_list_result(state, "10.0.0.25", 47989, { - {"Steam", 101, true, false, false, "steam-cover", true, false}, - }, 0xBEEFU, true, "Loaded 1 Sunshine app(s)"); + {"Steam", 101, true, false, false, "steam-cover", true, false}, + }, + 0xBEEFU, + true, + "Loaded 1 Sunshine app(s)"); ASSERT_EQ(state.hosts.front().apps.size(), 1U); EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); @@ -346,8 +353,8 @@ namespace { TEST(ClientStateTest, MarksCoverArtCachedWhenBackgroundTasksReportTheResolvedHttpsPort) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); state.hosts.front().httpsPort = 47990; state.hosts.front().apps = { @@ -363,8 +370,8 @@ namespace { TEST(ClientStateTest, FailedRefreshKeepsCachedAppsAvailable) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); state.hosts.front().apps = { @@ -383,8 +390,8 @@ namespace { TEST(ClientStateTest, ExplicitUnpairedAppListFailureMarksTheHostAsNotPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); @@ -406,8 +413,8 @@ namespace { TEST(ClientStateTest, ExplicitUnpairedAppListFailureClearsCachedApps) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); @@ -434,8 +441,8 @@ namespace { TEST(ClientStateTest, TransientAppListFailuresDoNotMarkTheHostAsNotPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); @@ -448,17 +455,20 @@ namespace { TEST(ClientStateTest, AppGridWrapsHorizontallyAndFindsTheClosestItemInPartialRows) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"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 Sunshine app(s)"); + {"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 Sunshine app(s)"); state.selectedAppIndex = 3U; app::handle_command(state, input::UiCommand::move_right); @@ -504,8 +514,8 @@ namespace { TEST(ClientStateTest, LeavingTheAppsScreenClearsTransientAppStatusAndIgnoresLaterRefreshText) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); ASSERT_EQ(state.activeScreen, app::ScreenId::apps); @@ -517,8 +527,11 @@ namespace { EXPECT_TRUE(state.statusMessage.empty()); app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"Steam", 101, false, false, false, "steam-cover", false, false}, - }, 0, false, "The host applist response did not contain any app entries"); + {"Steam", 101, false, false, false, "steam-cover", false, false}, + }, + 0, + false, + "The host applist response did not contain any app entries"); EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); EXPECT_TRUE(state.statusMessage.empty()); @@ -534,8 +547,8 @@ namespace { ASSERT_EQ(state.activeScreen, app::ScreenId::settings); app::replace_saved_files(state, { - {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 128U}, - }); + {"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); @@ -580,9 +593,9 @@ namespace { TEST(ClientStateTest, HostContextMenuCanDeleteTheSelectedHost) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, - }); + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + }); app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::open_context_menu); @@ -645,9 +658,9 @@ namespace { TEST(ClientStateTest, SuccessfulPairingReturnsToHostsAndKeepsTheHostSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::online}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::online}, + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); state.selectedHostIndex = 1U; EXPECT_TRUE(app::apply_pairing_result(state, "192.168.1.20", app::DEFAULT_HOST_PORT, true, "Paired successfully")); diff --git a/tests/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp index 7bb5bd1..cb6bfe6 100644 --- a/tests/unit/logging/log_file_test.cpp +++ b/tests/unit/logging/log_file_test.cpp @@ -69,4 +69,3 @@ namespace { } } // namespace - diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index 85cb0ea..4e0f3f9 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -1,7 +1,6 @@ #include "src/network/host_pairing.h" #include - #include namespace { @@ -23,12 +22,13 @@ namespace { std::atomic cancelRequested = true; const network::HostPairingResult result = network::pair_host({ - "192.168.1.20", - 47984, - "1234", - "MoonlightXboxOG", - identity, - }, &cancelRequested); + "192.168.1.20", + 47984, + "1234", + "MoonlightXboxOG", + identity, + }, + &cancelRequested); EXPECT_FALSE(result.success); EXPECT_FALSE(result.alreadyPaired); @@ -223,5 +223,3 @@ namespace { } } // namespace - - diff --git a/tests/unit/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp index f6667e8..2803178 100644 --- a/tests/unit/startup/client_identity_storage_test.cpp +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -18,8 +18,8 @@ namespace { _rmdir(path.c_str()); } - class ClientIdentityStorageTest : public ::testing::Test { - protected: + class ClientIdentityStorageTest: public ::testing::Test { + protected: void TearDown() override { remove_if_present((nestedIdentityDirectory + "\\uniqueid.dat")); remove_if_present((nestedIdentityDirectory + "\\client.pem")); @@ -73,6 +73,3 @@ namespace { } } // namespace - - - diff --git a/tests/unit/startup/cover_art_cache_test.cpp b/tests/unit/startup/cover_art_cache_test.cpp index 78875fa..f5e4765 100644 --- a/tests/unit/startup/cover_art_cache_test.cpp +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -19,8 +19,8 @@ namespace { _rmdir(path.c_str()); } - class CoverArtCacheTest : public ::testing::Test { - protected: + class CoverArtCacheTest: public ::testing::Test { + protected: void TearDown() override { remove_if_present(testFilePath); remove_directory_if_present(testDirectory); @@ -54,4 +54,3 @@ namespace { } } // namespace - diff --git a/tests/unit/startup/host_storage_test.cpp b/tests/unit/startup/host_storage_test.cpp index 4b774d1..ee56229 100644 --- a/tests/unit/startup/host_storage_test.cpp +++ b/tests/unit/startup/host_storage_test.cpp @@ -19,8 +19,8 @@ namespace { _rmdir(path.c_str()); } - class HostStorageTest : public ::testing::Test { - protected: + class HostStorageTest: public ::testing::Test { + protected: void TearDown() override { remove_if_present(nestedFilePath); remove_directory_if_present(testDirectory + "\\nested"); diff --git a/tests/unit/startup/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp index 0fe1e53..47ca18c 100644 --- a/tests/unit/startup/saved_files_test.cpp +++ b/tests/unit/startup/saved_files_test.cpp @@ -26,8 +26,8 @@ namespace { ASSERT_EQ(std::fclose(file), 0); } - class SavedFilesTest : public ::testing::Test { - protected: + class SavedFilesTest: public ::testing::Test { + protected: std::string testDirectory = "saved-files-test"; std::string hostStoragePath = testDirectory + "\\moonlight-hosts.tsv"; std::string logFilePath = testDirectory + "\\moonlight.log"; @@ -120,6 +120,3 @@ namespace { } } // namespace - - - diff --git a/tests/unit/streaming/stats_overlay_test.cpp b/tests/unit/streaming/stats_overlay_test.cpp index c2bcb31..f9f2450 100644 --- a/tests/unit/streaming/stats_overlay_test.cpp +++ b/tests/unit/streaming/stats_overlay_test.cpp @@ -1,8 +1,7 @@ #include "src/streaming/stats_overlay.h" -#include - #include +#include namespace { @@ -56,4 +55,3 @@ namespace { } } // namespace - diff --git a/tests/unit/ui/menu_model_test.cpp b/tests/unit/ui/menu_model_test.cpp index f48b7da..604fca2 100644 --- a/tests/unit/ui/menu_model_test.cpp +++ b/tests/unit/ui/menu_model_test.cpp @@ -82,4 +82,3 @@ namespace { } } // namespace - diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 3d34221..a7d1c4b 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -1,8 +1,7 @@ #include "src/ui/shell_view.h" -#include - #include +#include namespace { @@ -31,9 +30,10 @@ namespace { TEST(ShellViewTest, ShowsSavedHostsAsTiles) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired, app::HostReachability::offline}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }, "Loaded 2 saved host(s)"); + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired, app::HostReachability::offline}, + {"Office PC", "10.0.0.25", 48000, 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, {}); @@ -53,8 +53,8 @@ namespace { TEST(ShellViewTest, HidesHostMenuFooterActionWhenToolbarIsSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online}, + }); state.hostsFocusArea = app::HostsFocusArea::toolbar; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); @@ -94,9 +94,9 @@ namespace { app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); state.loggingLevel = logging::LogLevel::debug; app::replace_saved_files(state, { - {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 2048U}, - {"E:\\UDATA\\12345678\\pairing\\client.pem", "pairing\\client.pem", 1536U}, - }); + {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 2048U}, + {"E:\\UDATA\\12345678\\pairing\\client.pem", "pairing\\client.pem", 1536U}, + }); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); @@ -118,8 +118,8 @@ namespace { state.selectedSettingsCategory = app::SettingsCategory::reset; app::replace_saved_files(state, { - {"E:\\UDATA\\12345678\\pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", "pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", 1536U}, - }); + {"E:\\UDATA\\12345678\\pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", "pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", 1536U}, + }); state.settingsFocusArea = 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")); @@ -151,14 +151,17 @@ namespace { TEST(ShellViewTest, BuildsTheAppsPageForASelectedPairedHost) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); state.hosts.front().runningGameId = 101; app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"Steam", 101, true, false, false, "steam-cover", true, false}, - {"Desktop", 102, false, false, false, "desktop-cover", false, false}, - }, 0x4242U, true, "Loaded 2 Sunshine app(s)"); + {"Steam", 101, true, false, false, "steam-cover", true, false}, + {"Desktop", 102, false, false, false, "desktop-cover", false, false}, + }, + 0x4242U, + true, + "Loaded 2 Sunshine app(s)"); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); @@ -181,8 +184,8 @@ namespace { TEST(ShellViewTest, HidesCachedAppTilesWhenTheSelectedHostIsNoLongerPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::not_paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::not_paired, app::HostReachability::online}, + }); state.activeScreen = app::ScreenId::apps; state.hosts.front().apps = { {"Steam", 101, false, false, false, "steam-cover", true, false}, @@ -200,8 +203,8 @@ namespace { TEST(ShellViewTest, SuppressesTransientAppsLoadingTextAndNotifications) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); state.statusMessage = "Loading apps for Office PC..."; @@ -214,8 +217,8 @@ namespace { TEST(ShellViewTest, ShowsOnlyBackOnAppsScreenWhenNoVisibleAppIsSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, - }); + {"Office PC", "10.0.0.25", 48000, 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, {}); @@ -227,8 +230,8 @@ namespace { TEST(ShellViewTest, BuildsHostDetailsModalContent) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online, "192.168.0.10", "uuid-123", "192.168.0.10", "203.0.113.7", {}, "192.168.0.10", "00:11:22:33:44:55", 47990, 0}, - }); + {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online, "192.168.0.10", "uuid-123", "192.168.0.10", "203.0.113.7", {}, "192.168.0.10", "00:11:22:33:44:55", 47990, 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); @@ -251,9 +254,10 @@ namespace { app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); state.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"); + "[000001] [INFO] app: Entered shell", + "[000002] [WARN] network: No active stream", + }, + "Loaded log file preview"); state.logViewerScrollOffset = 1; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); @@ -358,6 +362,4 @@ namespace { EXPECT_EQ(viewModel.overlayLines.front(), "Showing earlier log entries"); } - } // namespace - From 965a3bc9ad08cb70fdad6c67caf02440aac2e838 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:05:31 -0400 Subject: [PATCH 05/35] Track deleted hosts, cleanup pairing and cover art Record and handle data cleanup when hosts are deleted: add pairingResetEndpoints to ClientState and new AppUpdate fields to request host-delete cleanup and carry deleted host metadata (address, port, paired flag, cover art keys). Implement helpers to remember/clear deleted host pairing and to check host_requires_manual_pairing, and clear these records when a host is successfully re-paired. Add startup APIs to delete persisted client identity and cached cover art (delete_client_identity, delete_cover_art) with basic error reporting. Wire a delete_host_data_if_requested flow that removes cached cover art, clears texture cache, and resets the shared client identity if no paired hosts remain, updating status messages accordingly. Improve settings/menu synchronization (sync_selected_settings_category_from_menu, rebuild_settings_detail_menu) and adjust UI rendering for the menu/options/detail layout and visuals. Simplify SVG asset handling and texture creation (use SDL scale hint for quality), and swap footer action icons for Exit in the view model. Update many SVG icon assets to remove text parameters (nanosvg limitation) and tweak visuals, add xbe/assets README, and extend unit tests to cover host deletion/pairing cleanup, client identity deletion, and cover art deletion. Also mark several README features as completed. --- README.md | 12 +- src/app/client_state.cpp | 100 ++++++- src/app/client_state.h | 8 + src/startup/client_identity_storage.cpp | 28 ++ src/startup/client_identity_storage.h | 5 + src/startup/cover_art_cache.cpp | 20 ++ src/startup/cover_art_cache.h | 6 + src/ui/shell_screen.cpp | 266 ++++++++---------- src/ui/shell_view.cpp | 6 +- tests/unit/app/client_state_test.cpp | 54 ++++ .../startup/client_identity_storage_test.cpp | 25 ++ tests/unit/startup/cover_art_cache_test.cpp | 23 ++ tests/unit/ui/shell_view_test.cpp | 4 +- xbe/assets/README.md | 2 + xbe/assets/icons/add-host.svg | 7 +- xbe/assets/icons/button-a.svg | 3 - xbe/assets/icons/button-b.svg | 3 - xbe/assets/icons/button-lb.svg | 6 +- xbe/assets/icons/button-lt.svg | 6 +- xbe/assets/icons/button-rb.svg | 7 +- xbe/assets/icons/button-rt.svg | 7 +- xbe/assets/icons/button-select.svg | 9 +- xbe/assets/icons/button-start.svg | 9 +- xbe/assets/icons/button-x.svg | 3 - xbe/assets/icons/button-y.svg | 3 - xbe/assets/icons/gear.svg | 26 +- xbe/assets/icons/host-monitor-offline.svg | 14 +- xbe/assets/icons/host-monitor-online.svg | 10 +- xbe/assets/icons/host-monitor-pairing.svg | 10 +- xbe/assets/icons/support.svg | 11 +- 30 files changed, 438 insertions(+), 255 deletions(-) create mode 100644 xbe/assets/README.md diff --git a/README.md b/README.md index e59c3a4..ad1342b 100644 --- a/README.md +++ b/README.md @@ -210,12 +210,12 @@ scripts\setup-xemu.cmd --skip-support-files - [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 diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index a0db14f..2bab779 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -69,6 +69,56 @@ namespace { return std::string(SETTINGS_CATEGORY_PREFIX) + "logging"; } + app::SettingsCategory settings_category_from_menu_id(const std::string &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; + } + + std::string pairing_reset_endpoint_key(const std::string &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.pairingResetEndpoints.begin(), state.pairingResetEndpoints.end(), key) == state.pairingResetEndpoints.end()) { + state.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.pairingResetEndpoints.erase( + std::remove(state.pairingResetEndpoints.begin(), state.pairingResetEndpoints.end(), key), + state.pairingResetEndpoints.end() + ); + } + + void sync_selected_settings_category_from_menu(app::ClientState &state) { + if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) { + state.selectedSettingsCategory = settings_category_from_menu_id(selectedItem->id); + } + } + bool starts_with(const std::string &value, const char *prefix) { return value.rfind(prefix, 0U) == 0U; } @@ -306,6 +356,17 @@ namespace { } } + 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); @@ -319,6 +380,10 @@ namespace { } 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); } @@ -818,6 +883,17 @@ namespace { return true; case 2: if (state.selectedHostIndex < state.hosts.size()) { + const app::HostRecord deletedHost = state.hosts[state.selectedHostIndex]; + remember_deleted_host_pairing(state, deletedHost); + update->hostDeleteCleanupRequested = true; + update->deletedHostAddress = deletedHost.address; + update->deletedHostPort = deletedHost.port; + update->deletedHostWasPaired = deletedHost.pairingState == app::PairingState::paired; + for (const app::HostAppRecord &appRecord : deletedHost.apps) { + if (!appRecord.boxArtCacheKey.empty() && std::find(update->deletedHostCoverArtCacheKeys.begin(), update->deletedHostCoverArtCacheKeys.end(), appRecord.boxArtCacheKey) == update->deletedHostCoverArtCacheKeys.end()) { + update->deletedHostCoverArtCacheKeys.push_back(appRecord.boxArtCacheKey); + } + } state.hosts.erase(state.hosts.begin() + static_cast(state.selectedHostIndex)); state.hostsDirty = true; update->hostsChanged = true; @@ -911,6 +987,7 @@ namespace app { logging::LogLevel::info, {}, true, + {}, }; } @@ -997,6 +1074,7 @@ namespace app { } if (success) { + clear_deleted_host_pairing(state, address, port); host->pairingState = PairingState::paired; host->reachability = HostReachability::online; select_host_by_endpoint(state, address, port); @@ -1116,6 +1194,11 @@ namespace app { 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.pairingResetEndpoints.begin(), state.pairingResetEndpoints.end(), key) != state.pairingResetEndpoints.end(); + } + const HostRecord *selected_host(const ClientState &state) { if (state.hosts.empty() || state.selectedHostIndex >= state.hosts.size()) { return nullptr; @@ -1245,21 +1328,18 @@ namespace app { update.screenChanged = true; return update; } + if (categoryUpdate.selectionChanged) { + sync_selected_settings_category_from_menu(state); + rebuild_settings_detail_menu(state, {}, false); + return update; + } if (!categoryUpdate.activationRequested) { return update; } update.activatedItemId = categoryUpdate.activatedItemId; - if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::logging)) { - state.selectedSettingsCategory = SettingsCategory::logging; - } else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::display)) { - state.selectedSettingsCategory = SettingsCategory::display; - } else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::input)) { - state.selectedSettingsCategory = SettingsCategory::input; - } else if (categoryUpdate.activatedItemId == settings_category_menu_id(SettingsCategory::reset)) { - state.selectedSettingsCategory = SettingsCategory::reset; - } - rebuild_menu(state); + sync_selected_settings_category_from_menu(state); + rebuild_menu(state, categoryUpdate.activatedItemId); if (!state.detailMenu.items().empty()) { state.settingsFocusArea = SettingsFocusArea::options; } diff --git a/src/app/client_state.h b/src/app/client_state.h index 8439ae8..48348a7 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -164,6 +164,7 @@ namespace app { logging::LogLevel loggingLevel = logging::LogLevel::info; std::vector savedFiles; bool savedFilesDirty = true; + std::vector pairingResetEndpoints; }; /** @@ -183,8 +184,10 @@ namespace app { bool logViewRequested; bool savedFileDeleteRequested; bool factoryResetRequested; + bool hostDeleteCleanupRequested; bool modalOpened; bool modalClosed; + bool deletedHostWasPaired; std::string activatedItemId; std::string connectionTestAddress; uint16_t connectionTestPort; @@ -192,6 +195,9 @@ namespace app { uint16_t pairingPort; std::string pairingPin; std::string savedFileDeletePath; + std::string deletedHostAddress; + uint16_t deletedHostPort; + std::vector deletedHostCoverArtCacheKeys; }; /** @@ -305,6 +311,8 @@ namespace app { void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage); + 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. * diff --git a/src/startup/client_identity_storage.cpp b/src/startup/client_identity_storage.cpp index b999110..0011d53 100644 --- a/src/startup/client_identity_storage.cpp +++ b/src/startup/client_identity_storage.cpp @@ -17,6 +17,13 @@ extern "C" { namespace { + bool append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + return false; + } + 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"; @@ -151,6 +158,14 @@ namespace { 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 { @@ -206,6 +221,19 @@ namespace startup { 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); + + std::string deleteError; + if (!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 (!ensure_directory_exists(directoryPath, &errorMessage)) { diff --git a/src/startup/client_identity_storage.h b/src/startup/client_identity_storage.h index ddd0fa6..bd3dbfd 100644 --- a/src/startup/client_identity_storage.h +++ b/src/startup/client_identity_storage.h @@ -24,6 +24,11 @@ namespace startup { LoadClientIdentityResult load_client_identity(const std::string &directoryPath = default_client_identity_directory()); + bool delete_client_identity( + std::string *errorMessage = nullptr, + const std::string &directoryPath = default_client_identity_directory() + ); + SaveClientIdentityResult save_client_identity( const network::PairingIdentity &identity, const std::string &directoryPath = default_client_identity_directory() diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp index bdc8274..31f5338 100644 --- a/src/startup/cover_art_cache.cpp +++ b/src/startup/cover_art_cache.cpp @@ -28,6 +28,13 @@ extern "C" { namespace { + bool append_error(std::string *errorMessage, std::string message) { + if (errorMessage != nullptr) { + *errorMessage = std::move(message); + } + return false; + } + std::string normalize_directory_component(std::string path) { while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { path.pop_back(); @@ -174,6 +181,19 @@ namespace startup { 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"); diff --git a/src/startup/cover_art_cache.h b/src/startup/cover_art_cache.h index 2300daa..37d834a 100644 --- a/src/startup/cover_art_cache.h +++ b/src/startup/cover_art_cache.h @@ -24,6 +24,12 @@ namespace startup { bool cover_art_exists(std::string_view cacheKey, const std::string &cacheRoot = default_cover_art_cache_root()); + bool delete_cover_art( + std::string_view cacheKey, + std::string *errorMessage = nullptr, + const std::string &cacheRoot = default_cover_art_cache_root() + ); + LoadCoverArtResult load_cover_art(std::string_view cacheKey, const std::string &cacheRoot = default_cover_art_cache_root()); SaveCoverArtResult save_cover_art(std::string_view cacheKey, const std::vector &bytes, const std::string &cacheRoot = default_cover_art_cache_root()); diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 2837c2a..ca8f1ab 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -61,7 +61,6 @@ namespace { constexpr Uint32 EXIT_COMBO_HOLD_MILLISECONDS = 900U; constexpr Uint32 LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS = 110U; constexpr Uint32 LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS = 45U; - constexpr int MIN_SVG_RASTER_DIMENSION = 256; constexpr std::size_t LOG_VIEWER_MAX_LOADED_LINES = 512U; constexpr std::size_t LOG_VIEWER_MAX_RENDER_CHARACTERS = 320U; @@ -78,64 +77,13 @@ namespace { return path.size() >= 4U && path.substr(path.size() - 4U) == ".svg"; } - Uint32 read_surface_pixel(const SDL_Surface *surface, int x, int y) { - const Uint8 *row = static_cast(surface->pixels) + (y * surface->pitch); - Uint32 pixel = 0; - std::memcpy(&pixel, row + (x * static_cast(sizeof(Uint32))), sizeof(Uint32)); - return pixel; - } - - void write_surface_pixel(SDL_Surface *surface, int x, int y, Uint32 pixel) { - Uint8 *row = static_cast(surface->pixels) + (y * surface->pitch); - std::memcpy(row + (x * static_cast(sizeof(Uint32))), &pixel, sizeof(Uint32)); - } - - Uint32 sample_bilinear_pixel(const SDL_Surface *sourceSurface, float sourceX, float sourceY, const SDL_PixelFormat *targetFormat) { - const int x0 = std::clamp(static_cast(std::floor(sourceX)), 0, sourceSurface->w - 1); - const int y0 = std::clamp(static_cast(std::floor(sourceY)), 0, sourceSurface->h - 1); - const int x1 = std::min(x0 + 1, sourceSurface->w - 1); - const int y1 = std::min(y0 + 1, sourceSurface->h - 1); - const float tx = std::clamp(sourceX - static_cast(x0), 0.0f, 1.0f); - const float ty = std::clamp(sourceY - static_cast(y0), 0.0f, 1.0f); - - Uint8 topLeftRed = 0; - Uint8 topLeftGreen = 0; - Uint8 topLeftBlue = 0; - Uint8 topLeftAlpha = 0; - Uint8 topRightRed = 0; - Uint8 topRightGreen = 0; - Uint8 topRightBlue = 0; - Uint8 topRightAlpha = 0; - Uint8 bottomLeftRed = 0; - Uint8 bottomLeftGreen = 0; - Uint8 bottomLeftBlue = 0; - Uint8 bottomLeftAlpha = 0; - Uint8 bottomRightRed = 0; - Uint8 bottomRightGreen = 0; - Uint8 bottomRightBlue = 0; - Uint8 bottomRightAlpha = 0; - - SDL_GetRGBA(read_surface_pixel(sourceSurface, x0, y0), sourceSurface->format, &topLeftRed, &topLeftGreen, &topLeftBlue, &topLeftAlpha); - SDL_GetRGBA(read_surface_pixel(sourceSurface, x1, y0), sourceSurface->format, &topRightRed, &topRightGreen, &topRightBlue, &topRightAlpha); - SDL_GetRGBA(read_surface_pixel(sourceSurface, x0, y1), sourceSurface->format, &bottomLeftRed, &bottomLeftGreen, &bottomLeftBlue, &bottomLeftAlpha); - SDL_GetRGBA(read_surface_pixel(sourceSurface, x1, y1), sourceSurface->format, &bottomRightRed, &bottomRightGreen, &bottomRightBlue, &bottomRightAlpha); - - const float topRed = (static_cast(topLeftRed) * (1.0f - tx)) + (static_cast(topRightRed) * tx); - const float topGreen = (static_cast(topLeftGreen) * (1.0f - tx)) + (static_cast(topRightGreen) * tx); - const float topBlue = (static_cast(topLeftBlue) * (1.0f - tx)) + (static_cast(topRightBlue) * tx); - const float topAlpha = (static_cast(topLeftAlpha) * (1.0f - tx)) + (static_cast(topRightAlpha) * tx); - const float bottomRed = (static_cast(bottomLeftRed) * (1.0f - tx)) + (static_cast(bottomRightRed) * tx); - const float bottomGreen = (static_cast(bottomLeftGreen) * (1.0f - tx)) + (static_cast(bottomRightGreen) * tx); - const float bottomBlue = (static_cast(bottomLeftBlue) * (1.0f - tx)) + (static_cast(bottomRightBlue) * tx); - const float bottomAlpha = (static_cast(bottomLeftAlpha) * (1.0f - tx)) + (static_cast(bottomRightAlpha) * tx); - - return SDL_MapRGBA( - targetFormat, - static_cast((topRed * (1.0f - ty)) + (bottomRed * ty)), - static_cast((topGreen * (1.0f - ty)) + (bottomGreen * ty)), - static_cast((topBlue * (1.0f - ty)) + (bottomBlue * ty)), - static_cast((topAlpha * (1.0f - ty)) + (bottomAlpha * ty)) - ); + 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; } SDL_Surface *normalize_asset_surface(SDL_Surface *surface) { @@ -158,64 +106,21 @@ namespace { return normalizedSurface; } - SDL_Surface *create_scaled_surface_bilinear(SDL_Surface *sourceSurface, int targetWidth, int targetHeight) { - if (sourceSurface == nullptr || targetWidth <= 0 || targetHeight <= 0) { - return nullptr; - } - - SDL_Surface *scaledSurface = SDL_CreateRGBSurfaceWithFormat(0, targetWidth, targetHeight, 32, SDL_PIXELFORMAT_ARGB8888); - if (scaledSurface == nullptr) { - SDL_FreeSurface(sourceSurface); - return nullptr; - } - - if (SDL_LockSurface(sourceSurface) < 0) { - SDL_FreeSurface(sourceSurface); - SDL_FreeSurface(scaledSurface); - return nullptr; - } - - if (SDL_LockSurface(scaledSurface) < 0) { - SDL_UnlockSurface(sourceSurface); - SDL_FreeSurface(sourceSurface); - SDL_FreeSurface(scaledSurface); - return nullptr; - } - - for (int y = 0; y < scaledSurface->h; ++y) { - const float sourceY = ((static_cast(y) + 0.5f) * static_cast(sourceSurface->h) / static_cast(scaledSurface->h)) - 0.5f; - for (int x = 0; x < scaledSurface->w; ++x) { - const float sourceX = ((static_cast(x) + 0.5f) * static_cast(sourceSurface->w) / static_cast(scaledSurface->w)) - 0.5f; - write_surface_pixel(scaledSurface, x, y, sample_bilinear_pixel(sourceSurface, sourceX, sourceY, scaledSurface->format)); - } - } - - SDL_UnlockSurface(scaledSurface); - SDL_UnlockSurface(sourceSurface); - SDL_FreeSurface(sourceSurface); - - if (SDL_SetSurfaceBlendMode(scaledSurface, SDL_BLENDMODE_BLEND) != 0) { - SDL_FreeSurface(scaledSurface); - return nullptr; - } - - return scaledSurface; + SDL_Surface *prepare_asset_surface(SDL_Surface *surface) { + return normalize_asset_surface(surface); } - SDL_Surface *prepare_asset_surface(SDL_Surface *surface, const char *relativePath) { - SDL_Surface *normalizedSurface = normalize_asset_surface(surface); - if (normalizedSurface == nullptr || !asset_path_uses_svg(relativePath)) { - return normalizedSurface; - } - - const int sourceMaxDimension = std::max(normalizedSurface->w, normalizedSurface->h); - if (sourceMaxDimension <= 0 || sourceMaxDimension >= MIN_SVG_RASTER_DIMENSION) { - return normalizedSurface; + 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 int targetWidth = std::max(1, (normalizedSurface->w * MIN_SVG_RASTER_DIMENSION) / sourceMaxDimension); - const int targetHeight = std::max(1, (normalizedSurface->h * MIN_SVG_RASTER_DIMENSION) / sourceMaxDimension); - return create_scaled_surface_bilinear(normalizedSurface, targetWidth, targetHeight); + 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(logging::Logger &logger, const char *category, const std::string &message) { @@ -482,12 +387,12 @@ namespace { return nullptr; } - surface = prepare_asset_surface(surface, relativePath); + surface = prepare_asset_surface(surface); if (surface == nullptr) { return nullptr; } - SDL_Texture *texture = SDL_CreateTextureFromSurface(renderer, surface); + 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; } @@ -1495,6 +1400,7 @@ namespace { } bool persistedMetadataChanged = false; + const bool hostRequiresManualPairing = app::host_requires_manual_pairing(state, address, port); if (!serverInfo.hostName.empty()) { persistedMetadataChanged = persistedMetadataChanged || host.displayName != serverInfo.hostName; host.displayName = serverInfo.hostName; @@ -1511,13 +1417,14 @@ namespace { host.httpsPort = serverInfo.httpsPort; host.runningGameId = serverInfo.runningGameId; if (serverInfo.pairingStatusCurrentClientKnown) { - const app::PairingState resolvedPairingState = serverInfo.pairingStatusCurrentClient ? app::PairingState::paired : app::PairingState::not_paired; + const bool clientIsEffectivelyPaired = serverInfo.pairingStatusCurrentClient && !hostRequiresManualPairing; + const app::PairingState resolvedPairingState = clientIsEffectivelyPaired ? app::PairingState::paired : app::PairingState::not_paired; persistedMetadataChanged = persistedMetadataChanged || host.pairingState != resolvedPairingState; host.pairingState = resolvedPairingState; - if (!serverInfo.pairingStatusCurrentClient) { + if (!clientIsEffectivelyPaired) { host.apps.clear(); - host.appListState = app::HostAppListState::failed; - host.appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again from Sunshine."; + 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 from Sunshine."; host.appListContentHash = 0; host.lastAppListRefreshTick = 0; state.selectedAppIndex = 0U; @@ -1605,6 +1512,49 @@ namespace { logger.log(logging::LogLevel::info, "storage", state.statusMessage); } + void delete_host_data_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + if (!update.hostDeleteCleanupRequested) { + return; + } + + std::size_t deletedCoverArtCount = 0U; + for (const std::string &cacheKey : update.deletedHostCoverArtCacheKeys) { + std::string errorMessage; + if (!startup::delete_cover_art(cacheKey, &errorMessage)) { + logger.log(logging::LogLevel::warning, "storage", errorMessage); + } else { + ++deletedCoverArtCount; + } + clear_cover_art_texture(coverArtTextureCache, cacheKey); + } + + bool deletedClientIdentity = false; + if (update.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)) { + logger.log(logging::LogLevel::warning, "storage", errorMessage); + } else { + deletedClientIdentity = true; + } + } else { + logger.log(logging::LogLevel::info, "storage", "Retained the shared pairing identity because other paired hosts still exist"); + } + } + + state.statusMessage = "Deleted saved host"; + if (deletedCoverArtCount > 0U) { + state.statusMessage += " and cleared " + std::to_string(deletedCoverArtCount) + " cached asset" + (deletedCoverArtCount == 1U ? std::string {} : "s"); + } + if (deletedClientIdentity) { + state.statusMessage += " and reset local pairing identity"; + } + logger.log(logging::LogLevel::info, "storage", state.statusMessage); + } + void factory_reset_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { if (!update.factoryResetRequested) { return; @@ -2693,40 +2643,67 @@ namespace { } } } else { - const int menuPanelWidth = std::max(228, (contentRect.w * 34) / 100); + const bool settingsScreen = viewModel.screen == app::ScreenId::settings; + const bool hasDetailMenu = settingsScreen && !viewModel.detailMenuRows.empty(); + const int menuPanelWidth = std::max(232, (contentRect.w * 31) / 100); const SDL_Rect menuPanel {contentRect.x, contentRect.y, menuPanelWidth, contentRect.h}; const SDL_Rect bodyPanel {contentRect.x + menuPanelWidth + panelGap, contentRect.y, contentRect.w - menuPanelWidth - panelGap, contentRect.h}; - fill_rect(renderer, menuPanel, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xA0); - fill_rect(renderer, bodyPanel, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0x60); - draw_rect(renderer, menuPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); - draw_rect(renderer, bodyPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); - if (!render_action_rows(renderer, bodyFont, viewModel.menuRows, {menuPanel.x + 12, menuPanel.y + 18, menuPanel.w - 24, menuPanel.h - 36}, std::max(36, screenHeight / 13))) { + 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, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD8); + draw_rect(renderer, bodyPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xB8); + + const SDL_Rect menuHeaderRect {menuPanel.x + 14, menuPanel.y + 14, menuPanel.w - 28, 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; } - int bodyY = bodyPanel.y + 16; - if (viewModel.screen == app::ScreenId::settings && !viewModel.selectedMenuRowLabel.empty()) { - int selectedLabelHeight = 0; - if (!render_text_line(renderer, bodyFont, "Selected: " + viewModel.selectedMenuRowLabel, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, bodyPanel.x + 16, bodyY, bodyPanel.w - 32, &selectedLabelHeight)) { + + if (!render_action_rows( + renderer, + bodyFont, + viewModel.menuRows, + {menuPanel.x + 14, menuHeaderRect.y + menuHeaderRect.h + 12, menuPanel.w - 28, menuPanel.h - (menuHeaderRect.h + 40)}, + std::max(36, screenHeight / 13) + )) { + return false; + } + + const int bodyCardPadding = 16; + if (hasDetailMenu) { + const SDL_Rect optionsCard {bodyPanel.x + 16, bodyPanel.y + 16, bodyPanel.w - 32, std::max(1, bodyPanel.h - 32)}; + fill_rect(renderer, optionsCard, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xCC); + draw_rect(renderer, optionsCard, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xC0); + if (!render_text_line_simple(renderer, smallFont, "Options", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, optionsCard.x + bodyCardPadding, optionsCard.y + 12, optionsCard.w - (bodyCardPadding * 2))) { return false; } - bodyY += selectedLabelHeight + 12; - } - if (viewModel.screen == app::ScreenId::settings && !viewModel.detailMenuRows.empty()) { - const int detailMenuHeight = std::min(std::max(88, bodyPanel.h / 3), std::max(88, 54 * static_cast(viewModel.detailMenuRows.size()))); - const SDL_Rect detailMenuRect {bodyPanel.x + 16, bodyY, bodyPanel.w - 32, detailMenuHeight}; - fill_rect(renderer, detailMenuRect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xA8); - draw_rect(renderer, detailMenuRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD0); - if (!render_action_rows(renderer, bodyFont, viewModel.detailMenuRows, {detailMenuRect.x + 10, detailMenuRect.y + 10, detailMenuRect.w - 20, detailMenuRect.h - 20}, std::max(34, TTF_FontLineSkip(bodyFont) + 12))) { + if (!render_action_rows( + renderer, + bodyFont, + viewModel.detailMenuRows, + {optionsCard.x + 12, optionsCard.y + 40, optionsCard.w - 24, optionsCard.h - 52}, + std::max(34, TTF_FontLineSkip(bodyFont) + 12) + )) { return false; } - bodyY = detailMenuRect.y + detailMenuRect.h + 16; - } - for (const std::string &line : viewModel.bodyLines) { - int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + 16, bodyY, bodyPanel.w - 32, &drawnHeight)) { - return false; + } else { + const SDL_Rect contentCard { + bodyPanel.x + 16, + bodyPanel.y + 16, + bodyPanel.w - 32, + std::max(1, bodyPanel.h - 32), + }; + fill_rect(renderer, contentCard, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xC2); + draw_rect(renderer, contentCard, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x50); + + int bodyY = contentCard.y + bodyCardPadding; + for (const std::string &line : viewModel.bodyLines) { + int drawnHeight = 0; + if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentCard.x + bodyCardPadding, bodyY, contentCard.w - (bodyCardPadding * 2), &drawnHeight)) { + return false; + } + bodyY += drawnHeight + 8; } - bodyY += drawnHeight + 8; } } @@ -3020,6 +2997,7 @@ namespace ui { test_host_connection_if_requested(logger, state, update); browse_host_apps_if_requested(logger, state, update); pair_host_if_requested(logger, state, update, &pairingTask); + delete_host_data_if_requested(logger, state, update, &coverArtTextureCache); delete_saved_file_if_requested(logger, state, update, &coverArtTextureCache); factory_reset_if_requested(logger, state, update, &coverArtTextureCache); refresh_saved_files_if_needed(logger, state); diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index e1ce454..c760683 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -446,7 +446,7 @@ namespace { if (state.hostsFocusArea == app::HostsFocusArea::grid && app::selected_host(state) != nullptr) { actions.push_back({"host-menu", "Host Menu", "icons\\button-y.svg", {}, false}); } - actions.push_back({"exit", "Exit", "icons\\button-start.svg", "icons\\button-select.svg", false}); + actions.push_back({"exit", "Exit", "icons\\button-select.svg", "icons\\button-start.svg", false}); return actions; } case app::ScreenId::apps: @@ -512,8 +512,8 @@ namespace ui { viewModel.bodyLines = body_lines(state); viewModel.menuRows = menu_rows(state); viewModel.detailMenuRows = detail_menu_rows(state); - if (state.activeScreen == app::ScreenId::settings) { - if (state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { + if (state.activeScreen == app::ScreenId::settings || state.activeScreen == app::ScreenId::add_host || state.activeScreen == app::ScreenId::pair_host) { + if (state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { viewModel.selectedMenuRowLabel = state.detailMenu.selected_item()->label; } else if (state.menu.selected_item() != nullptr) { viewModel.selectedMenuRowLabel = state.menu.selected_item()->label; diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index cfa7ca8..7aa4e71 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -608,6 +608,60 @@ namespace { EXPECT_EQ(state.hosts.front().displayName, "Office PC"); } + TEST(ClientStateTest, DeletingAPairedHostRequestsPersistentCleanupAndMarksItForManualRePairing) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, 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.hostsChanged); + EXPECT_TRUE(update.hostDeleteCleanupRequested); + EXPECT_TRUE(update.deletedHostWasPaired); + EXPECT_EQ(update.deletedHostAddress, "10.0.0.25"); + EXPECT_EQ(update.deletedHostPort, 48000); + ASSERT_EQ(update.deletedHostCoverArtCacheKeys.size(), 2U); + EXPECT_EQ(update.deletedHostCoverArtCacheKeys[0], "steam-cover"); + EXPECT_EQ(update.deletedHostCoverArtCacheKeys[1], "desktop-cover"); + EXPECT_TRUE(state.hosts.empty()); + EXPECT_TRUE(app::host_requires_manual_pairing(state, "10.0.0.25", 48000)); + } + + TEST(ClientStateTest, SuccessfulRePairingClearsTheManualRePairRequirementAfterHostDeletion) { + app::ClientState state = app::create_initial_state(); + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, 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.hostDeleteCleanupRequested); + ASSERT_TRUE(app::host_requires_manual_pairing(state, "10.0.0.25", 48000)); + + app::replace_hosts(state, { + {"Office PC", "10.0.0.25", 48000, app::PairingState::not_paired, app::HostReachability::online}, + }); + + EXPECT_TRUE(app::apply_pairing_result(state, "10.0.0.25", 48000, true, "Paired successfully")); + EXPECT_FALSE(app::host_requires_manual_pairing(state, "10.0.0.25", 48000)); + EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::paired); + } + TEST(ClientStateTest, RequestsAConnectionTestFromTheAddHostScreen) { app::ClientState state = app::create_initial_state(); diff --git a/tests/unit/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp index 2803178..67afa0e 100644 --- a/tests/unit/startup/client_identity_storage_test.cpp +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -72,4 +72,29 @@ namespace { 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((testDirectory + "\\uniqueid.dat").c_str()) == 0); + EXPECT_FALSE(std::remove((testDirectory + "\\client.pem").c_str()) == 0); + EXPECT_FALSE(std::remove((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()); + } + } // namespace diff --git a/tests/unit/startup/cover_art_cache_test.cpp b/tests/unit/startup/cover_art_cache_test.cpp index f5e4765..1547cc3 100644 --- a/tests/unit/startup/cover_art_cache_test.cpp +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -53,4 +53,27 @@ namespace { 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()); + } + } // namespace diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index a7d1c4b..6b6f881 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -22,8 +22,8 @@ namespace { EXPECT_EQ(viewModel.footerActions[0].label, "Select"); EXPECT_EQ(viewModel.footerActions[0].iconAssetPath, "icons\\button-a.svg"); EXPECT_EQ(viewModel.footerActions[1].label, "Exit"); - EXPECT_EQ(viewModel.footerActions[1].iconAssetPath, "icons\\button-start.svg"); - EXPECT_EQ(viewModel.footerActions[1].secondaryIconAssetPath, "icons\\button-select.svg"); + EXPECT_EQ(viewModel.footerActions[1].iconAssetPath, "icons\\button-select.svg"); + EXPECT_EQ(viewModel.footerActions[1].secondaryIconAssetPath, "icons\\button-start.svg"); EXPECT_FALSE(viewModel.overlayVisible); } 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 index 4a9a64b..faf3c83 100644 --- a/xbe/assets/icons/add-host.svg +++ b/xbe/assets/icons/add-host.svg @@ -2,10 +2,7 @@ - - + + - - - diff --git a/xbe/assets/icons/button-a.svg b/xbe/assets/icons/button-a.svg index e5eb9bb..1da5175 100644 --- a/xbe/assets/icons/button-a.svg +++ b/xbe/assets/icons/button-a.svg @@ -2,6 +2,3 @@ - - - diff --git a/xbe/assets/icons/button-b.svg b/xbe/assets/icons/button-b.svg index 5438d17..bd942dd 100644 --- a/xbe/assets/icons/button-b.svg +++ b/xbe/assets/icons/button-b.svg @@ -2,6 +2,3 @@ - - - diff --git a/xbe/assets/icons/button-lb.svg b/xbe/assets/icons/button-lb.svg index 3a3a99e..f5850bf 100644 --- a/xbe/assets/icons/button-lb.svg +++ b/xbe/assets/icons/button-lb.svg @@ -1,7 +1,3 @@ - - - - LB + - diff --git a/xbe/assets/icons/button-lt.svg b/xbe/assets/icons/button-lt.svg index e1f794b..e8e798e 100644 --- a/xbe/assets/icons/button-lt.svg +++ b/xbe/assets/icons/button-lt.svg @@ -1,7 +1,3 @@ - - - - LT + - diff --git a/xbe/assets/icons/button-rb.svg b/xbe/assets/icons/button-rb.svg index 9c04df0..4e4ae2f 100644 --- a/xbe/assets/icons/button-rb.svg +++ b/xbe/assets/icons/button-rb.svg @@ -1,7 +1,4 @@ - - - - RB + + - diff --git a/xbe/assets/icons/button-rt.svg b/xbe/assets/icons/button-rt.svg index 13e7822..db688f8 100644 --- a/xbe/assets/icons/button-rt.svg +++ b/xbe/assets/icons/button-rt.svg @@ -1,8 +1,3 @@ - - - - - RT + - diff --git a/xbe/assets/icons/button-select.svg b/xbe/assets/icons/button-select.svg index e7bd90c..3e672db 100644 --- a/xbe/assets/icons/button-select.svg +++ b/xbe/assets/icons/button-select.svg @@ -1,8 +1,7 @@ - - - - + + + + - diff --git a/xbe/assets/icons/button-start.svg b/xbe/assets/icons/button-start.svg index d2d86c9..426392a 100644 --- a/xbe/assets/icons/button-start.svg +++ b/xbe/assets/icons/button-start.svg @@ -1,8 +1,7 @@ - - - - + + + + - diff --git a/xbe/assets/icons/button-x.svg b/xbe/assets/icons/button-x.svg index 6b3f388..a9eaceb 100644 --- a/xbe/assets/icons/button-x.svg +++ b/xbe/assets/icons/button-x.svg @@ -2,6 +2,3 @@ - - - diff --git a/xbe/assets/icons/button-y.svg b/xbe/assets/icons/button-y.svg index 117fadf..ac12e75 100644 --- a/xbe/assets/icons/button-y.svg +++ b/xbe/assets/icons/button-y.svg @@ -2,6 +2,3 @@ - - - diff --git a/xbe/assets/icons/gear.svg b/xbe/assets/icons/gear.svg index a3adfc3..8630d4e 100644 --- a/xbe/assets/icons/gear.svg +++ b/xbe/assets/icons/gear.svg @@ -1,21 +1,15 @@ - - - - - - - - - - + + + + + + + + - - - + + - - - diff --git a/xbe/assets/icons/host-monitor-offline.svg b/xbe/assets/icons/host-monitor-offline.svg index 6d02c2a..9acce86 100644 --- a/xbe/assets/icons/host-monitor-offline.svg +++ b/xbe/assets/icons/host-monitor-offline.svg @@ -1,11 +1,9 @@ - - - - - - + + + + + + - - diff --git a/xbe/assets/icons/host-monitor-online.svg b/xbe/assets/icons/host-monitor-online.svg index 2456bd3..fe7924d 100644 --- a/xbe/assets/icons/host-monitor-online.svg +++ b/xbe/assets/icons/host-monitor-online.svg @@ -1,9 +1,7 @@ - - - - + + + + - - diff --git a/xbe/assets/icons/host-monitor-pairing.svg b/xbe/assets/icons/host-monitor-pairing.svg index 1ae6a19..24111cd 100644 --- a/xbe/assets/icons/host-monitor-pairing.svg +++ b/xbe/assets/icons/host-monitor-pairing.svg @@ -1,8 +1,8 @@ - - - - - + + + + + diff --git a/xbe/assets/icons/support.svg b/xbe/assets/icons/support.svg index 820b0f5..269c89d 100644 --- a/xbe/assets/icons/support.svg +++ b/xbe/assets/icons/support.svg @@ -1,10 +1,7 @@ - - - - + + + + - - - From eb228ce2126d251c9f6ed7c98600bcd0a2c8b96f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 16:52:11 -0400 Subject: [PATCH 06/35] Improve video/aspect handling and add startup logs Add verbose startup debug logging and encoder diagnostics, and harden video mode selection and UI layout for widescreen/HD scenarios. - main.cpp: introduce debug_print_* helpers, log XVideoGetEncoderSettings, XVideoSetMode result, SDL initialization/window creation, and startup task lifecycle for easier diagnostics. - splash: always apply aspect correction when framebuffer vs display aspect differs; add calculate_display_width() to compute logical UI width based on effective display aspect (header and implementation), and adjust logo scaling logic. - ui/shell_screen: query encoder settings, compute logical screen width via calculate_display_width, apply horizontal SDL renderer scaling to render square-pixel UI, and restore scale after presenting. - startup/video_mode: prefer 720p progressive over 1080i interlaced when both are available; add helper is_1080i and update selection logic. - tests: add unit tests for the 4:3 correction on HD modes, calculate_display_width behavior, and preferring 720p over 1080i. - scripts/README: note widescreen tool in README and update run-xemu.sh to set display.ui aspect_ratio = 'native' in generated config. These changes improve widescreen support, ensure UI layout uses a logical square-pixel width, prefer progressive HD modes for clarity, and make startup video/debug issues easier to diagnose. --- README.md | 3 ++ scripts/run-xemu.sh | 2 ++ src/main.cpp | 46 +++++++++++++++++++++++- src/splash/splash_layout.cpp | 7 ++-- src/splash/splash_layout.h | 14 ++++++++ src/startup/video_mode.cpp | 16 +++++++++ src/ui/shell_screen.cpp | 25 ++++++++++--- tests/unit/splash/splash_layout_test.cpp | 13 +++++++ tests/unit/startup/video_mode_test.cpp | 15 ++++++++ 9 files changed, 133 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index ad1342b..e7b1412 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,9 @@ 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 + ## Todo - Build diff --git a/scripts/run-xemu.sh b/scripts/run-xemu.sh index fc5bcb4..fffb265 100644 --- a/scripts/run-xemu.sh +++ b/scripts/run-xemu.sh @@ -71,6 +71,8 @@ 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' diff --git a/src/main.cpp b/src/main.cpp index 50cfc36..8b3abb4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -35,6 +35,32 @@ namespace { return 1; } + void debug_print_startup_checkpoint(const char *message) { + if (message == nullptr) { + return; + } + + debugPrint("[startup] %s\n", message); + } + + void debug_print_video_mode_selection(const startup::VideoModeSelection &selection) { + debugPrint("[startup] Detected %u video mode(s)\n", static_cast(selection.availableVideoModes.size())); + for (const std::string &line : startup::format_video_mode_lines(selection)) { + debugPrint("[startup] %s\n", line.c_str()); + } + } + + void debug_print_encoder_settings(DWORD encoderSettings) { + debugPrint( + "[startup] Encoder settings: 0x%08lX (widescreen=%s, 480p=%s, 720p=%s, 1080i=%s)\n", + static_cast(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" + ); + } + int run_startup_task(void *context) { StartupTaskState *task = static_cast(context); if (task == nullptr) { @@ -93,16 +119,27 @@ int main() { logger.set_minimum_level(clientState.loggingLevel); logger.log(logging::LogLevel::info, "app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); logger.log(logging::LogLevel::info, "logging", "Writing runtime logs to " + logFilePath); + 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); + + 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() + ); - XVideoSetMode(bestVideoMode.width, bestVideoMode.height, bestVideoMode.bpp, bestVideoMode.refresh); + const BOOL setVideoModeResult = XVideoSetMode(bestVideoMode.width, bestVideoMode.height, bestVideoMode.bpp, bestVideoMode.refresh); + debug_print_startup_checkpoint(setVideoModeResult ? "Returned from XVideoSetMode successfully" : "XVideoSetMode returned failure"); + debug_print_startup_checkpoint("About to call SDL_Init"); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { return report_startup_failure(logger, "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, @@ -116,17 +153,24 @@ int main() { 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"); } logger.log(logging::LogLevel::info, "app", "Showing splash screen"); + debug_print_startup_checkpoint("About to show splash screen"); splash::show_splash_screen(window, bestVideoMode, [&startupTask]() { return !startupTask.completed.load(std::memory_order_acquire); }); + debug_print_startup_checkpoint("Returned from splash screen"); finish_startup_task(logger, clientState, &startupTask); for (const std::string &line : startup::format_video_mode_lines(videoModeSelection)) { diff --git a/src/splash/splash_layout.cpp b/src/splash/splash_layout.cpp index 338506f..a840f4c 100644 --- a/src/splash/splash_layout.cpp +++ b/src/splash/splash_layout.cpp @@ -30,15 +30,18 @@ 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 (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..4c482b0 100644 --- a/src/splash/splash_layout.h +++ b/src/splash/splash_layout.h @@ -39,6 +39,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/startup/video_mode.cpp b/src/startup/video_mode.cpp index 924c0d5..a93b9e4 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -6,7 +6,23 @@ 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; } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index ca8f1ab..e89210f 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -26,6 +26,7 @@ #include "src/network/host_pairing.h" #include "src/network/runtime_network.h" #include "src/os.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" @@ -2426,6 +2427,8 @@ namespace { bool draw_shell( SDL_Renderer *renderer, + const VIDEO_MODE &videoMode, + unsigned long encoderSettings, SDL_Texture *titleLogoTexture, TTF_Font *titleFont, TTF_Font *bodyFont, @@ -2434,12 +2437,18 @@ namespace { CoverArtTextureCache *textureCache, AssetTextureCache *assetCache ) { - int screenWidth = 0; - int screenHeight = 0; - if (SDL_GetRendererOutputSize(renderer, &screenWidth, &screenHeight) != 0 || screenWidth <= 0 || screenHeight <= 0) { + 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); @@ -2453,6 +2462,10 @@ namespace { 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); @@ -2867,6 +2880,7 @@ namespace { } SDL_RenderPresent(renderer); + SDL_RenderSetScale(renderer, 1.0f, 1.0f); return true; } @@ -2962,6 +2976,7 @@ namespace ui { HostProbeTaskState hostProbeTask {}; CoverArtTextureCache coverArtTextureCache {}; AssetTextureCache assetTextureCache {}; + const unsigned long encoderSettings = XVideoGetEncoderSettings(); reset_pairing_task(&pairingTask); reset_app_list_task(&appListTask); reset_app_art_task(&appArtTask); @@ -2971,7 +2986,7 @@ namespace ui { const auto draw_current_shell = [&]() { const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); - if (draw_shell(renderer, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { + if (draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { return true; } @@ -3178,7 +3193,7 @@ namespace ui { start_app_art_task_if_needed(logger, state, &appArtTask); const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); - if (!draw_shell(renderer, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { + if (!draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); running = false; break; diff --git a/tests/unit/splash/splash_layout_test.cpp b/tests/unit/splash/splash_layout_test.cpp index 5d6bead..52a7096 100644 --- a/tests/unit/splash/splash_layout_test.cpp +++ b/tests/unit/splash/splash_layout_test.cpp @@ -18,6 +18,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/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index 9758cdc..36510a5 100644 --- a/tests/unit/startup/video_mode_test.cpp +++ b/tests/unit/startup/video_mode_test.cpp @@ -41,4 +41,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 From 8d963ee276f876fcacc35c879d47f67afc7ff12c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:35:57 -0400 Subject: [PATCH 07/35] Add lwIP select compatibility; drop ws2_32 Remove the imported ws2_32 target and all NXDK::ws2_32 link usages from CMake (FindNXDK.cmake, xbox-build.cmake, GetOpenSSL.cmake) and link against NXDK::Net instead. Update OpenSSL target linkage to omit ws2_32 on HOST Windows builds. Add lwIP/socket compatibility helpers to src/_nxdk_compat/openssl_compat.h: include lwip/opt.h, sys/time.h and string.h, declare lwip_select, provide defaults for LWIP_SOCKET_OFFSET and FD_SETSIZE (based on MEMP_NUM_NETCONN), and define fd_set plus FD_* macros and a select macro that maps to lwip_select. These changes remove the dependency on WinSock symbols and provide a minimal fd_set/select shim for lwIP on NXDK. --- cmake/modules/FindNXDK.cmake | 22 ---------------------- cmake/modules/GetOpenSSL.cmake | 3 --- cmake/xbox-build.cmake | 5 ++--- src/_nxdk_compat/openssl_compat.h | 27 +++++++++++++++++++++++++++ tests/CMakeLists.txt | 7 +------ 5 files changed, 30 insertions(+), 34 deletions(-) diff --git a/cmake/modules/FindNXDK.cmake b/cmake/modules/FindNXDK.cmake index 3aafed0..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,18 +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 - NXDK::Net - ws2_32 - ) - target_include_directories( - NXDK::ws2_32 - SYSTEM INTERFACE - "${NXDK_DIR}/lib/winapi/ws2_32" - ) -endif () diff --git a/cmake/modules/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake index 8d10321..d95bc53 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -162,9 +162,6 @@ if(NOT TARGET OpenSSL::Crypto) set_target_properties(OpenSSL::Crypto PROPERTIES IMPORTED_LOCATION "${OPENSSL_CRYPTO_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${OPENSSL_INCLUDE_DIR}") - if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "HOST" AND (WIN32 OR MINGW OR CMAKE_HOST_WIN32)) - target_link_libraries(OpenSSL::Crypto INTERFACE ws2_32 crypt32 gdi32 advapi32 user32) - endif() add_dependencies(OpenSSL::Crypto openssl_external) endif() diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index e35d945..857f538 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -59,7 +59,7 @@ 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_link_libraries(enet PUBLIC NXDK::NXDK NXDK::Net) target_include_directories(enet PRIVATE "${MOONLIGHT_NXDK_NET_INCLUDE_DIR}" "${MOONLIGHT_NXDK_LIBC_EXTENSIONS_DIR}" @@ -73,7 +73,6 @@ 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() add_executable(${CMAKE_PROJECT_NAME} @@ -93,7 +92,7 @@ target_link_libraries(${CMAKE_PROJECT_NAME} PUBLIC NXDK::NXDK NXDK::NXDK_CXX - NXDK::ws2_32 + NXDK::Net NXDK::SDL2 NXDK::SDL2_Image NXDK::SDL2_TTF diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h index ae0a040..dd0f722 100644 --- a/src/_nxdk_compat/openssl_compat.h +++ b/src/_nxdk_compat/openssl_compat.h @@ -4,8 +4,11 @@ #define __STDC_WANT_LIB_EXT1__ 1 #endif +#include #include +#include #include +#include #include #include @@ -15,6 +18,30 @@ extern "C" { ssize_t lwip_recv(int s, void *mem, size_t len, int flags); ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags); + int lwip_select(int maxfdp1, struct fd_set *readset, struct fd_set *writeset, struct fd_set *exceptset, struct timeval *timeout); + +#ifndef LWIP_SOCKET_OFFSET + #define LWIP_SOCKET_OFFSET 0 +#endif + +#ifndef FD_SETSIZE + #define FD_SETSIZE MEMP_NUM_NETCONN +#endif + +#ifndef FD_SET + typedef struct fd_set { + unsigned char fd_bits[(FD_SETSIZE + 7) / 8]; + } 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)))) + #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)))) + #define FD_ISSET(n, p) (((p)->fd_bits[((n) - LWIP_SOCKET_OFFSET) / 8] & (1u << (((n) - LWIP_SOCKET_OFFSET) & 7))) != 0) + #define FD_ZERO(p) memset((void *) (p), 0, sizeof(*(p))) +#endif + +#ifndef select + #define select(maxfdp1, readset, writeset, exceptset, timeout) lwip_select(maxfdp1, readset, writeset, exceptset, timeout) +#endif #ifndef F_OK #define F_OK 0 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b5d4f05..7ea792b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -59,12 +59,7 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} PRIVATE gtest_main - ${MOONLIGHT_TEST_OPENSSL_LIBRARIES} - ws2_32 - crypt32 - gdi32 - advapi32 - user32) + ${MOONLIGHT_TEST_OPENSSL_LIBRARIES}) target_compile_options(${PROJECT_NAME} PRIVATE ${TEST_COVERAGE_COMPILE_OPTIONS}) From a3b46bd4020037ac48f64884c684af99aec1902b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 21:31:39 -0400 Subject: [PATCH 08/35] Add platform filesystem utils and CMake deps Introduce platform::filesystem utilities and centralize dependency preparation. - Add src/platform/filesystem_utils.{h,cpp} to provide cross-platform path operations, directory creation, file size checks and prefix checks. - Replace duplicated path and directory helper code in logging, startup (client_identity_storage, cover_art_cache, host_storage, saved_files) with platform:: APIs and use platform::join_path for consistent path handling. - Make saved_files and host_pairing more portable: add POSIX directory enumeration and socket handling fallbacks, unify SOCKET handling and non-blocking configuration. - Improve OpenSSL vendored build: add msys2 support, shell-quoting helpers and platform-specific configure/build commands in cmake/modules/GetOpenSSL.cmake; expose provider/platform info and link ws2_32 for Windows host builds when needed. - Add cmake/moonlight-dependencies.cmake to prepare common third-party dependencies (moonlight-common-c, OpenSSL, enet) and use it from top-level CMakeLists and xbox-build.cmake. - Tests: add tests/support/filesystem_test_utils.h and update tests to use it; require prepared Moonlight dependency targets in tests/CMakeLists and link Moonlight::OpenSSL (add ws2_32 on Windows), and add dependency on moonlight-common-c for test target. These changes reduce duplicated filesystem/platform code, improve portability across Windows/Unix/NXDK, and centralize dependency configuration for builds and tests. --- .github/workflows/ci.yml | 4 + CMakeLists.txt | 2 + cmake/modules/GetOpenSSL.cmake | 203 +++++++++++++++--- cmake/moonlight-dependencies.cmake | 75 +++++++ cmake/xbox-build.cmake | 31 +-- src/logging/log_file.cpp | 73 +------ src/network/host_pairing.cpp | 57 +++-- src/platform/filesystem_utils.cpp | 194 +++++++++++++++++ src/platform/filesystem_utils.h | 23 ++ src/startup/client_identity_storage.cpp | 77 +------ src/startup/cover_art_cache.cpp | 73 +------ src/startup/host_storage.cpp | 69 +----- src/startup/saved_files.cpp | 95 ++++---- tests/CMakeLists.txt | 22 +- tests/support/filesystem_test_utils.h | 33 +++ tests/unit/logging/log_file_test.cpp | 7 +- .../startup/client_identity_storage_test.cpp | 46 ++-- tests/unit/startup/cover_art_cache_test.cpp | 24 +-- tests/unit/startup/host_storage_test.cpp | 28 +-- tests/unit/startup/saved_files_test.cpp | 68 +++--- 20 files changed, 714 insertions(+), 490 deletions(-) create mode 100644 cmake/moonlight-dependencies.cmake create mode 100644 src/platform/filesystem_utils.cpp create mode 100644 src/platform/filesystem_utils.h create mode 100644 tests/support/filesystem_test_utils.h diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00ddff9..1d1cac3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -145,6 +145,10 @@ jobs: tar -czf ./artifacts/Moonlight.tar.gz ./${CMAKE_BUILD}/xbox/xbe cp ./${CMAKE_BUILD}/xbox/Moonlight.iso ./artifacts + - name: Debug logs + if: failure() + run: cat ./build/openssl_external-prefix/src/openssl_external-stamp/openssl_external-build.log || true + - name: Upload Artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/CMakeLists.txt b/CMakeLists.txt index 2d860a2..b76265b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,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/cmake/modules/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake index d95bc53..8b2b3a7 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -1,6 +1,10 @@ include_guard(GLOBAL) include(ExternalProject) +include("${CMAKE_CURRENT_LIST_DIR}/../msys2.cmake") + +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") @@ -8,12 +12,43 @@ 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() +set(MOONLIGHT_OPENSSL_PROVIDER "BUNDLED") + +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() + +function(_moonlight_shell_quote out_var value) + string(REPLACE "'" "'\"'\"'" _escaped_value "${value}") + set(${out_var} "'${_escaped_value}'" PARENT_SCOPE) +endfunction() + +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") @@ -40,6 +75,16 @@ set(OPENSSL_CONFIGURE_OPTIONS no-dso) set(OPENSSL_ENV ${CMAKE_COMMAND} -E env) +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() if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "XBOX") find_program(OPENSSL_MAKE_EXECUTABLE NAMES make REQUIRED) @@ -92,48 +137,144 @@ if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "XBOX") ${OPENSSL_MAKE_EXECUTABLE} install_dev) else() - if(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(OPENSSL_CONFIGURE_TARGET mingw64) - else() - set(OPENSSL_CONFIGURE_TARGET mingw) - endif() + if(MOONLIGHT_OPENSSL_WINDOWS_HOST) + if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(OPENSSL_CONFIGURE_TARGET mingw64) + else() + set(OPENSSL_CONFIGURE_TARGET mingw) + endif() - if(NOT DEFINED CMAKE_MAKE_PROGRAM OR CMAKE_MAKE_PROGRAM STREQUAL "") - message(FATAL_ERROR "CMAKE_MAKE_PROGRAM must be defined for the host vendored OpenSSL build") - endif() - set(OPENSSL_MAKE_EXECUTABLE "${CMAKE_MAKE_PROGRAM}") + set(_openssl_windows_env + "CC=${CMAKE_C_COMPILER}" + "CXX=${CMAKE_CXX_COMPILER}" + "CFLAGS=-DNOCRYPT" + "CPPFLAGS=-DWIN32_LEAN_AND_MEAN") + if(DEFINED CMAKE_AR AND NOT CMAKE_AR STREQUAL "") + list(APPEND _openssl_windows_env "AR=${CMAKE_AR}") + endif() + if(DEFINED CMAKE_RANLIB AND NOT CMAKE_RANLIB STREQUAL "") + list(APPEND _openssl_windows_env "RANLIB=${CMAKE_RANLIB}") + endif() - list(APPEND OPENSSL_ENV - "CC=${CMAKE_C_COMPILER}" - "CXX=${CMAKE_CXX_COMPILER}") + if(MOONLIGHT_OPENSSL_IN_ACTIVE_MSYS) + find_program(OPENSSL_MAKE_EXECUTABLE NAMES make REQUIRED) + list(APPEND OPENSSL_ENV ${_openssl_windows_env}) + set(OPENSSL_BUILD_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + build_libs) + set(OPENSSL_INSTALL_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + install_dev) + else() + moonlight_get_windows_msys2_shell(OPENSSL_MSYS2_SHELL) - set(OPENSSL_BUILD_COMMAND - ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} - build_libs) - set(OPENSSL_INSTALL_COMMAND - ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} - install_dev) + _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 + "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 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 install_dev") + endif() + 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(DEFINED CMAKE_AR AND NOT CMAKE_AR STREQUAL "") - list(APPEND OPENSSL_ENV "AR=${CMAKE_AR}") + 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(DEFINED CMAKE_RANLIB AND NOT CMAKE_RANLIB STREQUAL "") - list(APPEND OPENSSL_ENV "RANLIB=${CMAKE_RANLIB}") + + 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} + build_libs) + set(OPENSSL_INSTALL_COMMAND + ${OPENSSL_ENV} + ${OPENSSL_MAKE_EXECUTABLE} + install_dev) endif() endif() -ExternalProject_Add(openssl_external - SOURCE_DIR "${OPENSSL_SOURCE_DIR}" - BINARY_DIR "${OPENSSL_BUILD_DIR}" - CONFIGURE_COMMAND +if(NOT DEFINED OPENSSL_CONFIGURE_COMMAND) + set(OPENSSL_CONFIGURE_COMMAND ${OPENSSL_ENV} "${PERL_EXECUTABLE}" "${OPENSSL_SOURCE_DIR}/Configure" ${OPENSSL_CONFIGURE_TARGET} ${OPENSSL_CONFIGURE_OPTIONS} "--prefix=${OPENSSL_INSTALL_DIR}" - "--openssldir=${OPENSSL_INSTALL_DIR}/ssl" + "--openssldir=${OPENSSL_INSTALL_DIR}/ssl") +endif() + +ExternalProject_Add(openssl_external + SOURCE_DIR "${OPENSSL_SOURCE_DIR}" + BINARY_DIR "${OPENSSL_BUILD_DIR}" + CONFIGURE_COMMAND + ${OPENSSL_CONFIGURE_COMMAND} BUILD_COMMAND ${OPENSSL_BUILD_COMMAND} INSTALL_COMMAND @@ -162,6 +303,9 @@ if(NOT TARGET OpenSSL::Crypto) set_target_properties(OpenSSL::Crypto PROPERTIES IMPORTED_LOCATION "${OPENSSL_CRYPTO_LIBRARY}" INTERFACE_INCLUDE_DIRECTORIES "${OPENSSL_INCLUDE_DIR}") + if(WIN32 AND MOONLIGHT_OPENSSL_PLATFORM STREQUAL "HOST") + target_link_libraries(OpenSSL::Crypto INTERFACE ws2_32) + endif() add_dependencies(OpenSSL::Crypto openssl_external) endif() @@ -176,5 +320,6 @@ endif() message(STATUS "OpenSSL version: ${OPENSSL_VERSION}") message(STATUS "OpenSSL platform: ${MOONLIGHT_OPENSSL_PLATFORM}") +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..3cc7d09 --- /dev/null +++ b/cmake/moonlight-dependencies.cmake @@ -0,0 +1,75 @@ +include_guard(GLOBAL) + +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 TARGET openssl_external) + add_dependencies(moonlight-common-c openssl_external) + 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/xbox-build.cmake b/cmake/xbox-build.cmake index 857f538..3faa551 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 @@ -45,35 +46,7 @@ endif() set(CMAKE_CXX_FLAGS_RELEASE "-O2") set(CMAKE_C_FLAGS_RELEASE "-O2") -# moonlight-common-c submodule -include(GetOpenSSL REQUIRED) -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") -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) -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) -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() +moonlight_prepare_common_dependencies() add_executable(${CMAKE_PROJECT_NAME} ${MOONLIGHT_SOURCES} diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp index c7fb130..0bece09 100644 --- a/src/logging/log_file.cpp +++ b/src/logging/log_file.cpp @@ -8,75 +8,12 @@ #include #include -extern "C" { -#include -} - // local includes +#include "src/platform/filesystem_utils.h" #include "src/startup/host_storage.h" namespace { - std::string normalize_directory_component(std::string path) { - while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { - path.pop_back(); - } - return path; - } - - bool is_drive_root_path(const std::string &path) { - return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; - } - - 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { - partialPath = directoryPath.substr(0, 2); - startIndex = 2; - } - - for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { - partialPath.push_back(directoryPath[index]); - const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; - const bool atPathEnd = index + 1 == directoryPath.size(); - if (!atSeparator && !atPathEnd) { - continue; - } - - if (is_drive_root_path(partialPath)) { - continue; - } - - const std::string normalizedPath = normalize_directory_component(partialPath); - if (normalizedPath.empty()) { - continue; - } - - if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { - if (errorMessage != nullptr) { - *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); - } - return false; - } - } - - return true; - } - - std::string parent_directory(const std::string &filePath) { - const std::size_t separatorIndex = filePath.find_last_of("\\/"); - if (separatorIndex == std::string::npos) { - return {}; - } - - return filePath.substr(0, separatorIndex); - } - std::string persisted_log_line(const logging::LogEntry &entry) { return std::string("[") + logging::format_timestamp(entry.timestamp) + "] " + logging::format_entry(entry); } @@ -87,16 +24,16 @@ namespace logging { std::string default_log_file_path() { const std::string hostStoragePath = startup::default_host_storage_path(); - const std::string directoryPath = parent_directory(hostStoragePath); + const std::string directoryPath = platform::parent_directory(hostStoragePath); if (directoryPath.empty()) { return "moonlight.log"; } - return directoryPath + "\\moonlight.log"; + return platform::join_path(directoryPath, "moonlight.log"); } bool reset_log_file(const std::string &filePath, std::string *errorMessage) { - if (!ensure_directory_exists(parent_directory(filePath), errorMessage)) { + if (!platform::ensure_directory_exists(platform::parent_directory(filePath), errorMessage)) { return false; } @@ -119,7 +56,7 @@ namespace logging { } bool append_log_file_entry(const LogEntry &entry, const std::string &filePath, std::string *errorMessage) { - if (!ensure_directory_exists(parent_directory(filePath), errorMessage)) { + if (!platform::ensure_directory_exists(platform::parent_directory(filePath), errorMessage)) { return false; } diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 5b76a25..e18faf9 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -20,15 +20,28 @@ #include #include #include -#else - #include +#elif defined(_WIN32) +// clang-format off + // winsock2 must be included before windows.h #include + #include +// clang-format on +#else + #include + #include + #include + #include + #include + #include + #include #endif // nxdk includes #ifdef NXDK #include +#endif +#if defined(NXDK) || !defined(_WIN32) using SOCKET = int; #ifndef INVALID_SOCKET @@ -87,7 +100,7 @@ namespace { struct WsaGuard { WsaGuard(): initialized(false) { -#ifdef NXDK +#if defined(NXDK) || !defined(_WIN32) initialized = true; #else WSADATA wsaData {}; @@ -96,7 +109,7 @@ namespace { } ~WsaGuard() { -#ifndef NXDK +#if defined(_WIN32) && !defined(NXDK) if (initialized) { WSACleanup(); } @@ -113,7 +126,11 @@ namespace { ~SocketGuard() { if (handle != INVALID_SOCKET) { +#if defined(_WIN32) && !defined(NXDK) closesocket(handle); +#else + close(handle); +#endif } } @@ -149,7 +166,7 @@ namespace { } int last_socket_error() { -#ifdef NXDK +#if defined(NXDK) || !defined(_WIN32) return errno; #else return WSAGetLastError(); @@ -157,7 +174,7 @@ namespace { } bool is_connect_in_progress_error(int errorCode) { -#ifdef NXDK +#if defined(NXDK) || !defined(_WIN32) return errorCode == EWOULDBLOCK || errorCode == EINPROGRESS || errorCode == EALREADY; #else return errorCode == WSAEWOULDBLOCK || errorCode == WSAEINPROGRESS || errorCode == WSAEALREADY; @@ -165,7 +182,7 @@ namespace { } bool is_timeout_error(int errorCode) { -#ifdef NXDK +#if defined(NXDK) || !defined(_WIN32) return errorCode == ETIMEDOUT; #else return errorCode == WSAETIMEDOUT; @@ -175,19 +192,31 @@ namespace { bool set_socket_non_blocking(SOCKET socketHandle, bool enabled, std::string *errorMessage) { #ifdef NXDK int nonBlockingMode = enabled ? 1 : 0; -#else +#elif defined(_WIN32) u_long nonBlockingMode = enabled ? 1UL : 0UL; #endif +#if defined(NXDK) || defined(_WIN32) if (ioctlsocket(socketHandle, FIONBIO, &nonBlockingMode) != 0) { 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) { -#ifdef NXDK +#if defined(NXDK) || !defined(_WIN32) timeval timeout { SOCKET_TIMEOUT_MILLISECONDS / 1000, (SOCKET_TIMEOUT_MILLISECONDS % 1000) * 1000, @@ -1669,12 +1698,16 @@ namespace { } int socketError = 0; -#ifdef NXDK - socklen_t socketErrorLength = sizeof(socketError); -#else +#if defined(_WIN32) && !defined(NXDK) int socketErrorLength = sizeof(socketError); +#else + socklen_t socketErrorLength = sizeof(socketError); #endif +#if defined(_WIN32) && !defined(NXDK) if (getsockopt(socketGuard->handle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { +#else + if (getsockopt(socketGuard->handle, 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) { diff --git a/src/platform/filesystem_utils.cpp b/src/platform/filesystem_utils.cpp new file mode 100644 index 0000000..e2e0c5d --- /dev/null +++ b/src/platform/filesystem_utils.cpp @@ -0,0 +1,194 @@ +// class header include +#include "src/platform/filesystem_utils.h" + +// standard includes +#include +#include +#include + +// platform includes +#if defined(_WIN32) || defined(NXDK) + #include +extern "C" { + #include +} +#else + #include + #include +#endif + +namespace { + + bool is_drive_root_path(const std::string &path) { +#if defined(_WIN32) || defined(NXDK) + return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; +#else + (void) path; + return false; +#endif + } + + bool is_root_path(const std::string &path) { +#if defined(_WIN32) || defined(NXDK) + return is_drive_root_path(path); +#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(), 0777) == 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(const std::string &filePath) { + const std::size_t separatorIndex = filePath.find_last_of("\\/"); + if (separatorIndex == std::string::npos) { + return {}; + } + return filePath.substr(0, separatorIndex); + } + + 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]); + const bool atPathEnd = index + 1 == directoryPath.size(); + if (!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(const std::string &path, std::uint64_t *sizeBytes) { +#if defined(_WIN32) || defined(NXDK) + WIN32_FILE_ATTRIBUTE_DATA fileData {}; + if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &fileData)) { + return false; + } + if ((fileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0U) { + return false; + } + + if (sizeBytes != nullptr) { + ULARGE_INTEGER sizeValue {}; + sizeValue.HighPart = fileData.nFileSizeHigh; + sizeValue.LowPart = fileData.nFileSizeLow; + *sizeBytes = sizeValue.QuadPart; + } + return true; +#else + struct stat status {}; + if (stat(path.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..0b35aa2 --- /dev/null +++ b/src/platform/filesystem_utils.h @@ -0,0 +1,23 @@ +#pragma once + +// standard includes +#include +#include + +namespace platform { + + char preferred_path_separator(); + + bool is_path_separator(char character); + + std::string join_path(const std::string &left, const std::string &right); + + std::string parent_directory(const std::string &filePath); + + bool ensure_directory_exists(const std::string &directoryPath, std::string *errorMessage = nullptr); + + bool try_get_file_size(const std::string &path, std::uint64_t *sizeBytes = nullptr); + + bool path_has_prefix(const std::string &path, const std::string &prefix); + +} // namespace platform diff --git a/src/startup/client_identity_storage.cpp b/src/startup/client_identity_storage.cpp index 0011d53..9a679cf 100644 --- a/src/startup/client_identity_storage.cpp +++ b/src/startup/client_identity_storage.cpp @@ -8,11 +8,8 @@ #include #include -extern "C" { -#include -} - // local includes +#include "src/platform/filesystem_utils.h" #include "src/startup/host_storage.h" namespace { @@ -33,74 +30,8 @@ namespace { int errorCode; }; - std::string parent_directory(const std::string &filePath) { - const std::size_t separatorIndex = filePath.find_last_of("\\/"); - if (separatorIndex == std::string::npos) { - return {}; - } - - return filePath.substr(0, separatorIndex); - } - std::string join_path(const std::string &left, const std::string &right) { - if (left.empty()) { - return right; - } - if (left.back() == '\\' || left.back() == '/') { - return left + right; - } - return left + "\\" + right; - } - - std::string normalize_directory_component(std::string path) { - while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { - path.pop_back(); - } - return path; - } - - bool is_drive_root_path(const std::string &path) { - return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; - } - - 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { - partialPath = directoryPath.substr(0, 2); - startIndex = 2; - } - - for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { - partialPath.push_back(directoryPath[index]); - const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; - const bool atPathEnd = index + 1 == directoryPath.size(); - if (!atSeparator && !atPathEnd) { - continue; - } - - if (is_drive_root_path(partialPath)) { - continue; - } - - const std::string normalizedPath = normalize_directory_component(partialPath); - if (normalizedPath.empty()) { - continue; - } - - if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { - if (errorMessage != nullptr) { - *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); - } - return false; - } - } - - return true; + return platform::join_path(left, right); } ReadFileTextResult read_file_text(const std::string &filePath, std::string *errorMessage) { @@ -171,7 +102,7 @@ namespace { namespace startup { std::string default_client_identity_directory() { - const std::string hostStorageDirectory = parent_directory(default_host_storage_path()); + const std::string hostStorageDirectory = platform::parent_directory(default_host_storage_path()); if (hostStorageDirectory.empty()) { return "pairing"; } @@ -236,7 +167,7 @@ namespace startup { SaveClientIdentityResult save_client_identity(const network::PairingIdentity &identity, const std::string &directoryPath) { std::string errorMessage; - if (!ensure_directory_exists(directoryPath, &errorMessage)) { + if (!platform::ensure_directory_exists(directoryPath, &errorMessage)) { return {false, errorMessage}; } diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp index 31f5338..2eb1105 100644 --- a/src/startup/cover_art_cache.cpp +++ b/src/startup/cover_art_cache.cpp @@ -9,10 +9,6 @@ #include #include -extern "C" { -#include -} - // nxdk includes #if defined(__has_include) #if __has_include() @@ -26,6 +22,9 @@ extern "C" { #endif #endif +// local includes +#include "src/platform/filesystem_utils.h" + namespace { bool append_error(std::string *errorMessage, std::string message) { @@ -35,66 +34,6 @@ namespace { return false; } - std::string normalize_directory_component(std::string path) { - while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { - path.pop_back(); - } - return path; - } - - bool is_drive_root_path(const std::string &path) { - return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; - } - - 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { - partialPath = directoryPath.substr(0, 2); - startIndex = 2; - } - - for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { - partialPath.push_back(directoryPath[index]); - const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; - const bool atPathEnd = index + 1 == directoryPath.size(); - if (!atSeparator && !atPathEnd) { - continue; - } - - if (is_drive_root_path(partialPath)) { - continue; - } - - const std::string normalizedPath = normalize_directory_component(partialPath); - if (normalizedPath.empty()) { - continue; - } - - if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { - if (errorMessage != nullptr) { - *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); - } - return false; - } - } - - return true; - } - - std::string parent_directory(const std::string &filePath) { - const std::size_t separatorIndex = filePath.find_last_of("\\/"); - if (separatorIndex == std::string::npos) { - return {}; - } - - return filePath.substr(0, separatorIndex); - } - std::string title_scoped_storage_root() { #ifdef MOONLIGHT_HAS_NXDK_XBE #ifdef MOONLIGHT_HAS_NXDK_MOUNT @@ -112,7 +51,7 @@ namespace { } std::string cover_art_cache_path(std::string_view cacheKey, const std::string &cacheRoot) { - return cacheRoot + "\\" + std::string(cacheKey) + ".bin"; + return platform::join_path(cacheRoot, std::string(cacheKey) + ".bin"); } uint64_t fnv1a_64(std::string_view text) { @@ -215,10 +154,10 @@ namespace startup { SaveCoverArtResult save_cover_art(std::string_view cacheKey, const std::vector &bytes, const std::string &cacheRoot) { std::string errorMessage; - if (!ensure_directory_exists(cacheRoot, &errorMessage)) { + if (!platform::ensure_directory_exists(cacheRoot, &errorMessage)) { return {false, errorMessage}; } - if (!ensure_directory_exists(parent_directory(cover_art_cache_path(cacheKey, cacheRoot)), &errorMessage)) { + if (!platform::ensure_directory_exists(platform::parent_directory(cover_art_cache_path(cacheKey, cacheRoot)), &errorMessage)) { return {false, errorMessage}; } diff --git a/src/startup/host_storage.cpp b/src/startup/host_storage.cpp index 5c3aefb..9c8a82b 100644 --- a/src/startup/host_storage.cpp +++ b/src/startup/host_storage.cpp @@ -8,10 +8,6 @@ #include #include -extern "C" { -#include -} - // nxdk includes #if defined(__has_include) #if __has_include() @@ -25,6 +21,9 @@ extern "C" { #endif #endif +// local includes +#include "src/platform/filesystem_utils.h" + namespace { std::string read_all_text(FILE *file) { @@ -45,66 +44,6 @@ namespace { return content; } - std::string normalize_directory_component(std::string path) { - while (path.size() > 3 && (path.back() == '\\' || path.back() == '/')) { - path.pop_back(); - } - return path; - } - - bool is_drive_root_path(const std::string &path) { - return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; - } - - 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 (directoryPath.size() >= 2 && directoryPath[1] == ':') { - partialPath = directoryPath.substr(0, 2); - startIndex = 2; - } - - for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { - partialPath.push_back(directoryPath[index]); - const bool atSeparator = directoryPath[index] == '\\' || directoryPath[index] == '/'; - const bool atPathEnd = index + 1 == directoryPath.size(); - if (!atSeparator && !atPathEnd) { - continue; - } - - if (is_drive_root_path(partialPath)) { - continue; - } - - const std::string normalizedPath = normalize_directory_component(partialPath); - if (normalizedPath.empty()) { - continue; - } - - if (_mkdir(normalizedPath.c_str()) != 0 && errno != EEXIST) { - if (errorMessage != nullptr) { - *errorMessage = "Failed to create directory '" + normalizedPath + "': " + std::strerror(errno); - } - return false; - } - } - - return true; - } - - std::string parent_directory(const std::string &filePath) { - const std::size_t separatorIndex = filePath.find_last_of("\\/"); - if (separatorIndex == std::string::npos) { - return {}; - } - - return filePath.substr(0, separatorIndex); - } - std::string title_scoped_storage_root() { #ifdef MOONLIGHT_HAS_NXDK_XBE #ifdef MOONLIGHT_HAS_NXDK_MOUNT @@ -160,7 +99,7 @@ namespace startup { SaveSavedHostsResult save_saved_hosts(const std::vector &hosts, const std::string &filePath) { std::string errorMessage; - if (!ensure_directory_exists(parent_directory(filePath), &errorMessage)) { + if (!platform::ensure_directory_exists(platform::parent_directory(filePath), &errorMessage)) { return {false, errorMessage}; } diff --git a/src/startup/saved_files.cpp b/src/startup/saved_files.cpp index 2616854..99c76be 100644 --- a/src/startup/saved_files.cpp +++ b/src/startup/saved_files.cpp @@ -13,10 +13,17 @@ #include // platform includes -#include +#if defined(_WIN32) || defined(NXDK) + #include +#else + #include + #include + #include +#endif // local includes #include "src/logging/log_file.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" @@ -42,13 +49,7 @@ namespace { } std::string join_path(const std::string &left, const std::string &right) { - if (left.empty()) { - return right; - } - if (left.back() == '\\' || left.back() == '/') { - return left + right; - } - return left + "\\" + right; + return platform::join_path(left, right); } std::string file_name_from_path(const std::string &path) { @@ -57,16 +58,7 @@ namespace { } 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 (std::tolower(static_cast(path[index])) != std::tolower(static_cast(prefix[index]))) { - return false; - } - } - return true; + return platform::path_has_prefix(path, prefix); } std::string relative_path_from_root(const std::string &rootPath, const std::string &path) { @@ -82,21 +74,7 @@ namespace { } bool try_get_file_size(const std::string &path, std::uint64_t *sizeBytes) { - WIN32_FILE_ATTRIBUTE_DATA fileData {}; - if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &fileData)) { - return false; - } - if (fileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { - return false; - } - - if (sizeBytes != nullptr) { - ULARGE_INTEGER sizeValue {}; - sizeValue.HighPart = fileData.nFileSizeHigh; - sizeValue.LowPart = fileData.nFileSizeLow; - *sizeBytes = sizeValue.QuadPart; - } - return true; + return platform::try_get_file_size(path, sizeBytes); } ResolvedSavedFileCatalogConfig resolve_config(const startup::SavedFileCatalogConfig &config) { @@ -138,6 +116,7 @@ namespace { return; } +#if defined(_WIN32) || defined(NXDK) WIN32_FIND_DATAA findData {}; const std::string searchPattern = join_path(rootPath, "*"); HANDLE handle = FindFirstFileA(searchPattern.c_str(), &findData); @@ -173,6 +152,50 @@ namespace { if (lastError != ERROR_NO_MORE_FILES && warnings != nullptr) { warnings->push_back("Stopped enumerating saved files in '" + rootPath + "' early: error " + std::to_string(static_cast(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 (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) { @@ -211,9 +234,9 @@ namespace startup { add_file_if_present(&result.files, &seenPaths, resolvedConfig.hostStoragePath, file_name_from_path(resolvedConfig.hostStoragePath)); 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), "pairing\\uniqueid.dat"); - add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_CERTIFICATE_FILE_NAME), "pairing\\client.pem"); - add_file_if_present(&result.files, &seenPaths, join_path(resolvedConfig.pairingDirectory, PAIRING_PRIVATE_KEY_FILE_NAME), "pairing\\key.pem"); + 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) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7ea792b..d6dcd8a 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,7 +15,9 @@ 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) -include(GetOpenSSL REQUIRED) +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) @@ -38,17 +40,6 @@ add_executable(${PROJECT_NAME} ${MOONLIGHT_HOST_TESTABLE_SOURCES}) set_target_properties(${PROJECT_NAME} PROPERTIES CXX_STANDARD 17) -set(MOONLIGHT_TEST_OPENSSL_LIBRARIES OpenSSL::SSL OpenSSL::Crypto) -if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" - OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") - set(MOONLIGHT_TEST_OPENSSL_LIBRARIES - -Wl,--start-group - -Wl,--whole-archive - OpenSSL::SSL - OpenSSL::Crypto - -Wl,--no-whole-archive - -Wl,--end-group) -endif() target_include_directories(${PROJECT_NAME} PRIVATE @@ -59,7 +50,10 @@ target_include_directories(${PROJECT_NAME} target_link_libraries(${PROJECT_NAME} PRIVATE gtest_main - ${MOONLIGHT_TEST_OPENSSL_LIBRARIES}) + Moonlight::OpenSSL) +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}) @@ -67,7 +61,7 @@ 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) diff --git a/tests/support/filesystem_test_utils.h b/tests/support/filesystem_test_utils.h new file mode 100644 index 0000000..22455fc --- /dev/null +++ b/tests/support/filesystem_test_utils.h @@ -0,0 +1,33 @@ +#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::string &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/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp index cb6bfe6..86240b0 100644 --- a/tests/unit/logging/log_file_test.cpp +++ b/tests/unit/logging/log_file_test.cpp @@ -1,4 +1,4 @@ -// class header include +// test header include #include "src/logging/log_file.h" // standard includes @@ -8,10 +8,13 @@ // lib includes #include +// test includes +#include "tests/support/filesystem_test_utils.h" + namespace { std::string test_log_file_path(const char *name) { - return std::string("test-output\\logging\\") + name; + return test_support::join_path(test_support::join_path("test-output", "logging"), name); } TEST(LogFileTest, AppendsEntriesAndLoadsRecentLines) { diff --git a/tests/unit/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp index 67afa0e..c17dab0 100644 --- a/tests/unit/startup/client_identity_storage_test.cpp +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -1,40 +1,34 @@ +// test header include #include "src/startup/client_identity_storage.h" +// standard includes #include -extern "C" { -#include -} - +// lib includes #include -namespace { - - void remove_if_present(const std::string &path) { - std::remove(path.c_str()); - } +// test includes +#include "tests/support/filesystem_test_utils.h" - void remove_directory_if_present(const std::string &path) { - _rmdir(path.c_str()); - } +namespace { class ClientIdentityStorageTest: public ::testing::Test { protected: void TearDown() override { - remove_if_present((nestedIdentityDirectory + "\\uniqueid.dat")); - remove_if_present((nestedIdentityDirectory + "\\client.pem")); - remove_if_present((nestedIdentityDirectory + "\\key.pem")); - remove_directory_if_present(nestedIdentityDirectory); - remove_directory_if_present(testDirectory + "\\nested"); - - remove_if_present((testDirectory + "\\uniqueid.dat")); - remove_if_present((testDirectory + "\\client.pem")); - remove_if_present((testDirectory + "\\key.pem")); - remove_directory_if_present(testDirectory); + 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 = testDirectory + "\\nested\\identity"; + std::string nestedIdentityDirectory = test_support::join_path(test_support::join_path(testDirectory, "nested"), "identity"); }; TEST_F(ClientIdentityStorageTest, SavesAndReloadsAClientIdentity) { @@ -81,9 +75,9 @@ namespace { std::string errorMessage; EXPECT_TRUE(startup::delete_client_identity(&errorMessage, testDirectory)) << errorMessage; - EXPECT_FALSE(std::remove((testDirectory + "\\uniqueid.dat").c_str()) == 0); - EXPECT_FALSE(std::remove((testDirectory + "\\client.pem").c_str()) == 0); - EXPECT_FALSE(std::remove((testDirectory + "\\key.pem").c_str()) == 0); + 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); diff --git a/tests/unit/startup/cover_art_cache_test.cpp b/tests/unit/startup/cover_art_cache_test.cpp index 1547cc3..9565f8c 100644 --- a/tests/unit/startup/cover_art_cache_test.cpp +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -1,34 +1,28 @@ +// test header include #include "src/startup/cover_art_cache.h" +// standard includes #include #include -extern "C" { -#include -} - +// lib includes #include -namespace { - - void remove_if_present(const std::string &path) { - std::remove(path.c_str()); - } +// test includes +#include "tests/support/filesystem_test_utils.h" - void remove_directory_if_present(const std::string &path) { - _rmdir(path.c_str()); - } +namespace { class CoverArtCacheTest: public ::testing::Test { protected: void TearDown() override { - remove_if_present(testFilePath); - remove_directory_if_present(testDirectory); + 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", "192.168.0.10", 42); - std::string testFilePath = testDirectory + "\\" + cacheKey + ".bin"; + std::string testFilePath = test_support::join_path(testDirectory, cacheKey + ".bin"); }; TEST_F(CoverArtCacheTest, SavesAndReloadsCachedCoverArtBytes) { diff --git a/tests/unit/startup/host_storage_test.cpp b/tests/unit/startup/host_storage_test.cpp index ee56229..25961fc 100644 --- a/tests/unit/startup/host_storage_test.cpp +++ b/tests/unit/startup/host_storage_test.cpp @@ -1,36 +1,30 @@ +// test header include #include "src/startup/host_storage.h" +// standard includes #include #include -extern "C" { -#include -} - +// lib includes #include -namespace { - - void remove_if_present(const std::string &path) { - std::remove(path.c_str()); - } +// test includes +#include "tests/support/filesystem_test_utils.h" - void remove_directory_if_present(const std::string &path) { - _rmdir(path.c_str()); - } +namespace { class HostStorageTest: public ::testing::Test { protected: void TearDown() override { - remove_if_present(nestedFilePath); - remove_directory_if_present(testDirectory + "\\nested"); - remove_if_present(testFilePath); - remove_directory_if_present(testDirectory); + 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 = testDirectory + "\\nested\\hosts.tsv"; + std::string nestedFilePath = test_support::join_path(test_support::join_path(testDirectory, "nested"), "hosts.tsv"); }; TEST_F(HostStorageTest, LoadsMissingFilesWithoutWarnings) { diff --git a/tests/unit/startup/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp index 47ca18c..98db60d 100644 --- a/tests/unit/startup/saved_files_test.cpp +++ b/tests/unit/startup/saved_files_test.cpp @@ -1,23 +1,17 @@ +// test header include #include "src/startup/saved_files.h" +// standard includes #include #include -extern "C" { -#include -} - +// lib includes #include -namespace { - - void remove_if_present(const std::string &path) { - std::remove(path.c_str()); - } +// test includes +#include "tests/support/filesystem_test_utils.h" - void remove_directory_if_present(const std::string &path) { - _rmdir(path.c_str()); - } +namespace { void write_file_bytes(const std::string &path, const std::vector &bytes) { FILE *file = std::fopen(path.c_str(), "wb"); @@ -29,14 +23,14 @@ namespace { class SavedFilesTest: public ::testing::Test { protected: std::string testDirectory = "saved-files-test"; - std::string hostStoragePath = testDirectory + "\\moonlight-hosts.tsv"; - std::string logFilePath = testDirectory + "\\moonlight.log"; - std::string pairingDirectory = testDirectory + "\\pairing"; - std::string pairingUniqueIdPath = pairingDirectory + "\\uniqueid.dat"; - std::string pairingCertificatePath = pairingDirectory + "\\client.pem"; - std::string pairingKeyPath = pairingDirectory + "\\key.pem"; - std::string coverArtDirectory = testDirectory + "\\cover-art-cache"; - std::string coverArtFilePath = coverArtDirectory + "\\cover-101.bin"; + std::string hostStoragePath = test_support::join_path(testDirectory, "moonlight-hosts.tsv"); + std::string logFilePath = test_support::join_path(testDirectory, "moonlight.log"); + std::string pairingDirectory = test_support::join_path(testDirectory, "pairing"); + 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"); startup::SavedFileCatalogConfig config { hostStoragePath, logFilePath, @@ -45,21 +39,21 @@ namespace { }; void SetUp() override { - ASSERT_EQ(_mkdir(testDirectory.c_str()), 0); - ASSERT_EQ(_mkdir(pairingDirectory.c_str()), 0); - ASSERT_EQ(_mkdir(coverArtDirectory.c_str()), 0); + ASSERT_TRUE(test_support::create_directory(testDirectory)); + ASSERT_TRUE(test_support::create_directory(pairingDirectory)); + ASSERT_TRUE(test_support::create_directory(coverArtDirectory)); } void TearDown() override { - remove_if_present(coverArtFilePath); - remove_if_present(pairingKeyPath); - remove_if_present(pairingCertificatePath); - remove_if_present(pairingUniqueIdPath); - remove_if_present(logFilePath); - remove_if_present(hostStoragePath); - remove_directory_if_present(coverArtDirectory); - remove_directory_if_present(pairingDirectory); - remove_directory_if_present(testDirectory); + 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(hostStoragePath); + test_support::remove_directory_if_present(coverArtDirectory); + test_support::remove_directory_if_present(pairingDirectory); + test_support::remove_directory_if_present(testDirectory); } }; @@ -75,12 +69,12 @@ namespace { EXPECT_TRUE(result.warnings.empty()); ASSERT_EQ(result.files.size(), 6U); - EXPECT_EQ(result.files[0].displayName, "cover-art-cache\\cover-101.bin"); + 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, "pairing\\client.pem"); - EXPECT_EQ(result.files[4].displayName, "pairing\\key.pem"); - EXPECT_EQ(result.files[5].displayName, "pairing\\uniqueid.dat"); + EXPECT_EQ(result.files[3].displayName, test_support::join_path("pairing", "client.pem")); + EXPECT_EQ(result.files[4].displayName, test_support::join_path("pairing", "key.pem")); + EXPECT_EQ(result.files[5].displayName, test_support::join_path("pairing", "uniqueid.dat")); } TEST_F(SavedFilesTest, DeletesManagedSavedFiles) { @@ -101,7 +95,7 @@ namespace { EXPECT_FALSE(startup::delete_saved_file(outsidePath, &errorMessage, config)); EXPECT_FALSE(errorMessage.empty()); - remove_if_present(outsidePath); + test_support::remove_if_present(outsidePath); } TEST_F(SavedFilesTest, FactoryResetDeletesAllManagedSavedFiles) { From 4300c1e9ac4cdd8e07346ec7bcee539262c9017a Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 6 Apr 2026 23:09:20 -0400 Subject: [PATCH 09/35] Improve OpenSSL build and MinGW support Make Windows/Mingw builds more reliable and surface better logs. CI: install mingw-w64-x86_64-cmake for MinGW CMake support and expand failure debug step to print OpenSSL configure/build logs for host and Xbox builds. build.sh: collect CMake configure args into an array, require a CMake with MinGW Makefiles on Windows, and set the MinGW generator and toolchain file. cmake/modules/GetOpenSSL.cmake: introduce a deterministic external target name, clear Make-related env vars, pass MAKEFLAGS/MFLAGS/GNUMAKEFLAGS/MAKELEVEL and enforce single-job (-j1) builds for Windows/MSYS to avoid parallel build issues; properly map compiler/AR/RANLIB into MSYS2 shell commands; use the new external target name in ExternalProject_Add and add_dependencies; and emit the OpenSSL external target in status messages. cmake/moonlight-dependencies.cmake: conditionally wire moonlight-common-c to the OpenSSL external target when defined and present. These changes improve reproducibility and Windows build stability. --- .github/workflows/ci.yml | 18 ++++- build.sh | 31 +++++-- cmake/modules/GetOpenSSL.cmake | 125 ++++++++++++++--------------- cmake/moonlight-dependencies.cmake | 6 +- cmake/msys2.cmake | 44 +++++++++- 5 files changed, 146 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d1cac3..cd19a20 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,13 +103,14 @@ 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-make mingw-w64-x86_64-llvm - name: Setup python @@ -145,9 +146,20 @@ jobs: tar -czf ./artifacts/Moonlight.tar.gz ./${CMAKE_BUILD}/xbox/xbe cp ./${CMAKE_BUILD}/xbox/Moonlight.iso ./artifacts - - name: Debug logs + - name: Debug OpenSSL logs if: failure() - run: cat ./build/openssl_external-prefix/src/openssl_external-stamp/openssl_external-build.log || true + run: | + for log in \ + "./${CMAKE_BUILD}/openssl_external_host-prefix/src/openssl_external_host-stamp/openssl_external_host-configure.log" \ + "./${CMAKE_BUILD}/openssl_external_host-prefix/src/openssl_external_host-stamp/openssl_external_host-build.log" \ + "./${CMAKE_BUILD}/xbox/openssl_external_xbox-prefix/src/openssl_external_xbox-stamp/openssl_external_xbox-configure.log" \ + "./${CMAKE_BUILD}/xbox/openssl_external_xbox-prefix/src/openssl_external_xbox-stamp/openssl_external_xbox-build.log" + do + if [ -f "${log}" ]; then + echo "===== ${log} =====" + cat "${log}" + fi + done - name: Upload Artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/build.sh b/build.sh index 9331d26..3b648a7 100644 --- a/build.sh +++ b/build.sh @@ -63,13 +63,30 @@ 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/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake index 8b2b3a7..12e0a2e 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -19,6 +19,8 @@ if(MOONLIGHT_BUILD_KIND STREQUAL "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") @@ -74,7 +76,12 @@ set(OPENSSL_CONFIGURE_OPTIONS no-async no-dso) -set(OPENSSL_ENV ${CMAKE_COMMAND} -E env) +set(OPENSSL_ENV + ${CMAKE_COMMAND} -E env + "MAKEFLAGS=" + "MFLAGS=" + "GNUMAKEFLAGS=" + "MAKELEVEL=") set(MOONLIGHT_OPENSSL_WINDOWS_HOST FALSE) if(CMAKE_HOST_WIN32) set(MOONLIGHT_OPENSSL_WINDOWS_HOST TRUE) @@ -85,6 +92,10 @@ if(MOONLIGHT_OPENSSL_WINDOWS_HOST 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) @@ -131,10 +142,12 @@ if(MOONLIGHT_OPENSSL_PLATFORM STREQUAL "XBOX") 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) else() if(MOONLIGHT_OPENSSL_WINDOWS_HOST) @@ -144,73 +157,52 @@ else() set(OPENSSL_CONFIGURE_TARGET mingw) endif() - set(_openssl_windows_env - "CC=${CMAKE_C_COMPILER}" - "CXX=${CMAKE_CXX_COMPILER}" + 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 "") - list(APPEND _openssl_windows_env "AR=${CMAKE_AR}") + 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 "") - list(APPEND _openssl_windows_env "RANLIB=${CMAKE_RANLIB}") - endif() - - if(MOONLIGHT_OPENSSL_IN_ACTIVE_MSYS) - find_program(OPENSSL_MAKE_EXECUTABLE NAMES make REQUIRED) - list(APPEND OPENSSL_ENV ${_openssl_windows_env}) - set(OPENSSL_BUILD_COMMAND - ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} - build_libs) - set(OPENSSL_INSTALL_COMMAND - ${OPENSSL_ENV} - ${OPENSSL_MAKE_EXECUTABLE} - install_dev) - else() - 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 - "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 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 install_dev") + 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) @@ -252,10 +244,12 @@ else() 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() @@ -270,7 +264,7 @@ if(NOT DEFINED OPENSSL_CONFIGURE_COMMAND) "--openssldir=${OPENSSL_INSTALL_DIR}/ssl") endif() -ExternalProject_Add(openssl_external +ExternalProject_Add(${MOONLIGHT_OPENSSL_EXTERNAL_TARGET} SOURCE_DIR "${OPENSSL_SOURCE_DIR}" BINARY_DIR "${OPENSSL_BUILD_DIR}" CONFIGURE_COMMAND @@ -306,7 +300,7 @@ if(NOT TARGET OpenSSL::Crypto) if(WIN32 AND MOONLIGHT_OPENSSL_PLATFORM STREQUAL "HOST") target_link_libraries(OpenSSL::Crypto INTERFACE ws2_32) endif() - add_dependencies(OpenSSL::Crypto openssl_external) + add_dependencies(OpenSSL::Crypto ${MOONLIGHT_OPENSSL_EXTERNAL_TARGET}) endif() if(NOT TARGET OpenSSL::SSL) @@ -315,11 +309,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 index 3cc7d09..592a353 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -35,8 +35,10 @@ macro(moonlight_prepare_common_dependencies) unset(BUILD_SHARED_LIBS) endif() - if(TARGET moonlight-common-c AND TARGET openssl_external) - add_dependencies(moonlight-common-c openssl_external) + 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 diff --git a/cmake/msys2.cmake b/cmake/msys2.cmake index 4065649..38fad93 100644 --- a/cmake/msys2.cmake +++ b/cmake/msys2.cmake @@ -66,9 +66,51 @@ function(moonlight_detect_windows_msys2_root out_var) 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() + + foreach(candidate_root IN LISTS candidate_roots) + _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() + + foreach(tool_path IN ITEMS "${CMAKE_COMMAND}" "${CMAKE_MAKE_PROGRAM}") + _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() + + 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(MOONLIGHT_MSYS2_ROOT "${_resolved_root}" CACHE PATH "Path to the detected MSYS2 installation" FORCE) + set(${out_var} "${_resolved_root}" PARENT_SCOPE) + return() + endif() + endforeach() + if(DEFINED ENV{SystemDrive} AND NOT "$ENV{SystemDrive}" STREQUAL "") list(APPEND candidate_roots "$ENV{SystemDrive}/msys64") endif() @@ -100,7 +142,7 @@ function(moonlight_detect_windows_msys2_root out_var) 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} From 05c5ad927e5419f77b9af3a3481ac7e6ec846419 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 7 Apr 2026 09:34:20 -0400 Subject: [PATCH 10/35] Add Doxygen documentation to headers Add Doxygen-style comments across many public headers to document structs, functions, parameters and return values. Updated .github/copilot-instructions.md to require/mention Doxygen documentation. Files touched include openssl_compat.h, client_state.h, host_records.h, pairing_flow.h, log_file.h, logger.h, host_pairing.h, runtime_network.h, client_identity_storage.h, cover_art_cache.h, host_storage.h, saved_files.h, stats_overlay.h, menu_model.h, shell_view.h and a small SVG asset tweak. These comments improve API clarity and help enforce build-time documentation checks. --- .github/copilot-instructions.md | 6 + src/_nxdk_compat/openssl_compat.h | 5 +- src/app/client_state.h | 181 +++++++++++++++----------- src/app/host_records.h | 60 ++++----- src/app/pairing_flow.h | 10 +- src/logging/log_file.h | 38 +++++- src/logging/logger.h | 41 ++++-- src/network/host_pairing.h | 169 +++++++++++++++++++----- src/network/runtime_network.h | 44 ++++++- src/startup/client_identity_storage.h | 41 +++++- src/startup/cover_art_cache.h | 59 ++++++++- src/startup/host_storage.h | 34 ++++- src/startup/saved_files.h | 18 +-- src/streaming/stats_overlay.h | 24 ++-- src/ui/menu_model.h | 29 +++-- src/ui/shell_view.h | 144 ++++++++++---------- xbe/assets/icons/button-rb.svg | 1 - 17 files changed, 623 insertions(+), 281 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2c8a5e6..03f415d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -9,4 +9,10 @@ The host native tests will use MinGW Makefiles on Windows, but Unix Makefiles on 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. + +Inline doxygen comments should use `///< ...` instead of `/**< ... */`. + Always follow the style guidelines defined in .clang-format for c/c++ code. diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h index dd0f722..86000f2 100644 --- a/src/_nxdk_compat/openssl_compat.h +++ b/src/_nxdk_compat/openssl_compat.h @@ -29,8 +29,11 @@ extern "C" { #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]; + unsigned char fd_bits[(FD_SETSIZE + 7) / 8]; ///< Bitset storing tracked socket descriptors relative to LWIP_SOCKET_OFFSET. } 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)))) diff --git a/src/app/client_state.h b/src/app/client_state.h index 48348a7..96f9889 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -49,6 +49,9 @@ namespace app { log_viewer, }; + /** + * @brief Layout options for the embedded log viewer. + */ enum class LogViewerPlacement { full, left, @@ -94,110 +97,118 @@ namespace app { * @brief Controller selection state for the add-host keypad modal. */ struct AddHostKeypadState { - bool visible; - std::size_t selectedButtonIndex; - std::string stagedInput; + 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; - std::string portInput; - AddHostField activeField; - AddHostKeypadState keypad; - ScreenId returnScreen; - std::string validationMessage; - std::string connectionMessage; - bool lastConnectionSucceeded; + 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; - std::size_t selectedActionIndex = 0; - + 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; - std::string targetPath; - std::string title; - std::vector lines; + 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 Serializable app state for the menu-driven client shell. */ struct ClientState { - ScreenId activeScreen; - bool overlayVisible; - bool shouldExit; - bool hostsDirty; - std::size_t overlayScrollOffset; - HostsFocusArea hostsFocusArea; - std::size_t selectedToolbarButtonIndex; - std::size_t selectedHostIndex; - std::size_t selectedAppIndex; - std::size_t appsScrollPage; - bool showHiddenApps; - ui::MenuModel menu; - ui::MenuModel detailMenu; - std::vector hosts; - AddHostDraft addHostDraft; - PairingDraft pairingDraft; - ModalState modal; - SettingsFocusArea settingsFocusArea = SettingsFocusArea::categories; - SettingsCategory selectedSettingsCategory = SettingsCategory::logging; - ConfirmationDialogState confirmation; - std::string statusMessage; - std::string logFilePath; - std::vector logViewerLines; - std::size_t logViewerScrollOffset = 0U; - LogViewerPlacement logViewerPlacement = LogViewerPlacement::full; - logging::LogLevel loggingLevel = logging::LogLevel::info; - std::vector savedFiles; - bool savedFilesDirty = true; - std::vector pairingResetEndpoints; + ScreenId activeScreen; ///< Screen currently shown by the shell. + bool overlayVisible; ///< True when the diagnostics overlay is visible. + bool shouldExit; ///< True when the application should terminate. + bool hostsDirty; ///< True when the host list changed and should be saved. + std::size_t overlayScrollOffset; ///< Scroll offset used by long overlay content. + HostsFocusArea hostsFocusArea; ///< Focused region on the hosts page. + std::size_t selectedToolbarButtonIndex; ///< Zero-based selection inside the hosts toolbar. + std::size_t selectedHostIndex; ///< Zero-based selection inside the saved host list. + std::size_t selectedAppIndex; ///< Zero-based selection inside the visible app list. + std::size_t appsScrollPage; ///< Horizontal page offset for paged app browsing. + bool showHiddenApps; ///< True when hidden apps should remain visible in the apps screen. + ui::MenuModel menu; ///< Primary vertical menu model for the active screen. + ui::MenuModel detailMenu; ///< Secondary detail or actions menu. + std::vector hosts; ///< Saved hosts currently tracked by the shell. + 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. + SettingsFocusArea settingsFocusArea = SettingsFocusArea::categories; ///< Focused pane within the settings screen. + SettingsCategory selectedSettingsCategory = SettingsCategory::logging; ///< Settings category selected in the left pane. + ConfirmationDialogState confirmation; ///< Confirmation dialog content for destructive actions. + std::string statusMessage; ///< Primary user-visible status line. + 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::info; ///< Minimum log level selected in settings. + std::vector savedFiles; ///< Saved-file catalog shown on the reset settings page. + bool savedFilesDirty = true; ///< True when the saved-file catalog should be refreshed. + std::vector pairingResetEndpoints; ///< Endpoints whose pairing material should be cleared during reset. }; /** * @brief Result of updating the client shell with a UI command. */ struct AppUpdate { - bool screenChanged; - bool overlayChanged; - bool overlayVisibilityChanged; - bool exitRequested; - bool hostsChanged; - bool connectionTestRequested; - bool pairingRequested; - bool pairingCancelledRequested; - bool appsBrowseRequested; - bool appsBrowseShowHidden; - bool logViewRequested; - bool savedFileDeleteRequested; - bool factoryResetRequested; - bool hostDeleteCleanupRequested; - bool modalOpened; - bool modalClosed; - bool deletedHostWasPaired; - std::string activatedItemId; - std::string connectionTestAddress; - uint16_t connectionTestPort; - std::string pairingAddress; - uint16_t pairingPort; - std::string pairingPin; - std::string savedFileDeletePath; - std::string deletedHostAddress; - uint16_t deletedHostPort; - std::vector deletedHostCoverArtCacheKeys; + bool screenChanged; ///< True when the active screen changed. + bool overlayChanged; ///< True when overlay content changed. + bool overlayVisibilityChanged; ///< True when overlay visibility toggled. + bool exitRequested; ///< True when the shell requested application exit. + bool hostsChanged; ///< True when the host list changed and should be persisted. + bool connectionTestRequested; ///< True when a manual host connection test should run. + bool pairingRequested; ///< True when manual pairing should begin. + bool pairingCancelledRequested; ///< True when an in-progress pairing request should be cancelled. + bool appsBrowseRequested; ///< True when app browsing for the selected host should begin. + bool appsBrowseShowHidden; ///< Hidden-app visibility requested for the app browse action. + bool logViewRequested; ///< True when the log viewer should be refreshed from disk. + bool savedFileDeleteRequested; ///< True when one managed file should be deleted. + bool factoryResetRequested; ///< True when a full saved-data reset should run. + bool hostDeleteCleanupRequested; ///< True when host deletion follow-up cleanup should run. + bool modalOpened; ///< True when a modal became active during the update. + bool modalClosed; ///< True when the active modal was dismissed during the update. + bool deletedHostWasPaired; ///< True when the deleted host previously had pairing credentials. + std::string activatedItemId; ///< Stable identifier for the activated menu item, when any. + std::string connectionTestAddress; ///< Host address that should be tested. + uint16_t connectionTestPort; ///< Host port that should be tested. + std::string pairingAddress; ///< Host address targeted by pairing. + uint16_t pairingPort; ///< Host port targeted by pairing. + std::string pairingPin; ///< Generated client PIN that should be shown to the user. + std::string savedFileDeletePath; ///< Managed file path requested for deletion. + std::string deletedHostAddress; ///< Address of the host removed from storage. + uint16_t deletedHostPort; ///< Port of the host removed from storage. + std::vector deletedHostCoverArtCacheKeys; ///< Cover-art cache keys to remove for the deleted host. }; /** @@ -284,6 +295,7 @@ namespace app { * @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. */ @@ -307,10 +319,31 @@ namespace app { */ 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); /** diff --git a/src/app/host_records.h b/src/app/host_records.h index 51107bb..baec292 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -41,48 +41,48 @@ namespace app { * @brief App metadata shown on the per-host apps page. */ struct HostAppRecord { - std::string name; - int id = 0; - bool hdrSupported = false; - bool hidden = false; - bool favorite = false; - std::string boxArtCacheKey; - bool boxArtCached = false; - bool running = false; + 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; - std::string address; - uint16_t port = 0; - PairingState pairingState = PairingState::not_paired; - HostReachability reachability = HostReachability::unknown; - std::string activeAddress; - std::string uuid; - std::string localAddress; - std::string remoteAddress; - std::string ipv6Address; - std::string manualAddress; - std::string macAddress; - uint16_t httpsPort = 0; - uint32_t runningGameId = 0; - std::vector apps; - HostAppListState appListState = HostAppListState::idle; - std::string appListStatusMessage; - uint16_t resolvedHttpPort = 0; - uint64_t appListContentHash = 0; - uint32_t lastAppListRefreshTick = 0; + 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; - std::vector errors; + std::vector records; ///< Parsed host records accepted from the serialized input. + std::vector errors; ///< Non-fatal line-level parse or validation errors. }; /** diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h index c21456c..10a4ee0 100644 --- a/src/app/pairing_flow.h +++ b/src/app/pairing_flow.h @@ -21,11 +21,11 @@ namespace app { * @brief Controller-friendly state for a client-generated pairing PIN. */ struct PairingDraft { - std::string targetAddress; - uint16_t targetPort; - std::string generatedPin; - PairingStage stage; - std::string statusMessage; + 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. }; /** diff --git a/src/logging/log_file.h b/src/logging/log_file.h index 2bc68d0..92f62a7 100644 --- a/src/logging/log_file.h +++ b/src/logging/log_file.h @@ -10,19 +10,49 @@ namespace logging { + /** + * @brief Result of loading the persisted log file for the shell viewer. + */ struct LoadLogFileResult { - std::string filePath; - std::vector lines; - bool fileFound = false; - std::string errorMessage; + 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 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.h b/src/logging/logger.h index 43a7b79..8842684 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -25,24 +25,34 @@ namespace logging { * @brief Structured log entry stored by the in-memory logger. */ struct LogTimestamp { - int year = 0; - int month = 0; - int day = 0; - int hour = 0; - int minute = 0; - int second = 0; - int millisecond = 0; + 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 Structured log entry stored by the in-memory logger. + */ struct LogEntry { - uint64_t sequence; - LogLevel level; - std::string category; - std::string message; - LogTimestamp timestamp {}; + uint64_t sequence; ///< Monotonic sequence number assigned by the logger. + LogLevel level; ///< 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. }; + /** + * @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; /** @@ -78,11 +88,14 @@ namespace logging { * @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; @@ -95,6 +108,8 @@ namespace logging { /** * @brief Return the minimum accepted log level. + * + * @return Minimum accepted log level. */ LogLevel minimum_level() const; @@ -125,6 +140,8 @@ namespace logging { /** * @brief Return the retained entries. + * + * @return Immutable view of the retained ring-buffer contents. */ const std::deque &entries() const; diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index 427ae7a..ef3fe4c 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -9,64 +9,137 @@ namespace network { + /** + * @brief Client identity material used for Moonlight host pairing. + */ struct PairingIdentity { - std::string uniqueId; - std::string certificatePem; - std::string privateKeyPem; + 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; - uint16_t httpPort = 0; - uint16_t httpsPort = 0; - bool paired = false; - bool pairingStatusCurrentClientKnown = false; - bool pairingStatusCurrentClient = false; - std::string hostName; - std::string uuid; - std::string activeAddress; - std::string localAddress; - std::string remoteAddress; - std::string ipv6Address; - std::string macAddress; - uint32_t runningGameId = 0; + 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; - int id = 0; - bool hdrSupported = false; - bool hidden = false; + 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; - uint16_t httpPort; - std::string pin; - std::string deviceName; - PairingIdentity identity; + 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; - bool alreadyPaired; - std::string message; + 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 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, @@ -75,8 +148,28 @@ namespace network { std::string *errorMessage = nullptr ); + /** + * @brief Query live host status without client authentication. + * + * @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 = 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, @@ -86,6 +179,17 @@ namespace network { 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, @@ -95,6 +199,13 @@ namespace network { 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 network diff --git a/src/network/runtime_network.h b/src/network/runtime_network.h index aaa3363..8f72cde 100644 --- a/src/network/runtime_network.h +++ b/src/network/runtime_network.h @@ -7,24 +7,54 @@ namespace network { + /** + * @brief Summary of runtime network initialization and the active IPv4 configuration. + */ struct RuntimeNetworkStatus { - bool initializationAttempted; - bool ready; - int initializationCode; - std::string summary; - std::string ipAddress; - std::string subnetMask; - std::string gateway; + 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/startup/client_identity_storage.h b/src/startup/client_identity_storage.h index bd3dbfd..db8e395 100644 --- a/src/startup/client_identity_storage.h +++ b/src/startup/client_identity_storage.h @@ -9,26 +9,57 @@ namespace startup { + /** + * @brief Result of loading persisted client pairing identity material. + */ struct LoadClientIdentityResult { - network::PairingIdentity identity; - std::vector warnings; - bool fileFound; + 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; - std::string errorMessage; + 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() diff --git a/src/startup/cover_art_cache.h b/src/startup/cover_art_cache.h index 37d834a..6c84c86 100644 --- a/src/startup/cover_art_cache.h +++ b/src/startup/cover_art_cache.h @@ -7,31 +7,80 @@ namespace startup { + /** + * @brief Result of loading cached cover art bytes from disk. + */ struct LoadCoverArtResult { - std::vector bytes; - bool fileFound = false; - std::string errorMessage; + 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; - std::string errorMessage; + 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.h b/src/startup/host_storage.h index 3c022c7..4e95635 100644 --- a/src/startup/host_storage.h +++ b/src/startup/host_storage.h @@ -9,21 +9,45 @@ namespace startup { + /** + * @brief Result of loading persisted saved hosts from disk. + */ struct LoadSavedHostsResult { - std::vector hosts; - std::vector warnings; - bool fileFound; + 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; - std::string errorMessage; + 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() diff --git a/src/startup/saved_files.h b/src/startup/saved_files.h index 020c6d0..a2f7da9 100644 --- a/src/startup/saved_files.h +++ b/src/startup/saved_files.h @@ -11,27 +11,27 @@ namespace startup { * @brief Describes one Moonlight-managed file that exists on disk. */ struct SavedFileEntry { - std::string path; - std::string displayName; - std::uint64_t sizeBytes = 0; + 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; - std::string logFilePath; - std::string pairingDirectory; - std::string coverArtCacheRoot; + std::string hostStoragePath; ///< Path to the saved-host storage file + std::string logFilePath; ///< Path to the persisted log file + std::string pairingDirectory; ///< Directory containing saved client pairing identity files + std::string coverArtCacheRoot; ///< Root directory containing cached cover art artifacts }; /** * @brief Result of enumerating Moonlight-managed files on disk. */ struct ListSavedFilesResult { - std::vector files; - std::vector warnings; + std::vector files; ///< Managed files currently found on disk. + std::vector warnings; ///< Non-fatal warnings produced during enumeration. }; /** diff --git a/src/streaming/stats_overlay.h b/src/streaming/stats_overlay.h index 0fef954..885fcc8 100644 --- a/src/streaming/stats_overlay.h +++ b/src/streaming/stats_overlay.h @@ -10,18 +10,18 @@ namespace streaming { * @brief Snapshot of stream telemetry shown in the on-screen stats overlay. */ struct StreamStatisticsSnapshot { - int width; - int height; - int fps; - int roundTripTimeMs; - int hostLatencyMs; - int decoderLatencyMs; - int videoQueueDepth; - int audioQueueDurationMs; - int videoPacketsReceived; - int videoPacketsRecovered; - int videoPacketsLost; - bool poorConnection; + 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. }; /** diff --git a/src/ui/menu_model.h b/src/ui/menu_model.h index 28f1126..8a46e6e 100644 --- a/src/ui/menu_model.h +++ b/src/ui/menu_model.h @@ -15,22 +15,22 @@ namespace ui { * @brief Item shown in a focus-driven menu. */ struct MenuItem { - std::string id; - std::string label; - bool enabled; + std::string id; ///< Stable identifier used by reducers and view builders. + std::string label; ///< User-facing label shown in the menu. + 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; - bool activationRequested; - bool backRequested; - bool previousPageRequested; - bool nextPageRequested; - bool overlayToggleRequested; - std::string activatedItemId; + 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. }; /** @@ -38,6 +38,9 @@ namespace ui { */ class MenuModel { public: + /** + * @brief Sentinel index used when no menu item is selectable. + */ static constexpr std::size_t npos = std::numeric_limits::max(); /** @@ -56,16 +59,22 @@ namespace ui { /** * @brief Return the configured items. + * + * @return Immutable view of the menu items in display order. */ const std::vector &items() const; /** * @brief Return the selected item index or npos when none is selectable. + * + * @return Selected item index or npos. */ 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. */ const MenuItem *selected_item() const; diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h index 14be8e0..e8c23c3 100644 --- a/src/ui/shell_view.h +++ b/src/ui/shell_view.h @@ -15,120 +15,120 @@ namespace ui { * @brief Render-ready button shown in the hosts toolbar. */ struct ShellToolbarButton { - std::string id; - std::string label; - std::string glyph; - std::string iconAssetPath; - bool selected = false; + 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; - std::string displayName; - std::string statusLabel; - std::string iconAssetPath; - app::PairingState pairingState = app::PairingState::not_paired; - app::HostReachability reachability = app::HostReachability::unknown; - bool selected = false; + 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; - std::string name; - std::string detail; - std::string badgeLabel; - std::string boxArtCacheKey; - bool hidden = false; - bool favorite = false; - bool boxArtCached = false; - bool running = false; - bool selected = false; + 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; - std::string label; - bool enabled = true; - bool selected = false; - bool checked = false; + 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; - std::string label; - std::string iconAssetPath; - std::string secondaryIconAssetPath; - bool emphasized = false; + 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; - std::string message; - std::vector actions; + 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; - bool enabled = true; - bool selected = false; + 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 Render-ready shell state derived from the app model. */ struct ShellViewModel { - app::ScreenId screen = app::ScreenId::hosts; - std::string title; - std::string pageTitle; - std::string statusMessage; - bool notificationVisible = false; - ShellNotification notification; - std::vector toolbarButtons; - std::vector hostTiles; - std::size_t hostColumnCount = 3; - std::vector appTiles; - std::size_t appColumnCount = 4; - std::vector bodyLines; - std::vector menuRows; - std::vector detailMenuRows; - std::string selectedMenuRowLabel; - std::vector footerActions; - bool overlayVisible = false; - std::string overlayTitle; - std::vector overlayLines; - bool modalVisible = false; - std::string modalTitle; - std::vector modalLines; - std::vector modalActions; - std::vector modalFooterActions; - bool logViewerVisible = false; - std::string logViewerPath; - std::vector logViewerLines; - std::size_t logViewerScrollOffset = 0U; - app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; - bool keypadModalVisible = false; - std::string keypadModalTitle; - std::vector keypadModalLines; - std::vector keypadModalButtons; - std::size_t keypadModalColumnCount = 0; + 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. + bool notificationVisible = false; ///< True when a transient notification should be rendered. + ShellNotification notification; ///< Notification content when visible. + std::vector toolbarButtons; ///< Toolbar buttons for the hosts page. + std::vector hostTiles; ///< Host tiles shown on the hosts page. + std::size_t hostColumnCount = 3; ///< Number of columns used to lay out host tiles. + std::vector appTiles; ///< App tiles shown on the apps page. + std::size_t appColumnCount = 4; ///< 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::vector footerActions; ///< Footer actions shown for the current screen. + bool overlayVisible = false; ///< True when the diagnostics overlay should be rendered. + std::string overlayTitle; ///< Diagnostics overlay title. + std::vector overlayLines; ///< Diagnostics overlay body lines. + bool modalVisible = false; ///< True when a modal dialog should be rendered. + std::string modalTitle; ///< Modal dialog title. + std::vector modalLines; ///< Modal dialog body lines. + std::vector modalActions; ///< Modal action rows. + std::vector modalFooterActions; ///< Footer actions displayed while a modal is open. + bool logViewerVisible = false; ///< True when the log viewer should be rendered. + std::string logViewerPath; ///< Path of the currently loaded log file. + std::vector logViewerLines; ///< Loaded log lines shown in the viewer. + std::size_t logViewerScrollOffset = 0U; ///< Vertical scroll offset inside the log viewer. + app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; ///< Placement of the log viewer pane. + bool keypadModalVisible = false; ///< True when the keypad modal should be rendered. + std::string keypadModalTitle; ///< Title shown at the top of the keypad modal. + std::vector keypadModalLines; ///< Instruction and draft lines shown in the keypad modal. + std::vector keypadModalButtons; ///< Buttons rendered inside the keypad modal. + std::size_t keypadModalColumnCount = 0; ///< Number of columns used to lay out keypad buttons. }; /** diff --git a/xbe/assets/icons/button-rb.svg b/xbe/assets/icons/button-rb.svg index 4e4ae2f..1fb2769 100644 --- a/xbe/assets/icons/button-rb.svg +++ b/xbe/assets/icons/button-rb.svg @@ -1,4 +1,3 @@ - From 97455a4c988a97558daa56dcda8885b19b2e1d41 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 7 Apr 2026 17:00:38 -0400 Subject: [PATCH 11/35] style: sonar fixes --- build.sh | 2 + sonar-project.properties | 5 + src/_nxdk_compat/poll_compat.cpp | 3 +- src/_nxdk_compat/stat_compat.cpp | 4 +- src/app/client_state.cpp | 111 ++--- src/app/client_state.h | 6 +- src/app/host_records.cpp | 108 +++-- src/app/host_records.h | 16 +- src/app/pairing_flow.cpp | 13 +- src/logging/log_file.cpp | 13 +- src/logging/logger.cpp | 13 +- src/logging/logger.h | 4 +- src/main.cpp | 13 +- src/network/host_pairing.cpp | 248 ++++++----- src/network/host_pairing.h | 9 + src/network/runtime_network.cpp | 6 +- src/platform/filesystem_utils.cpp | 37 +- src/platform/filesystem_utils.h | 5 +- src/splash/splash_layout.cpp | 4 +- src/splash/splash_screen.cpp | 4 +- src/startup/client_identity_storage.cpp | 18 +- src/startup/cover_art_cache.cpp | 17 +- src/startup/host_storage.cpp | 20 +- src/startup/saved_files.cpp | 16 +- src/ui/menu_model.cpp | 7 +- src/ui/menu_model.h | 13 +- src/ui/shell_screen.cpp | 417 +++++++----------- src/ui/shell_view.cpp | 100 +++-- src/ui/shell_view.h | 2 +- tests/support/filesystem_test_utils.h | 2 +- tests/support/network_test_constants.h | 72 +++ tests/unit/app/client_state_test.cpp | 186 ++++---- tests/unit/app/host_records_test.cpp | 33 +- tests/unit/app/pairing_flow_test.cpp | 13 +- tests/unit/network/host_pairing_test.cpp | 101 +++-- tests/unit/network/runtime_network_test.cpp | 28 +- .../startup/client_identity_storage_test.cpp | 2 +- tests/unit/startup/cover_art_cache_test.cpp | 10 +- tests/unit/startup/host_storage_test.cpp | 23 +- tests/unit/startup/saved_files_test.cpp | 2 +- tests/unit/ui/menu_model_test.cpp | 14 + tests/unit/ui/shell_view_test.cpp | 56 ++- 42 files changed, 926 insertions(+), 850 deletions(-) create mode 100644 sonar-project.properties create mode 100644 tests/support/network_test_constants.h diff --git a/build.sh b/build.sh index 3b648a7..9f5e478 100644 --- a/build.sh +++ b/build.sh @@ -85,6 +85,8 @@ case "$(uname -s)" in -DCMAKE_TOOLCHAIN_FILE="${PROJECT_ROOT}/cmake/host-mingw64-clang.cmake" ) ;; + *) + ;; esac cmake "${cmake_configure_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/poll_compat.cpp b/src/_nxdk_compat/poll_compat.cpp index 97a2ffd..f4f07c4 100644 --- a/src/_nxdk_compat/poll_compat.cpp +++ b/src/_nxdk_compat/poll_compat.cpp @@ -50,8 +50,7 @@ extern "C" int poll(struct pollfd *fds, nfds_t nfds, int timeout) { timeoutPointer = &timeoutValue; } - const int selectResult = select(maxFd + 1, &readSet, &writeSet, &errorSet, timeoutPointer); - if (selectResult <= 0) { + if (const int selectResult = select(maxFd + 1, &readSet, &writeSet, &errorSet, timeoutPointer); selectResult <= 0) { return selectResult; } diff --git a/src/_nxdk_compat/stat_compat.cpp b/src/_nxdk_compat/stat_compat.cpp index 385c9fd..2562b1f 100644 --- a/src/_nxdk_compat/stat_compat.cpp +++ b/src/_nxdk_compat/stat_compat.cpp @@ -5,7 +5,7 @@ extern "C" { - int stat(const char *path, struct stat *status) { + int stat(const char *path, struct stat *status) { // NOSONAR(cpp:S833) extern "C" linkage requires external visibility (void) path; if (status != nullptr) { @@ -15,7 +15,7 @@ extern "C" { return -1; } - int fstat(int fd, struct stat *status) { + int fstat(int fd, struct stat *status) { // NOSONAR(cpp:S833) extern "C" linkage requires external visibility (void) fd; if (status != nullptr) { diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 2bab779..c4892fa 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include @@ -27,21 +26,6 @@ namespace { char character; }; - std::string generate_pairing_pin() { - static std::mt19937 generator(std::random_device {}()); - std::uniform_int_distribution distribution(0, 9999); - - std::string pin = std::to_string(distribution(generator)); - while (pin.size() < 4U) { - pin.insert(pin.begin(), '0'); - } - return pin; - } - - std::string default_add_host_address() { - return "192.168.0.10"; - } - std::vector build_add_host_keypad_buttons(const app::ClientState &state) { if (state.addHostDraft.activeField == app::AddHostField::address) { return {{'1'}, {'2'}, {'3'}, {'4'}, {'5'}, {'6'}, {'7'}, {'8'}, {'9'}, {'.'}, {'0'}}; @@ -69,7 +53,7 @@ namespace { return std::string(SETTINGS_CATEGORY_PREFIX) + "logging"; } - app::SettingsCategory settings_category_from_menu_id(const std::string &itemId) { + app::SettingsCategory settings_category_from_menu_id(std::string_view itemId) { if (itemId == settings_category_menu_id(app::SettingsCategory::display)) { return app::SettingsCategory::display; } @@ -82,7 +66,7 @@ namespace { return app::SettingsCategory::logging; } - std::string pairing_reset_endpoint_key(const std::string &address, uint16_t port) { + 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)); } @@ -123,27 +107,9 @@ namespace { return value.rfind(prefix, 0U) == 0U; } - bool host_matches_endpoint(const app::HostRecord &host, const std::string &address, uint16_t port) { - if (host.address != address) { - return false; - } - - const uint16_t effectivePort = app::effective_host_port(port); - if (app::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; - } - void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen) { state.addHostDraft = { - default_add_host_address(), + {}, {}, app::AddHostField::address, {false, 0U, {}}, @@ -173,16 +139,16 @@ namespace { state.modal.selectedActionIndex = 0U; } - const app::HostRecord *find_host_by_endpoint(const std::vector &hosts, const std::string &address, uint16_t port) { + const app::HostRecord *find_host_by_endpoint(const std::vector &hosts, const std::string &address, uint16_t port) { // NOSONAR(cpp:S1144) used by endpoint-aware selection and background update flows const auto iterator = std::find_if(hosts.begin(), hosts.end(), [&address, port](const app::HostRecord &host) { - return host_matches_endpoint(host, address, port); + return app::host_matches_endpoint(host, address, port); }); return iterator == hosts.end() ? nullptr : &(*iterator); } 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 host_matches_endpoint(host, address, port); + return app::host_matches_endpoint(host, address, port); }); return iterator == hosts.end() ? nullptr : &(*iterator); } @@ -491,15 +457,15 @@ namespace { return false; } - const int rowCount = static_cast((buttons.size() + ADD_HOST_KEYPAD_COLUMN_COUNT - 1U) / ADD_HOST_KEYPAD_COLUMN_COUNT); + const auto rowCount = static_cast((buttons.size() + ADD_HOST_KEYPAD_COLUMN_COUNT - 1U) / ADD_HOST_KEYPAD_COLUMN_COUNT); const std::size_t currentIndex = state.addHostDraft.keypad.selectedButtonIndex % buttons.size(); - int currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT); - int currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT); + auto currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT); + auto currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT); currentRow = std::clamp(currentRow + rowDelta, 0, rowCount - 1); currentColumn = std::clamp(currentColumn + columnDelta, 0, static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT) - 1); - std::size_t nextIndex = static_cast((currentRow * static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT)) + currentColumn); + auto nextIndex = (static_cast(currentRow) * ADD_HOST_KEYPAD_COLUMN_COUNT) + static_cast(currentColumn); if (nextIndex >= buttons.size()) { nextIndex = buttons.size() - 1U; } @@ -610,8 +576,7 @@ namespace { if (columnDelta > 0) { for (int step = 0; step < columnDelta; ++step) { - const std::size_t rowEnd = grid_row_end(itemCount, currentRow, columnCount); - if (currentIndex + 1U < rowEnd) { + if (const std::size_t rowEnd = grid_row_end(itemCount, currentRow, columnCount); currentIndex + 1U < rowEnd) { ++currentIndex; continue; } @@ -698,13 +663,18 @@ namespace { } bool enter_pair_host_screen(app::ClientState &state, const std::string &address, uint16_t port) { - const app::HostRecord *host = find_host_by_endpoint(state.hosts, address, port); - if (host != nullptr && host->reachability == app::HostReachability::offline) { + if (const app::HostRecord *host = find_host_by_endpoint(state.hosts, address, port); host != nullptr && host->reachability == app::HostReachability::offline) { state.statusMessage = "Host is offline. Bring it online before pairing."; return false; } - state.pairingDraft = app::create_pairing_draft(address, app::effective_host_port(port), generate_pairing_pin()); + std::string pairingPin; + if (std::string pinError; !network::generate_pairing_pin(&pairingPin, &pinError)) { + state.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; } @@ -719,7 +689,7 @@ namespace { return false; } if (host->pairingState != app::PairingState::paired) { - state.statusMessage = "This host is no longer paired. Pair it again from Sunshine before opening apps."; + state.statusMessage = "This host is no longer paired. Pair it again before opening apps."; return false; } @@ -735,7 +705,7 @@ namespace { void select_host_by_endpoint(app::ClientState &state, const std::string &address, uint16_t port) { for (std::size_t index = 0; index < state.hosts.size(); ++index) { - if (host_matches_endpoint(state.hosts[index], address, port)) { + if (app::host_matches_endpoint(state.hosts[index], address, port)) { state.selectedHostIndex = index; state.hostsFocusArea = app::HostsFocusArea::grid; return; @@ -759,7 +729,7 @@ namespace { return logging::LogLevel::info; } - bool handle_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) { + bool handle_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) { // NOSONAR(cpp:S3776) modal command routing stays centralized for predictable UI behavior if (!state.modal.active()) { return false; } @@ -865,7 +835,7 @@ namespace { update->appsBrowseRequested = true; update->appsBrowseShowHidden = true; } else { - if (enter_pair_host_screen(state, host->address, host->port)) { + if (enter_pair_host_screen(state, host->address, host->port)) { // NOSONAR(cpp:S134) host action flow is intentionally kept inline with its UI side effects update->screenChanged = true; update->pairingRequested = true; update->pairingAddress = state.pairingDraft.targetAddress; @@ -906,14 +876,15 @@ namespace { case 3: open_modal(state, app::ModalId::host_details); return true; + default: + return true; } return true; } case app::ModalId::app_actions: { const app::HostRecord *host = app::apps_host(state); - const app::HostAppRecord *selectedApp = app::selected_app(state); - if (host == nullptr || selectedApp == nullptr) { + if (const app::HostAppRecord *selectedApp = app::selected_app(state); host == nullptr || selectedApp == nullptr) { close_modal(state); update->modalClosed = true; return true; @@ -943,6 +914,8 @@ namespace { close_modal(state); update->modalClosed = true; return true; + default: + return true; } return true; } @@ -973,7 +946,7 @@ namespace app { ui::MenuModel(), ui::MenuModel(), {}, - {default_add_host_address(), {}, AddHostField::address, {false, 0U, {}}, ScreenId::hosts, {}, {}, false}, + {{}, {}, AddHostField::address, {false, 0U, {}}, ScreenId::hosts, {}, {}, false}, {{}, DEFAULT_HOST_PORT, {}, PairingStage::idle, {}}, {}, SettingsFocusArea::categories, @@ -1087,7 +1060,7 @@ namespace app { return false; } - void apply_app_list_result( + void apply_app_list_result( // NOSONAR(cpp:S3776) app-list merge logic is intentionally centralized to preserve host selection state ClientState &state, const std::string &address, uint16_t port, @@ -1106,8 +1079,7 @@ namespace app { const int selectedAppId = currentSelection == nullptr ? 0 : currentSelection->id; if (!success) { - const bool hostIsUnpaired = network::error_indicates_unpaired_client(message); - if (hostIsUnpaired) { + if (const bool hostIsUnpaired = network::error_indicates_unpaired_client(message); hostIsUnpaired) { host->pairingState = PairingState::not_paired; host->apps.clear(); host->appListContentHash = 0; @@ -1131,9 +1103,7 @@ namespace app { return; } - const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; - - if (appListChanged) { + if (const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; appListChanged) { std::vector mergedApps; mergedApps.reserve(apps.size()); for (HostAppRecord &appRecord : apps) { @@ -1141,7 +1111,7 @@ namespace app { appRecord.hidden = appRecord.hidden || savedApp->hidden; appRecord.favorite = savedApp->favorite; appRecord.boxArtCached = appRecord.boxArtCached || savedApp->boxArtCached; - if (appRecord.boxArtCacheKey.empty()) { + if (appRecord.boxArtCacheKey.empty()) { // NOSONAR(cpp:S134) merge path keeps persisted app metadata updates together appRecord.boxArtCacheKey = savedApp->boxArtCacheKey; } } @@ -1223,7 +1193,7 @@ namespace app { return selected_host(state); } - AppUpdate handle_command(ClientState &state, input::UiCommand command) { + AppUpdate handle_command(ClientState &state, input::UiCommand command) { // NOSONAR(cpp:S3776) top-level UI command routing intentionally remains in one place AppUpdate update {}; if (command == input::UiCommand::toggle_overlay) { @@ -1285,8 +1255,7 @@ namespace app { return update; case input::UiCommand::activate: { - const std::vector buttons = build_add_host_keypad_buttons(state); - if (!buttons.empty()) { + if (const std::vector buttons = build_add_host_keypad_buttons(state); !buttons.empty()) { append_to_active_add_host_field(state, buttons[state.addHostDraft.keypad.selectedButtonIndex % buttons.size()].character); } return update; @@ -1416,7 +1385,7 @@ namespace app { return update; case input::UiCommand::move_down: if (state.hostsFocusArea == HostsFocusArea::toolbar) { - if (!state.hosts.empty()) { + if (!state.hosts.empty()) { // NOSONAR(cpp:S134) hosts-screen focus transition stays inline with navigation handling state.hostsFocusArea = HostsFocusArea::grid; } } else { @@ -1437,13 +1406,13 @@ namespace app { case input::UiCommand::activate: case input::UiCommand::confirm: if (state.hostsFocusArea == HostsFocusArea::toolbar) { - if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 0U) { + if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 0U) { // NOSONAR(cpp:S134) toolbar actions stay explicit for controller navigation parity set_screen(state, ScreenId::settings, settings_category_menu_id(SettingsCategory::logging)); update.screenChanged = true; update.activatedItemId = "settings-button"; return update; } - if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 1U) { + if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 1U) { // NOSONAR(cpp:S134) toolbar actions stay explicit for controller navigation parity open_modal(state, ModalId::support); update.modalOpened = true; update.activatedItemId = "support-button"; @@ -1457,7 +1426,7 @@ namespace app { if (const HostRecord *host = selected_host(state); host != nullptr) { update.activatedItemId = "select-host"; - if (host->pairingState == PairingState::paired) { + if (host->pairingState == PairingState::paired) { // NOSONAR(cpp:S134) host activation keeps browse-vs-pair branching with its update flags update.appsBrowseRequested = true; update.appsBrowseShowHidden = false; } else { @@ -1626,7 +1595,7 @@ namespace app { return update; } - HostRecord *host = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); + const HostRecord *host = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); if (host == nullptr) { state.hosts.push_back(make_host_record(normalizedAddress, parsedPort)); state.selectedHostIndex = state.hosts.size() - 1U; diff --git a/src/app/client_state.h b/src/app/client_state.h index 96f9889..cda35a4 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -146,7 +146,7 @@ namespace app { /** * @brief Serializable app state for the menu-driven client shell. */ - struct ClientState { + struct ClientState { ///< NOSONAR(cpp:S1820) app shell state intentionally keeps the full workflow snapshot together ScreenId activeScreen; ///< Screen currently shown by the shell. bool overlayVisible; ///< True when the diagnostics overlay is visible. bool shouldExit; ///< True when the application should terminate. @@ -181,7 +181,7 @@ namespace app { /** * @brief Result of updating the client shell with a UI command. */ - struct AppUpdate { + struct AppUpdate { ///< NOSONAR(cpp:S1820) command results intentionally bundle all one-frame side effects bool screenChanged; ///< True when the active screen changed. bool overlayChanged; ///< True when overlay content changed. bool overlayVisibilityChanged; ///< True when overlay visibility toggled. @@ -289,7 +289,7 @@ namespace app { bool apply_pairing_result(ClientState &state, const std::string &address, uint16_t port, bool success, std::string message); /** - * @brief Apply a fetched Sunshine app list to a saved host. + * @brief Apply a fetched app list to a saved host. * * @param state Mutable app state. * @param address Host address used for the fetch. diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index e254ca6..a552b76 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -2,6 +2,7 @@ #include "src/app/host_records.h" // standard includes +#include #include #include @@ -92,9 +93,9 @@ namespace app { } std::string normalizedAddress; - for (std::size_t index = 0; index < segments.size(); ++index) { + for (std::string_view segment : segments) { int octetValue = 0; - if (!parse_ipv4_octet(segments[index], &octetValue)) { + if (!parse_ipv4_octet(segment, &octetValue)) { return {}; } @@ -152,12 +153,26 @@ namespace app { bool contains_host_address(const std::vector &records, std::string_view normalizedAddress, uint16_t port) { const uint16_t effectivePort = effective_host_port(port); - for (const HostRecord &record : records) { - if (record.address == normalizedAddress && effective_host_port(record.port) == effectivePort) { - return true; - } + 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; } @@ -183,12 +198,53 @@ namespace app { return true; } + 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() != 3 && fields.size() != 4) { + result->errors.push_back("Line " + std::to_string(lineNumber) + " must contain three or four tab-separated fields"); + return; + } + + uint16_t port = 0; + const std::string_view pairingField = fields.size() == 4 ? fields[3] : fields[2]; + if (fields.size() == 4 && !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, + }; + + std::string errorMessage; + if (!validate_host_record(record, &errorMessage)) { + result->errors.push_back("Line " + std::to_string(lineNumber) + ": " + errorMessage); + return; + } + + result->records.push_back(std::move(record)); + } + std::string serialize_host_records(const std::vector &records) { std::string serializedRecords; for (const HostRecord &record : records) { - std::string errorMessage; - if (!validate_host_record(record, &errorMessage)) { + if (std::string errorMessage; !validate_host_record(record, &errorMessage)) { continue; } @@ -221,41 +277,7 @@ namespace app { } if (!line.empty()) { - const std::vector fields = split_string_view(line, '\t'); - if (fields.size() != 3 && fields.size() != 4) { - result.errors.push_back("Line " + std::to_string(lineNumber) + " must contain three or four tab-separated fields"); - } else { - uint16_t port = 0; - PairingState pairingState = PairingState::not_paired; - const std::string_view pairingField = fields.size() == 4 ? fields[3] : fields[2]; - if (fields.size() == 4 && !try_parse_host_port(fields[2], &port)) { - result.errors.push_back("Line " + std::to_string(lineNumber) + " uses an invalid TCP port"); - port = 0; - } - - if (pairingField == "not_paired") { - pairingState = PairingState::not_paired; - } else if (pairingField == "paired") { - pairingState = PairingState::paired; - } else { - result.errors.push_back("Line " + std::to_string(lineNumber) + " uses an unknown pairing state"); - pairingState = PairingState::not_paired; - } - - HostRecord record { - std::string(fields[0]), - std::string(fields[1]), - port, - pairingState, - }; - - std::string errorMessage; - if (validate_host_record(record, &errorMessage)) { - result.records.push_back(std::move(record)); - } else { - result.errors.push_back("Line " + std::to_string(lineNumber) + ": " + errorMessage); - } - } + append_parsed_host_record(line, lineNumber, &result); } if (lineEnd == std::string_view::npos) { diff --git a/src/app/host_records.h b/src/app/host_records.h index baec292..6556701 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -28,7 +28,7 @@ namespace app { }; /** - * @brief Fetch state for the per-host Sunshine app library. + * @brief Fetch state for the per-host app library. */ enum class HostAppListState { idle, @@ -152,6 +152,20 @@ namespace app { */ 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. * diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp index 86955ad..35ecd70 100644 --- a/src/app/pairing_flow.cpp +++ b/src/app/pairing_flow.cpp @@ -1,6 +1,9 @@ // class header include #include "src/app/pairing_flow.h" +// standard includes +#include + namespace app { PairingDraft create_pairing_draft(const std::string &targetAddress, uint16_t targetPort, std::string generatedPin) { @@ -19,13 +22,9 @@ namespace app { return false; } - for (char digit : pin) { - if (digit < '0' || digit > '9') { - return false; - } - } - - return true; + return std::all_of(pin.begin(), pin.end(), [](char digit) { + return digit >= '0' && digit <= '9'; + }); } } // namespace app diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp index 0bece09..b636710 100644 --- a/src/logging/log_file.cpp +++ b/src/logging/log_file.cpp @@ -2,6 +2,7 @@ #include "src/logging/log_file.h" // standard includes +#include #include #include #include @@ -69,8 +70,7 @@ namespace logging { } const std::string line = persisted_log_line(entry) + "\r\n"; - const std::size_t bytesWritten = std::fwrite(line.data(), 1, line.size(), file); - if (bytesWritten != line.size()) { + 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); } @@ -113,12 +113,11 @@ namespace logging { bufferedLines.push_back(std::move(line)); }; - char buffer[1024] = {}; + std::array buffer {}; std::string pendingLine; - while (std::fgets(buffer, static_cast(sizeof(buffer)), file) != nullptr) { - pendingLine += buffer; - const std::size_t pendingLength = pendingLine.size(); - if (pendingLength == 0U) { + 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; } diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index d6ec87e..1eba2d9 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -2,13 +2,14 @@ #include "src/logging/logger.h" // standard includes +#include #include #include #include #include #if defined(_WIN32) - #include + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names #endif namespace { @@ -78,11 +79,11 @@ namespace logging { } std::string format_timestamp(const LogTimestamp ×tamp) { - char buffer[32] = {}; + std::array buffer {}; const bool validTimestamp = is_valid_timestamp(timestamp); std::snprintf( - buffer, - sizeof(buffer), + buffer.data(), + buffer.size(), "%04d-%02d-%02d %02d:%02d:%02d.%03d", validTimestamp ? timestamp.year : 0, validTimestamp ? timestamp.month : 0, @@ -92,7 +93,7 @@ namespace logging { validTimestamp ? timestamp.second : 0, validTimestamp ? timestamp.millisecond : 0 ); - return buffer; + return {buffer.data()}; } std::string format_entry(const LogEntry &entry) { @@ -105,8 +106,6 @@ namespace logging { Logger::Logger(std::size_t capacity, TimestampProvider timestampProvider): capacity_(capacity == 0 ? 1 : capacity), - minimumLevel_(LogLevel::info), - nextSequence_(1), timestampProvider_(timestampProvider ? std::move(timestampProvider) : TimestampProvider(current_local_timestamp)) {} std::size_t Logger::capacity() const { diff --git a/src/logging/logger.h b/src/logging/logger.h index 8842684..275c0f6 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -155,8 +155,8 @@ namespace logging { private: std::size_t capacity_; - LogLevel minimumLevel_; - uint64_t nextSequence_; + LogLevel minimumLevel_ = LogLevel::info; + uint64_t nextSequence_ = 1; TimestampProvider timestampProvider_; std::deque entries_; std::vector sinks_; diff --git a/src/main.cpp b/src/main.cpp index 8b3abb4..e927b3c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,7 +1,7 @@ // nxdk includes #include #include -#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // standard includes #include @@ -53,7 +53,7 @@ namespace { void debug_print_encoder_settings(DWORD encoderSettings) { debugPrint( "[startup] Encoder settings: 0x%08lX (widescreen=%s, 480p=%s, 720p=%s, 1080i=%s)\n", - static_cast(encoderSettings), + encoderSettings, (encoderSettings & VIDEO_WIDESCREEN) != 0 ? "on" : "off", (encoderSettings & VIDEO_MODE_480P) != 0 ? "on" : "off", (encoderSettings & VIDEO_MODE_720P) != 0 ? "on" : "off", @@ -62,14 +62,14 @@ namespace { } int run_startup_task(void *context) { - StartupTaskState *task = static_cast(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, std::memory_order_release); + task->completed.store(true); return 0; } @@ -108,8 +108,7 @@ int main() { app::ClientState clientState = app::create_initial_state(); const std::string logFilePath = logging::default_log_file_path(); app::set_log_file_path(clientState, logFilePath); - std::string logResetError; - if (!logging::reset_log_file(logFilePath, &logResetError)) { + if (std::string logResetError; !logging::reset_log_file(logFilePath, &logResetError)) { debugPrint("Failed to reset runtime log file %s: %s\n", logFilePath.c_str(), logResetError.c_str()); } logger.add_sink([logFilePath](const logging::LogEntry &entry) { @@ -168,7 +167,7 @@ int main() { logger.log(logging::LogLevel::info, "app", "Showing splash screen"); debug_print_startup_checkpoint("About to show splash screen"); splash::show_splash_screen(window, bestVideoMode, [&startupTask]() { - return !startupTask.completed.load(std::memory_order_acquire); + return !startupTask.completed.load(); }); debug_print_startup_checkpoint("Returned from splash screen"); diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index e18faf9..43f14c4 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -15,16 +15,31 @@ #include #include +// lib includes +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "src/network/runtime_network.h" + // platform includes #ifdef NXDK #include + #include #include #include #elif defined(_WIN32) // clang-format off // winsock2 must be included before windows.h #include - #include + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // clang-format on #else #include @@ -36,11 +51,6 @@ #include #endif -// nxdk includes -#ifdef NXDK - #include -#endif - #if defined(NXDK) || !defined(_WIN32) using SOCKET = int; @@ -55,20 +65,6 @@ using SOCKET = int; #define OPENSSL_SUPPRESS_DEPRECATED -// lib includes -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// local includes -#include "src/network/runtime_network.h" - #ifdef NXDK #define _CRT_RAND_S #endif @@ -95,11 +91,10 @@ namespace { 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 from Sunshine."; + constexpr std::string_view UNPAIRED_CLIENT_ERROR_MESSAGE = "The host reports that this client is no longer paired. Pair the host again."; struct WsaGuard { - WsaGuard(): - initialized(false) { + WsaGuard() { #if defined(NXDK) || !defined(_WIN32) initialized = true; #else @@ -116,13 +111,15 @@ namespace { #endif } - bool initialized; + bool initialized = false; }; struct SocketGuard { - SocketGuard(): - handle(INVALID_SOCKET) { - } + SocketGuard() = default; + SocketGuard(const SocketGuard &) = delete; + SocketGuard &operator=(const SocketGuard &) = delete; + SocketGuard(SocketGuard &&) = delete; + SocketGuard &operator=(SocketGuard &&) = delete; ~SocketGuard() { if (handle != INVALID_SOCKET) { @@ -134,7 +131,7 @@ namespace { } } - SOCKET handle; + SOCKET handle = INVALID_SOCKET; }; bool append_error(std::string *errorMessage, std::string message); @@ -147,20 +144,19 @@ namespace { return append_error(errorMessage, "Pairing cancelled"); } - void append_hash_bytes(uint64_t *hash, const void *bytes, std::size_t byteCount) { + void append_hash_bytes(uint64_t *hash, const unsigned char *bytes, std::size_t byteCount) { if (hash == nullptr || bytes == nullptr) { return; } - const unsigned char *cursor = static_cast(bytes); for (std::size_t index = 0; index < byteCount; ++index) { - *hash ^= cursor[index]; + *hash ^= bytes[index]; *hash *= 1099511628211ULL; } } void append_hash_string(uint64_t *hash, std::string_view text) { - append_hash_bytes(hash, text.data(), text.size()); + append_hash_bytes(hash, reinterpret_cast(text.data()), text.size()); static constexpr unsigned char delimiter = 0x1F; append_hash_bytes(hash, &delimiter, 1U); } @@ -197,7 +193,7 @@ namespace { #endif #if defined(NXDK) || defined(_WIN32) - if (ioctlsocket(socketHandle, FIONBIO, &nonBlockingMode) != 0) { + 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 @@ -331,7 +327,7 @@ namespace { 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 unsigned char character = static_cast(text[index]); + const auto character = static_cast(text[index]); if (character >= 0x20U && character <= 0x7EU) { preview.push_back(static_cast(character)); } else if (character == '\r') { @@ -341,9 +337,9 @@ namespace { } else if (character == '\t') { preview += "\\t"; } else { - char buffer[5] = {}; - std::snprintf(buffer, sizeof(buffer), "\\x%02X", character); - preview += buffer; + std::array buffer {}; + std::snprintf(buffer.data(), buffer.size(), "\\x%02X", character); + preview += buffer.data(); } } @@ -432,8 +428,7 @@ namespace { std::size_t start = 0; while (start < value.size()) { const std::size_t end = value.find(',', start); - const std::string_view item = trim_ascii_whitespace(value.substr(start, end == std::string_view::npos ? std::string_view::npos : end - start)); - if (ascii_iequals(item, token)) { + 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; } @@ -494,8 +489,7 @@ namespace { } std::string_view chunkSizeText = responseText.substr(cursor, chunkLineEnd - cursor); - const std::size_t chunkExtensionSeparator = chunkSizeText.find(';'); - if (chunkExtensionSeparator != std::string_view::npos) { + if (const std::size_t chunkExtensionSeparator = chunkSizeText.find(';'); chunkExtensionSeparator != std::string_view::npos) { chunkSizeText = chunkSizeText.substr(0, chunkExtensionSeparator); } chunkSizeText = trim_ascii_whitespace(chunkSizeText); @@ -543,13 +537,12 @@ namespace { } const std::string_view headerLine = responseText.substr(lineStart, lineEnd - lineStart); - const std::size_t separator = headerLine.find(':'); - if (separator != std::string_view::npos) { + 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 (ascii_iequals(headerName, "Content-Length")) { - hasContentLength = try_parse_decimal_size(headerValue, &contentLength); + hasContentLength = try_parse_decimal_size(headerValue, &contentLength); // NOSONAR(cpp:S134) header parsing keeps validation adjacent to the matching header branch if (!hasContentLength) { return append_error(errorMessage, "Received an invalid Content-Length header while pairing"); } @@ -591,19 +584,18 @@ namespace { std::string details; unsigned long errorCode = 0; while ((errorCode = ERR_get_error()) != 0) { - char errorBuffer[256] = {}; - ERR_error_string_n(errorCode, errorBuffer, sizeof(errorBuffer)); + std::array errorBuffer {}; + ERR_error_string_n(errorCode, errorBuffer.data(), errorBuffer.size()); if (!details.empty()) { details += "; "; } - details += errorBuffer; + details += errorBuffer.data(); } return details; } bool append_openssl_error(std::string *errorMessage, std::string message) { - const std::string details = take_openssl_error_queue(); - if (!details.empty()) { + if (const std::string details = take_openssl_error_queue(); !details.empty()) { message += ": " + details; } return append_error(errorMessage, std::move(message)); @@ -620,7 +612,7 @@ namespace { } #ifdef NXDK - int nxdk_rand_seed(const void *, int) { + int nxdk_rand_seed(const void *, int) { // NOSONAR(cpp:S5008) signature required by OpenSSL RAND_METHOD return 1; } @@ -645,9 +637,10 @@ namespace { } void nxdk_rand_cleanup() { + // intentionally empty - no cleanup required for nxdk random method } - int nxdk_rand_add(const void *, int, double) { + int nxdk_rand_add(const void *, int, double) { // NOSONAR(cpp:S5008) signature required by OpenSSL RAND_METHOD return 1; } @@ -655,7 +648,7 @@ namespace { return 1; } - RAND_METHOD g_nxdk_rand_method = { + const RAND_METHOD g_nxdk_rand_method = { &nxdk_rand_seed, &nxdk_rand_bytes, &nxdk_rand_cleanup, @@ -722,8 +715,8 @@ namespace { 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]; - output[(index * 2) + 1] = HEX_DIGITS[data[index] & 0x0F]; + 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; @@ -759,7 +752,7 @@ namespace { 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)); + decoded.push_back(static_cast((upper << 4) | lower)); // NOSONAR(cpp:S6022) hex decoding is byte-oriented by design } if (bytes != nullptr) { @@ -791,8 +784,8 @@ namespace { return false; } - bytes[6] = static_cast((bytes[6] & 0x0F) | 0x40); - bytes[8] = static_cast((bytes[8] & 0x3F) | 0x80); + 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) { @@ -876,7 +869,7 @@ namespace { return true; } - bool verify_tls_peer_certificate(SSL *ssl, std::string_view expectedCertificatePem, std::string *errorMessage) { + bool verify_tls_peer_certificate(const SSL *ssl, std::string_view expectedCertificatePem, std::string *errorMessage) { if (ssl == nullptr || expectedCertificatePem.empty()) { return true; } @@ -949,14 +942,13 @@ namespace { X509_gmtime_adj(X509_get_notAfter(certificate.get()), 60L * 60L * 24L * 365L * 10L); X509_set_pubkey(certificate.get(), key.get()); - X509_NAME *subject = X509_get_subject_name(certificate.get()); - if (subject == nullptr) { + 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); } - X509_NAME_add_entry_by_txt(subject, "CN", MBSTRING_ASC, reinterpret_cast("NVIDIA GameStream Client"), -1, -1, 0); - X509_set_issuer_name(certificate.get(), subject); - if (X509_sign(certificate.get(), key.get(), EVP_sha256()) == 0) { return append_openssl_error(errorMessage, "Failed to sign the client certificate used for pairing"); } @@ -1002,7 +994,7 @@ namespace { bool try_parse_flag(std::string_view text, bool *value); bool try_parse_uint32(std::string_view text, uint32_t *value); - bool extract_xml_attribute_value(std::string_view openTag, std::string_view attributeName, std::string *value) { + bool extract_xml_attribute_value(std::string_view openTag, std::string_view attributeName, std::string *value) { // NOSONAR(cpp:S3776) permissive host XML parsing stays centralized here std::size_t cursor = 0; while (cursor < openTag.size()) { const std::size_t nameIndex = openTag.find(attributeName, cursor); @@ -1058,7 +1050,7 @@ namespace { const std::string closeTag = ""; std::size_t cursor = 0; - while (cursor < xml.size()) { + 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; @@ -1199,7 +1191,7 @@ namespace { } if (character == closeCharacter) { --depth; - if (depth == 0) { + if (depth == 0) { // NOSONAR(cpp:S134) delimiter matching keeps nested parsing state local if (closeIndex != nullptr) { *closeIndex = index; } @@ -1255,7 +1247,7 @@ namespace { } std::size_t cursor = 1; - while (cursor + 1U < arrayView.size()) { + while (cursor + 1U < arrayView.size()) { // NOSONAR(cpp:S924) permissive JSON scanning uses multiple early breaks for malformed payloads skip_json_whitespace(arrayView, &cursor); if (cursor + 1U >= arrayView.size()) { break; @@ -1281,7 +1273,7 @@ namespace { return blocks; } - bool extract_json_field_value(std::string_view object, std::string_view fieldName, std::string_view *valueView, bool *isStringValue = nullptr) { + bool extract_json_field_value(std::string_view object, std::string_view fieldName, std::string_view *valueView, bool *isStringValue = nullptr) { // NOSONAR(cpp:S3776) permissive JSON parsing stays centralized here if (object.empty() || object.front() != '{') { return false; } @@ -1315,8 +1307,7 @@ namespace { bool valueIsString = false; if (object[keyCursor] == '"') { valueIsString = true; - std::string ignored; - if (!parse_json_string_literal(object, &keyCursor, &ignored)) { + if (std::string ignored; !parse_json_string_literal(object, &keyCursor, &ignored)) { return false; } valueEnd = keyCursor; @@ -1368,7 +1359,7 @@ namespace { std::size_t cursor = 0; std::string parsedValue; if (parse_json_string_literal(rawValue, &cursor, &parsedValue)) { - if (value != nullptr) { + if (value != nullptr) { // NOSONAR(cpp:S134) JSON field extraction keeps conversion adjacent to the matching field *value = std::move(parsedValue); } return true; @@ -1473,9 +1464,8 @@ namespace { extract_xml_attribute_value(roots.front().openTag, "status_code", &statusCodeText); extract_xml_attribute_value(roots.front().openTag, "status_message", &statusMessage); - uint32_t statusCode = 200; - if (!statusCodeText.empty() && try_parse_uint32(trim_ascii_whitespace(statusCodeText), &statusCode) && statusCode != 200U) { - const std::string normalizedStatusMessage = statusMessage.empty() ? "The host returned Sunshine status " + std::to_string(statusCode) + " while requesting /applist" : 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); } @@ -1525,7 +1515,7 @@ namespace { const std::string closeTag = ""; std::size_t cursor = 0; - while (cursor < xml.size()) { + 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; @@ -1622,7 +1612,7 @@ namespace { return true; } - bool connect_socket(const std::string &address, uint16_t port, SocketGuard *socketGuard, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { + bool connect_socket(const std::string &address, uint16_t port, SocketGuard *socketGuard, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { // NOSONAR(cpp:S3776) connection setup keeps platform-specific error handling in one place if (socketGuard == nullptr) { return append_error(errorMessage, "Internal pairing error while preparing the host connection"); } @@ -1658,10 +1648,8 @@ namespace { } trace_pairing_detail("connecting to " + address + ":" + std::to_string(port)); - const int connectResult = connect(socketGuard->handle, reinterpret_cast(&socketAddress), sizeof(socketAddress)); - if (connectResult == SOCKET_ERROR) { - const int connectError = last_socket_error(); - if (!is_connect_in_progress_error(connectError)) { + 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) + ")"); } @@ -1669,7 +1657,7 @@ namespace { constexpr int CONNECT_POLL_INTERVAL_MILLISECONDS = 100; int remainingWaitMilliseconds = SOCKET_TIMEOUT_MILLISECONDS; int selectResult = 0; - while (remainingWaitMilliseconds > 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); } @@ -1699,14 +1687,9 @@ namespace { int socketError = 0; #if defined(_WIN32) && !defined(NXDK) - int socketErrorLength = sizeof(socketError); + if (int socketErrorLength = sizeof(socketError); getsockopt(socketGuard->handle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { #else - socklen_t socketErrorLength = sizeof(socketError); -#endif -#if defined(_WIN32) && !defined(NXDK) - if (getsockopt(socketGuard->handle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { -#else - if (getsockopt(socketGuard->handle, SOL_SOCKET, SO_ERROR, &socketError, &socketErrorLength) != 0) { + if (socklen_t socketErrorLength = sizeof(socketError); getsockopt(socketGuard->handle, 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()) + ")"); } @@ -1728,15 +1711,15 @@ namespace { bool recv_all_plain(SOCKET socketHandle, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { std::string received; - char buffer[4096] = {}; + std::array buffer {}; std::size_t completeLength = 0; - while (true) { + 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, sizeof(buffer), 0); + const int bytesRead = recv(socketHandle, buffer.data(), static_cast(buffer.size()), 0); if (bytesRead == 0) { break; } @@ -1744,7 +1727,7 @@ namespace { 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, buffer + bytesRead); + received.append(buffer.data(), buffer.data() + bytesRead); std::string framingError; if (try_get_http_response_length(received, &completeLength, &framingError)) { @@ -1764,15 +1747,15 @@ namespace { bool recv_all_ssl(SSL *ssl, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { std::string received; - char buffer[4096] = {}; + std::array buffer {}; std::size_t completeLength = 0; - while (true) { + 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, sizeof(buffer)); + const int bytesRead = SSL_read(ssl, buffer.data(), static_cast(buffer.size())); if (bytesRead == 0) { break; } @@ -1786,7 +1769,7 @@ namespace { } 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, buffer + bytesRead); + received.append(buffer.data(), buffer.data() + bytesRead); std::string framingError; if (try_get_http_response_length(received, &completeLength, &framingError)) { @@ -1926,7 +1909,7 @@ namespace { return append_error(errorMessage, std::move(combinedMessage)); } - bool http_get( + bool http_get( // NOSONAR(cpp:S3776,cpp:S107) HTTP transport keeps TLS/plain fallback and error reporting in one place const std::string &address, uint16_t port, std::string_view pathAndQuery, @@ -1942,8 +1925,7 @@ namespace { } trace_pairing_phase("http_get: socket initialization"); - WsaGuard wsaGuard; - if (!wsaGuard.initialized) { + if (WsaGuard wsaGuard; !wsaGuard.initialized) { return append_error(errorMessage, "Failed to initialize socket support for host pairing"); } @@ -1983,7 +1965,7 @@ namespace { return append_openssl_error(errorMessage, "Failed to create the TLS context for host pairing"); } - SSL_CTX_set_verify(context.get(), SSL_VERIFY_NONE, nullptr); + 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; } @@ -1997,7 +1979,7 @@ namespace { } #ifdef NXDK - std::unique_ptr socketBio(BIO_new_fd(static_cast(socketGuard.handle), BIO_NOCLOSE)); + std::unique_ptr socketBio(BIO_new_fd(socketGuard.handle, 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"); } @@ -2011,8 +1993,7 @@ namespace { #endif ERR_clear_error(); trace_pairing_phase("http_get: SSL_connect"); - const int connectResult = SSL_connect(ssl.get()); - if (connectResult != 1) { + 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) { @@ -2036,7 +2017,8 @@ namespace { } const EVP_MD *pairing_digest(int serverMajorVersion) { - return serverMajorVersion >= 7 ? EVP_sha256() : EVP_sha1(); + // TODO: remove legacy support... it is not needed + return serverMajorVersion >= 7 ? EVP_sha256() : EVP_sha1(); // NOSONAR(cpp:S4790) legacy servers require SHA-1 compatibility } std::size_t pairing_hash_length(int serverMajorVersion) { @@ -2063,7 +2045,7 @@ namespace { 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) { + 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"); } @@ -2086,7 +2068,7 @@ namespace { 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) { + 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"); } @@ -2163,7 +2145,7 @@ namespace { return true; } - bool load_certificate_signature(X509 *certificate, std::vector *signature, std::string *errorMessage) { + 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) { @@ -2198,6 +2180,35 @@ namespace network { 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; @@ -2333,8 +2344,7 @@ namespace network { } HttpResponse authorizationResponse {}; - std::string authorizationError; - if (!http_get(address, serverInfo->httpsPort, build_serverinfo_path(resolve_client_unique_id(clientIdentity)), true, clientIdentity, {}, &authorizationResponse, &authorizationError)) { + if (std::string authorizationError; !http_get(address, serverInfo->httpsPort, build_serverinfo_path(resolve_client_unique_id(clientIdentity)), true, clientIdentity, {}, &authorizationResponse, &authorizationError)) { return true; } @@ -2407,8 +2417,7 @@ namespace network { HttpResponse response {}; const uint16_t appListPort = resolvedServerInfo.httpsPort == 0 ? resolvedServerInfo.httpPort : resolvedServerInfo.httpsPort; - const std::string appListAddress = resolve_reachable_address(address, resolvedServerInfo); - if (!http_get(appListAddress, appListPort, build_applist_path(resolve_client_unique_id(clientIdentity)), true, clientIdentity, {}, &response, errorMessage)) { + 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; } @@ -2419,8 +2428,7 @@ namespace network { return append_error(errorMessage, "The host returned HTTP " + std::to_string(response.statusCode) + " while requesting /applist"); } - std::string parseError; - if (!parse_app_list_response(response.body, apps, &parseError)) { + 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)); } @@ -2437,9 +2445,9 @@ namespace network { uint64_t hash = 1469598103934665603ULL; for (const HostAppEntry &entry : apps) { append_hash_string(&hash, entry.name); - append_hash_bytes(&hash, &entry.id, sizeof(entry.id)); - append_hash_bytes(&hash, &entry.hdrSupported, sizeof(entry.hdrSupported)); - append_hash_bytes(&hash, &entry.hidden, sizeof(entry.hidden)); + 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; } @@ -2459,8 +2467,7 @@ namespace network { std::vector attemptFailures; for (const std::string &path : build_app_asset_paths(resolve_client_unique_id(clientIdentity), appId)) { HttpResponse response {}; - std::string attemptError; - if (!http_get(address, httpsPort, path, true, clientIdentity, {}, &response, &attemptError)) { + if (std::string attemptError; !http_get(address, httpsPort, path, true, clientIdentity, {}, &response, &attemptError)) { attemptFailures.push_back(path + ": " + attemptError); continue; } @@ -2495,7 +2502,7 @@ namespace network { return append_error(errorMessage, std::move(combinedMessage)); } - HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested) { + HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested) { // NOSONAR(cpp:S3776) pairing protocol phases intentionally remain linear and explicit here trace_pairing_phase("pair_host entered"); HostPairingResult result {false, false, "Pairing failed"}; auto fail_if_cancelled = [&cancelRequested, &result]() { @@ -2719,8 +2726,7 @@ namespace network { } std::vector serverSecret(pairingSecretBytes.begin(), pairingSecretBytes.begin() + 16); - std::vector serverSignature(pairingSecretBytes.begin() + 16, pairingSecretBytes.end()); - if (!verify_sha256_signature(serverSecret, serverSignature, plainCertificate.get(), &errorMessage)) { + if (std::vector serverSignature(pairingSecretBytes.begin() + 16, pairingSecretBytes.end()); !verify_sha256_signature(serverSecret, serverSignature, plainCertificate.get(), &errorMessage)) { return fail_with_phase("phase 4 (pairing secret)", errorMessage); } diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index ef3fe4c..7381b07 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -84,6 +84,15 @@ namespace network { */ 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. * diff --git a/src/network/runtime_network.cpp b/src/network/runtime_network.cpp index b876bab..c767a61 100644 --- a/src/network/runtime_network.cpp +++ b/src/network/runtime_network.cpp @@ -7,12 +7,12 @@ #include #include -extern "C" struct netif *g_pnetif; +extern "C" struct netif *g_pnetif; // NOSONAR(cpp:S5421) external symbol declared by nxdk; cannot be const #endif namespace { - network::RuntimeNetworkStatus g_runtimeNetworkStatus {}; + network::RuntimeNetworkStatus g_runtimeNetworkStatus {}; // NOSONAR(cpp:S5421) mutable state updated at runtime #ifdef NXDK std::string copy_ipv4_string(const ip4_addr_t *address) { @@ -25,7 +25,7 @@ namespace { } #endif - network::RuntimeNetworkStatus make_host_network_status() { + [[maybe_unused]] network::RuntimeNetworkStatus make_host_network_status() { return { true, true, diff --git a/src/platform/filesystem_utils.cpp b/src/platform/filesystem_utils.cpp index e2e0c5d..6f17e18 100644 --- a/src/platform/filesystem_utils.cpp +++ b/src/platform/filesystem_utils.cpp @@ -5,10 +5,11 @@ #include #include #include +#include // platform includes #if defined(_WIN32) || defined(NXDK) - #include + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names extern "C" { #include } @@ -19,18 +20,9 @@ extern "C" { namespace { - bool is_drive_root_path(const std::string &path) { + bool is_root_path(std::string_view path) { #if defined(_WIN32) || defined(NXDK) - return path.size() <= 3 && path.size() >= 2 && path[1] == ':'; -#else - (void) path; - return false; -#endif - } - - bool is_root_path(const std::string &path) { -#if defined(_WIN32) || defined(NXDK) - return is_drive_root_path(path); + return path.size() >= 2 && path.size() <= 3 && path[1] == ':'; #else return path == "/"; #endif @@ -52,7 +44,7 @@ namespace { return true; } #else - if (mkdir(path.c_str(), 0777) == 0 || errno == EEXIST) { + if (mkdir(path.c_str(), 0750) == 0 || errno == EEXIST) { return true; } #endif @@ -100,12 +92,12 @@ namespace platform { return left + preferred_path_separator() + right; } - std::string parent_directory(const std::string &filePath) { + std::string parent_directory(std::string_view filePath) { const std::size_t separatorIndex = filePath.find_last_of("\\/"); if (separatorIndex == std::string::npos) { return {}; } - return filePath.substr(0, separatorIndex); + return std::string(filePath.substr(0, separatorIndex)); } bool ensure_directory_exists(const std::string &directoryPath, std::string *errorMessage) { @@ -130,8 +122,7 @@ namespace platform { for (std::size_t index = startIndex; index < directoryPath.size(); ++index) { partialPath.push_back(directoryPath[index]); const bool atSeparator = is_path_separator(directoryPath[index]); - const bool atPathEnd = index + 1 == directoryPath.size(); - if (!atSeparator && !atPathEnd) { + if (const bool atPathEnd = index + 1 == directoryPath.size(); !atSeparator && !atPathEnd) { continue; } @@ -148,10 +139,10 @@ namespace platform { return true; } - bool try_get_file_size(const std::string &path, std::uint64_t *sizeBytes) { + bool try_get_file_size(std::string_view path, std::uint64_t *sizeBytes) { #if defined(_WIN32) || defined(NXDK) WIN32_FILE_ATTRIBUTE_DATA fileData {}; - if (!GetFileAttributesExA(path.c_str(), GetFileExInfoStandard, &fileData)) { + if (const std::string ownedPath(path); !GetFileAttributesExA(ownedPath.c_str(), GetFileExInfoStandard, &fileData)) { return false; } if ((fileData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0U) { @@ -159,15 +150,13 @@ namespace platform { } if (sizeBytes != nullptr) { - ULARGE_INTEGER sizeValue {}; - sizeValue.HighPart = fileData.nFileSizeHigh; - sizeValue.LowPart = fileData.nFileSizeLow; - *sizeBytes = sizeValue.QuadPart; + *sizeBytes = (static_cast(fileData.nFileSizeHigh) << 32U) | + static_cast(fileData.nFileSizeLow); } return true; #else struct stat status {}; - if (stat(path.c_str(), &status) != 0 || !S_ISREG(status.st_mode)) { + if (const std::string ownedPath(path); stat(ownedPath.c_str(), &status) != 0 || !S_ISREG(status.st_mode)) { return false; } diff --git a/src/platform/filesystem_utils.h b/src/platform/filesystem_utils.h index 0b35aa2..eeda309 100644 --- a/src/platform/filesystem_utils.h +++ b/src/platform/filesystem_utils.h @@ -3,6 +3,7 @@ // standard includes #include #include +#include namespace platform { @@ -12,11 +13,11 @@ namespace platform { std::string join_path(const std::string &left, const std::string &right); - std::string parent_directory(const std::string &filePath); + std::string parent_directory(std::string_view filePath); bool ensure_directory_exists(const std::string &directoryPath, std::string *errorMessage = nullptr); - bool try_get_file_size(const std::string &path, std::uint64_t *sizeBytes = nullptr); + bool try_get_file_size(std::string_view path, std::uint64_t *sizeBytes = nullptr); bool path_has_prefix(const std::string &path, const std::string &prefix); diff --git a/src/splash/splash_layout.cpp b/src/splash/splash_layout.cpp index a840f4c..e9c5989 100644 --- a/src/splash/splash_layout.cpp +++ b/src/splash/splash_layout.cpp @@ -29,9 +29,7 @@ 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); - - if (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; } diff --git a/src/splash/splash_screen.cpp b/src/splash/splash_screen.cpp index ba12ae4..b795ff1 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -12,7 +12,7 @@ #include #include #include -#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // local includes #include "src/os.h" @@ -226,7 +226,7 @@ namespace { IMG_Quit(); } - void runSplashScreen(SDL_Window *window, const VIDEO_MODE &videoMode, const std::function &keepShowing) { + 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); diff --git a/src/startup/client_identity_storage.cpp b/src/startup/client_identity_storage.cpp index 9a679cf..720a6ed 100644 --- a/src/startup/client_identity_storage.cpp +++ b/src/startup/client_identity_storage.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include // local includes @@ -61,7 +62,7 @@ namespace { return {std::move(content), 0}; } - bool write_file_text(const std::string &filePath, const std::string &content, std::string *errorMessage) { + 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) { @@ -70,8 +71,7 @@ namespace { return false; } - const std::size_t bytesWritten = std::fwrite(content.data(), 1, content.size(), file); - if (bytesWritten != content.size()) { + if (const std::size_t bytesWritten = std::fwrite(content.data(), 1, content.size(), file); bytesWritten != content.size()) { if (errorMessage != nullptr) { *errorMessage = std::strerror(errno); } @@ -157,8 +157,7 @@ namespace startup { const std::string certificatePath = join_path(directoryPath, CERTIFICATE_FILE_NAME); const std::string privateKeyPath = join_path(directoryPath, PRIVATE_KEY_FILE_NAME); - std::string deleteError; - if (!delete_file_if_present(uniqueIdPath, &deleteError) || !delete_file_if_present(certificatePath, &deleteError) || !delete_file_if_present(privateKeyPath, &deleteError)) { + 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); } @@ -171,18 +170,15 @@ namespace startup { return {false, errorMessage}; } - const std::string uniqueIdPath = join_path(directoryPath, UNIQUE_ID_FILE_NAME); - if (!write_file_text(uniqueIdPath, identity.uniqueId, &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}; } - const std::string certificatePath = join_path(directoryPath, CERTIFICATE_FILE_NAME); - if (!write_file_text(certificatePath, identity.certificatePem, &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}; } - const std::string privateKeyPath = join_path(directoryPath, PRIVATE_KEY_FILE_NAME); - if (!write_file_text(privateKeyPath, identity.privateKeyPem, &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}; } diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp index 2eb1105..95c6364 100644 --- a/src/startup/cover_art_cache.cpp +++ b/src/startup/cover_art_cache.cpp @@ -2,6 +2,7 @@ #include "src/startup/cover_art_cache.h" // standard includes +#include #include #include #include @@ -14,11 +15,11 @@ #if __has_include() #include #include - #define MOONLIGHT_HAS_NXDK_XBE 1 + #define MOONLIGHT_HAS_NXDK_XBE 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use #endif #if __has_include() #include - #define MOONLIGHT_HAS_NXDK_MOUNT 1 + #define MOONLIGHT_HAS_NXDK_MOUNT 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use #endif #endif @@ -42,9 +43,9 @@ namespace { } #endif - char titleIdBuffer[9] = {}; - std::snprintf(titleIdBuffer, sizeof(titleIdBuffer), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); - return std::string("E:\\UDATA\\") + titleIdBuffer + "\\"; + 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 @@ -97,8 +98,7 @@ namespace { namespace startup { std::string default_cover_art_cache_root() { - const std::string titleScopedRoot = title_scoped_storage_root(); - if (!titleScopedRoot.empty()) { + if (const std::string titleScopedRoot = title_scoped_storage_root(); !titleScopedRoot.empty()) { return titleScopedRoot + "cover-art-cache"; } @@ -166,8 +166,7 @@ namespace startup { return {false, "Failed to open cover-art cache entry for writing: " + std::string(std::strerror(errno))}; } - const std::size_t bytesWritten = std::fwrite(bytes.data(), 1, bytes.size(), file); - if (bytesWritten != bytes.size()) { + 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}; diff --git a/src/startup/host_storage.cpp b/src/startup/host_storage.cpp index 9c8a82b..78affc9 100644 --- a/src/startup/host_storage.cpp +++ b/src/startup/host_storage.cpp @@ -2,6 +2,7 @@ #include "src/startup/host_storage.h" // standard includes +#include #include #include #include @@ -13,11 +14,11 @@ #if __has_include() #include #include - #define MOONLIGHT_HAS_NXDK_XBE 1 + #define MOONLIGHT_HAS_NXDK_XBE 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use #endif #if __has_include() #include - #define MOONLIGHT_HAS_NXDK_MOUNT 1 + #define MOONLIGHT_HAS_NXDK_MOUNT 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use #endif #endif @@ -52,9 +53,9 @@ namespace { } #endif - char titleIdBuffer[9] = {}; - std::snprintf(titleIdBuffer, sizeof(titleIdBuffer), "%08X", CURRENT_XBE_HEADER->CertificateHeader->TitleID); - return std::string("E:\\UDATA\\") + titleIdBuffer + "\\"; + 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 @@ -65,8 +66,7 @@ namespace { namespace startup { std::string default_host_storage_path() { - const std::string titleScopedRoot = title_scoped_storage_root(); - if (!titleScopedRoot.empty()) { + if (const std::string titleScopedRoot = title_scoped_storage_root(); !titleScopedRoot.empty()) { return titleScopedRoot + "moonlight-hosts.tsv"; } @@ -98,8 +98,7 @@ namespace startup { } SaveSavedHostsResult save_saved_hosts(const std::vector &hosts, const std::string &filePath) { - std::string errorMessage; - if (!platform::ensure_directory_exists(platform::parent_directory(filePath), &errorMessage)) { + if (std::string errorMessage; !platform::ensure_directory_exists(platform::parent_directory(filePath), &errorMessage)) { return {false, errorMessage}; } @@ -109,8 +108,7 @@ namespace startup { } const std::string serializedHosts = app::serialize_host_records(hosts); - const std::size_t bytesWritten = std::fwrite(serializedHosts.data(), 1, serializedHosts.size(), file); - if (bytesWritten != serializedHosts.size()) { + 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}; diff --git a/src/startup/saved_files.cpp b/src/startup/saved_files.cpp index 99c76be..b9435a0 100644 --- a/src/startup/saved_files.cpp +++ b/src/startup/saved_files.cpp @@ -14,7 +14,7 @@ // platform includes #if defined(_WIN32) || defined(NXDK) - #include + #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names #else #include #include @@ -162,7 +162,7 @@ namespace { } errno = 0; - while (dirent *entry = readdir(directory)) { + while (const dirent *entry = readdir(directory)) { const std::string entryName = entry->d_name; if (entryName == "." || entryName == "..") { continue; @@ -203,12 +203,12 @@ namespace { return true; } - 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), - }; - if (std::find(pairingFiles.begin(), pairingFiles.end(), path) != pairingFiles.end()) { + 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; } diff --git a/src/ui/menu_model.cpp b/src/ui/menu_model.cpp index 0d91748..f531f61 100644 --- a/src/ui/menu_model.cpp +++ b/src/ui/menu_model.cpp @@ -6,8 +6,7 @@ namespace ui { - MenuModel::MenuModel(std::vector items): - selectedIndex_(npos) { + MenuModel::MenuModel(std::vector items) { set_items(std::move(items)); } @@ -32,7 +31,7 @@ namespace ui { return &items_[selectedIndex_]; } - bool MenuModel::select_item_by_id(const std::string &itemId) { + 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_; @@ -65,9 +64,11 @@ namespace ui { 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: diff --git a/src/ui/menu_model.h b/src/ui/menu_model.h index 8a46e6e..13ba420 100644 --- a/src/ui/menu_model.h +++ b/src/ui/menu_model.h @@ -4,6 +4,7 @@ #include #include #include +#include #include // local includes @@ -62,21 +63,21 @@ namespace ui { * * @return Immutable view of the menu items in display order. */ - const std::vector &items() const; + [[nodiscard]] const std::vector &items() const; /** * @brief Return the selected item index or npos when none is selectable. * * @return Selected item index or npos. */ - std::size_t selected_index() const; + [[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. */ - const MenuItem *selected_item() const; + [[nodiscard]] const MenuItem *selected_item() const; /** * @brief Select a specific enabled item by its stable identifier. @@ -84,7 +85,7 @@ namespace ui { * @param itemId Identifier to select. * @return true when the selection changed. */ - bool select_item_by_id(const std::string &itemId); + bool select_item_by_id(std::string_view itemId); /** * @brief Apply a UI command to the menu. @@ -96,10 +97,10 @@ namespace ui { private: bool move_selection(int direction); - std::size_t find_first_enabled_index() const; + [[nodiscard]] std::size_t find_first_enabled_index() const; std::vector items_; - std::size_t selectedIndex_; + std::size_t selectedIndex_ = npos; }; } // namespace ui diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index e89210f..513e792 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -18,7 +18,7 @@ #include #include #include -#include +#include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // local includes #include "src/input/navigation_input.h" @@ -131,24 +131,6 @@ namespace { return 1; } - bool host_matches_endpoint(const app::HostRecord &host, const std::string &address, uint16_t port) { - if (host.address != address) { - return false; - } - - const uint16_t effectivePort = app::effective_host_port(port); - if (app::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 append_error(std::string *errorMessage, std::string message) { if (errorMessage != nullptr) { *errorMessage = std::move(message); @@ -163,6 +145,29 @@ namespace { } } + 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; + } + struct CoverArtTextureCache { std::unordered_map textures; std::unordered_map failedKeys; @@ -186,8 +191,9 @@ namespace { return; } - for (const auto &entry : cache->textures) { - destroy_texture(entry.second); + for (const auto &[cacheKey, texture] : cache->textures) { + (void) cacheKey; + destroy_texture(texture); } cache->textures.clear(); cache->failedKeys.clear(); @@ -198,8 +204,7 @@ namespace { return; } - const auto textureIterator = cache->textures.find(cacheKey); - if (textureIterator != cache->textures.end()) { + if (const auto textureIterator = cache->textures.find(cacheKey); textureIterator != cache->textures.end()) { destroy_texture(textureIterator->second); cache->textures.erase(textureIterator); } @@ -211,8 +216,9 @@ namespace { return; } - for (const auto &entry : cache->textures) { - destroy_texture(entry.second); + for (const auto &[assetPath, texture] : cache->textures) { + (void) assetPath; + destroy_texture(texture); } cache->textures.clear(); cache->failedKeys.clear(); @@ -240,8 +246,8 @@ namespace { 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) { - const unsigned char character = static_cast(text[index]); + 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; } @@ -259,19 +265,19 @@ namespace { } std::size_t sequenceLength = 0U; - if ((character & 0xE0U) == 0xC0U) { + if ((character & 0xE0U) == 0xC0U) { // NOSONAR(cpp:S6022) UTF-8 parsing is byte-oriented by design sequenceLength = 2U; - } else if ((character & 0xF0U) == 0xE0U) { + } else if ((character & 0xF0U) == 0xE0U) { // NOSONAR(cpp:S6022) UTF-8 parsing is byte-oriented by design sequenceLength = 3U; - } else if ((character & 0xF8U) == 0xF0U) { + } 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) { - const unsigned char continuation = static_cast(text[index + continuationIndex]); - sequenceValid = (continuation & 0xC0U) == 0x80U; + 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) { @@ -332,7 +338,7 @@ namespace { TTF_Font *labelFont, const ui::ShellAppTile &tile, const SDL_Rect &rect, - AssetTextureCache *assetCache + const AssetTextureCache *assetCache ); SDL_Texture *load_cover_art_texture(SDL_Renderer *renderer, CoverArtTextureCache *cache, const std::string &cacheKey) { @@ -340,8 +346,7 @@ namespace { return nullptr; } - const auto existingTexture = cache->textures.find(cacheKey); - if (existingTexture != cache->textures.end()) { + if (const auto existingTexture = cache->textures.find(cacheKey); existingTexture != cache->textures.end()) { return existingTexture->second; } if (cache->failedKeys.find(cacheKey) != cache->failedKeys.end()) { @@ -373,7 +378,7 @@ namespace { return nullptr; } - cache->textures.emplace(cacheKey, texture); + cache->textures.try_emplace(cacheKey, texture); return texture; } @@ -403,8 +408,7 @@ namespace { return nullptr; } - const auto existingTexture = cache->textures.find(relativePath); - if (existingTexture != cache->textures.end()) { + if (const auto existingTexture = cache->textures.find(relativePath); existingTexture != cache->textures.end()) { return existingTexture->second; } if (cache->failedKeys.find(relativePath) != cache->failedKeys.end()) { @@ -417,7 +421,7 @@ namespace { return nullptr; } - cache->textures.emplace(relativePath, texture); + cache->textures.try_emplace(relativePath, texture); return texture; } @@ -431,7 +435,7 @@ namespace { SDL_RenderDrawRect(renderer, &rect); } - bool render_text_line( + 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, @@ -461,22 +465,7 @@ namespace { 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; + return render_surface_line(renderer, surface, x, y, drawnHeight); } std::string fit_single_line_text(TTF_Font *font, const std::string &text, int maxWidth) { @@ -497,7 +486,7 @@ namespace { const std::string ellipsis = "..."; for (std::size_t length = sanitized.size(); length > 0U; --length) { - const std::string candidate = sanitize_ascii_text_for_render(sanitized.substr(0, length)) + ellipsis; + 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; } @@ -506,7 +495,7 @@ namespace { return ellipsis; } - bool render_text_line_simple( + 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, @@ -536,22 +525,7 @@ namespace { 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; + return render_surface_line(renderer, surface, x, y, drawnHeight); } bool render_text_centered_simple( @@ -680,7 +654,7 @@ namespace { TTF_Font *labelFont, const ui::ShellAppTile &tile, const SDL_Rect &rect, - AssetTextureCache *assetCache + const AssetTextureCache *assetCache ) { (void) assetCache; const SDL_Color seedColor = placeholder_color(tile.name); @@ -719,74 +693,11 @@ namespace { 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) { + 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); } - void draw_host_icon(SDL_Renderer *renderer, const SDL_Rect &rect, const ui::ShellHostTile &tile) { - const Uint8 iconRed = tile.reachability == app::HostReachability::online ? TEXT_RED : MUTED_RED; - const Uint8 iconGreen = tile.reachability == app::HostReachability::online ? TEXT_GREEN : MUTED_GREEN; - const Uint8 iconBlue = tile.reachability == app::HostReachability::online ? TEXT_BLUE : MUTED_BLUE; - - const int iconWidth = std::min(rect.w, std::max(52, (rect.h * 10) / 9)); - const int iconHeight = std::min(rect.h, std::max(44, (iconWidth * 4) / 5)); - const SDL_Rect iconRect { - rect.x + std::max(0, (rect.w - iconWidth) / 2), - rect.y + std::max(0, (rect.h - iconHeight) / 2), - iconWidth, - iconHeight, - }; - const SDL_Rect monitorRect {iconRect.x + (iconRect.w / 10), iconRect.y + (iconRect.h / 14), (iconRect.w * 4) / 5, (iconRect.h * 3) / 5}; - const SDL_Rect standRect {iconRect.x + (iconRect.w * 7) / 16, monitorRect.y + monitorRect.h + std::max(4, iconRect.h / 20), std::max(6, iconRect.w / 8), std::max(6, iconRect.h / 10)}; - const SDL_Rect baseRect {iconRect.x + (iconRect.w * 5) / 16, standRect.y + standRect.h, iconRect.w / 4, std::max(4, iconRect.h / 18)}; - draw_rect(renderer, monitorRect, iconRed, iconGreen, iconBlue); - draw_rect(renderer, standRect, iconRed, iconGreen, iconBlue); - draw_rect(renderer, baseRect, iconRed, iconGreen, iconBlue); - - const int symbolMargin = std::max(4, std::min(monitorRect.w, monitorRect.h) / 5); - const SDL_Rect symbolRect { - monitorRect.x + symbolMargin, - monitorRect.y + symbolMargin, - std::max(10, monitorRect.w - (symbolMargin * 2)), - std::max(10, monitorRect.h - (symbolMargin * 2)), - }; - if (tile.reachability != app::HostReachability::online) { - const int centerX = symbolRect.x + (symbolRect.w / 2); - const int topY = symbolRect.y; - const int bottomY = symbolRect.y + symbolRect.h; - draw_line(renderer, centerX, topY, symbolRect.x, bottomY, iconRed, iconGreen, iconBlue); - draw_line(renderer, symbolRect.x, bottomY, symbolRect.x + symbolRect.w, bottomY, iconRed, iconGreen, iconBlue); - draw_line(renderer, symbolRect.x + symbolRect.w, bottomY, centerX, topY, iconRed, iconGreen, iconBlue); - fill_rect(renderer, {centerX - 2, symbolRect.y + std::max(4, symbolRect.h / 5), 4, std::max(6, symbolRect.h / 3)}, iconRed, iconGreen, iconBlue); - fill_rect(renderer, {centerX - 2, bottomY - std::max(6, symbolRect.h / 6), 4, 4}, iconRed, iconGreen, iconBlue); - return; - } - - if (tile.pairingState != app::PairingState::paired) { - const int bodyWidth = std::max(10, (symbolRect.w * 3) / 5); - const int bodyHeight = std::max(8, (symbolRect.h * 2) / 5); - const SDL_Rect bodyRect { - symbolRect.x + std::max(0, (symbolRect.w - bodyWidth) / 2), - symbolRect.y + std::max(4, symbolRect.h / 3), - bodyWidth, - bodyHeight, - }; - const SDL_Rect shackleRect { - bodyRect.x + std::max(1, bodyRect.w / 8), - symbolRect.y + 2, - std::max(8, (bodyRect.w * 3) / 4), - std::max(8, symbolRect.h / 2), - }; - draw_rect(renderer, shackleRect, iconRed, iconGreen, iconBlue); - draw_rect(renderer, bodyRect, iconRed, iconGreen, iconBlue); - return; - } - - draw_line(renderer, symbolRect.x + std::max(2, symbolRect.w / 10), symbolRect.y + (symbolRect.h / 2), symbolRect.x + (symbolRect.w / 2) - 1, symbolRect.y + symbolRect.h - std::max(2, symbolRect.h / 8), iconRed, iconGreen, iconBlue); - draw_line(renderer, symbolRect.x + (symbolRect.w / 2) - 1, symbolRect.y + symbolRect.h - std::max(2, symbolRect.h / 8), symbolRect.x + symbolRect.w - std::max(2, symbolRect.w / 10), symbolRect.y + std::max(2, symbolRect.h / 10), iconRed, iconGreen, iconBlue); - } - bool render_app_cover( SDL_Renderer *renderer, TTF_Font *labelFont, @@ -796,8 +707,7 @@ namespace { AssetTextureCache *assetCache ) { fill_rect(renderer, rect, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xFF); - SDL_Texture *texture = tile.boxArtCached ? load_cover_art_texture(renderer, textureCache, tile.boxArtCacheKey) : nullptr; - if (texture != nullptr) { + 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; } @@ -866,7 +776,7 @@ namespace { fill_rect(renderer, thumbRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD0); } - bool render_log_viewer_modal( + bool render_log_viewer_modal( // NOSONAR(cpp:S3776,cpp:S107) modal rendering stays centralized to keep layout behavior consistent SDL_Renderer *renderer, TTF_Font *bodyFont, TTF_Font *smallFont, @@ -894,13 +804,13 @@ namespace { 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}; - 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}, - }; - if (!render_footer_actions(renderer, smallFont, assetCache, logViewerActions, hintRect)) { + 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; } @@ -922,7 +832,7 @@ namespace { const std::size_t maxOffset = viewModel.logViewerLines.size() > 1U ? viewModel.logViewerLines.size() - 1U : 0U; const std::size_t clampedOffset = std::min(viewModel.logViewerScrollOffset, maxOffset); - auto build_log_viewer_layout = [&](int availableWidth) { + auto build_log_viewer_layout = [&](int availableWidth) { // NOSONAR(cpp:S1188) kept adjacent to modal layout state for readability LogViewerLayout layout {}; if (viewModel.logViewerLines.empty()) { layout.visibleLines.push_back(nullptr); @@ -962,8 +872,7 @@ namespace { contentRect.h, }; int contentCursorY = textRect.y + 6; - const bool olderLinesAvailable = logViewerLayout.firstVisibleIndex > 0U; - if (olderLinesAvailable) { + if (const bool olderLinesAvailable = logViewerLayout.firstVisibleIndex > 0U; olderLinesAvailable) { 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; } @@ -984,10 +893,8 @@ namespace { } } - if (viewModel.logViewerScrollOffset > 0U) { - if (!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 false; - } + if (viewModel.logViewerScrollOffset > 0U && !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 false; } if (overflow) { @@ -1016,7 +923,7 @@ namespace { const int rowSpacing = 6; const int rowStep = rowHeight + rowSpacing; - const std::size_t visibleRowCount = static_cast(std::max(1, (rect.h + rowSpacing) / std::max(1, rowStep))); + 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) { @@ -1051,8 +958,7 @@ namespace { } const std::string label = row.checked ? "[x] " + row.label : row.label; - const SDL_Color color = row.enabled ? SDL_Color {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF} : SDL_Color {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}; - if (!render_text_line_simple(renderer, font, label, color, rowRect.x + 12, rowRect.y + 8, rowRect.w - 24)) { + 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; } @@ -1090,17 +996,14 @@ namespace { const int iconSize = std::max(18, buttonRect.h - 16); const SDL_Rect iconRect {buttonRect.x + 10, buttonRect.y + (buttonRect.h - iconSize) / 2, iconSize, iconSize}; - const bool renderedIcon = !button.iconAssetPath.empty() && render_asset_icon(renderer, assetCache, button.iconAssetPath, iconRect); - if (!renderedIcon) { - if (!render_text_centered(renderer, font, button.glyph, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, iconRect)) { - return false; - } + 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)); } - bool render_footer_actions( + bool render_footer_actions( // NOSONAR(cpp:S3776) footer chip layout is intentionally centralized for all shell surfaces SDL_Renderer *renderer, TTF_Font *font, AssetTextureCache *assetCache, @@ -1114,8 +1017,7 @@ namespace { for (const ui::ShellFooterAction &action : actions) { int labelWidth = 0; - int labelHeight = 0; - if (TTF_SizeUTF8(font, action.label.c_str(), &labelWidth, &labelHeight) != 0) { + if (int labelHeight = 0; TTF_SizeUTF8(font, action.label.c_str(), &labelWidth, &labelHeight) != 0) { labelWidth = static_cast(action.label.size()) * 8; } @@ -1154,7 +1056,7 @@ namespace { return true; } - bool render_notification( + bool render_notification( // NOSONAR(cpp:S107) notification layout helper keeps the render inputs explicit SDL_Renderer *renderer, TTF_Font *titleFont, TTF_Font *bodyFont, @@ -1225,7 +1127,7 @@ namespace { 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 int selectedRow = static_cast(std::min(selectedIndex, itemCount - 1U) / columnCount); + 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; @@ -1394,9 +1296,9 @@ namespace { persist_hosts(logger, state); } - void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { + void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { // NOSONAR(cpp:S3776) host metadata updates intentionally stay grouped with pairing-state transitions for (app::HostRecord &host : state.hosts) { - if (!host_matches_endpoint(host, address, port)) { + if (!app::host_matches_endpoint(host, address, port)) { continue; } @@ -1425,11 +1327,11 @@ namespace { if (!clientIsEffectivelyPaired) { 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 from Sunshine."; + 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.selectedAppIndex = 0U; - if (state.activeScreen == app::ScreenId::apps && state.selectedHostIndex < state.hosts.size() && &host == &state.hosts[state.selectedHostIndex]) { + if (state.activeScreen == app::ScreenId::apps && state.selectedHostIndex < state.hosts.size() && &host == &state.hosts[state.selectedHostIndex]) { // NOSONAR(cpp:S134) selected-host UI update stays inline with pairing-state demotion state.statusMessage = host.appListStatusMessage; } } @@ -1449,8 +1351,7 @@ namespace { } std::string cover_art_cache_key_from_path(const std::string &path) { - const std::string coverArtRoot = startup::default_cover_art_cache_root(); - if (coverArtRoot.empty() || path.size() <= coverArtRoot.size() || path.rfind(coverArtRoot, 0U) != 0U) { + if (const std::string coverArtRoot = startup::default_cover_art_cache_root(); coverArtRoot.empty() || path.size() <= coverArtRoot.size() || path.rfind(coverArtRoot, 0U) != 0U) { return {}; } @@ -1466,7 +1367,7 @@ namespace { return fileName.substr(0, fileName.size() - 4U); } - void clear_deleted_cover_art_flag(app::ClientState &state, const std::string &cacheKey) { + void clear_deleted_cover_art_flag(app::ClientState &state, std::string_view cacheKey) { if (cacheKey.empty()) { return; } @@ -1497,8 +1398,7 @@ namespace { return; } - std::string errorMessage; - if (!startup::delete_saved_file(update.savedFileDeletePath, &errorMessage)) { + if (std::string errorMessage; !startup::delete_saved_file(update.savedFileDeletePath, &errorMessage)) { state.statusMessage = errorMessage; logger.log(logging::LogLevel::warning, "storage", errorMessage); return; @@ -1520,8 +1420,7 @@ namespace { std::size_t deletedCoverArtCount = 0U; for (const std::string &cacheKey : update.deletedHostCoverArtCacheKeys) { - std::string errorMessage; - if (!startup::delete_cover_art(cacheKey, &errorMessage)) { + if (std::string errorMessage; !startup::delete_cover_art(cacheKey, &errorMessage)) { logger.log(logging::LogLevel::warning, "storage", errorMessage); } else { ++deletedCoverArtCount; @@ -1561,8 +1460,7 @@ namespace { return; } - std::string errorMessage; - if (!startup::delete_all_saved_files(&errorMessage)) { + if (std::string errorMessage; !startup::delete_all_saved_files(&errorMessage)) { state.statusMessage = errorMessage; logger.log(logging::LogLevel::warning, "storage", errorMessage); return; @@ -1617,8 +1515,7 @@ namespace { } network::HostPairingServerInfo serverInfo {}; - std::string errorMessage; - if (!network::query_server_info(address, port, clientIdentity, &serverInfo, &errorMessage)) { + if (std::string errorMessage; !network::query_server_info(address, port, clientIdentity, &serverInfo, &errorMessage)) { if (message != nullptr) { *message = std::move(errorMessage); } @@ -1725,11 +1622,14 @@ namespace { } bool pairing_task_is_active(const PairingTaskState &task) { - return task.activeAttempt != nullptr && task.activeAttempt->thread != nullptr && !task.activeAttempt->completed.load(); + 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(std::memory_order_acquire); + return attempt != nullptr && attempt->thread != nullptr && attempt->completed.load(); } void finalize_pairing_attempt(logging::Logger &logger, app::ClientState *state, std::unique_ptr attempt) { @@ -1778,7 +1678,7 @@ namespace { if (discardResult) { task->activeAttempt->discardResult.store(true); } - task->activeAttempt->cancelRequested.store(true, std::memory_order_release); + task->activeAttempt->cancelRequested.store(true); task->retiredAttempts.push_back(std::move(task->activeAttempt)); } @@ -1853,8 +1753,8 @@ namespace { return task.thread != nullptr && !task.completed.load(); } - int run_pairing_task(void *context) { - PairingAttemptState *task = static_cast(context); + 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; } @@ -1878,14 +1778,13 @@ namespace { false, identityError.empty() ? "Failed to generate a valid client pairing identity" : "Failed to generate a valid client pairing identity: " + identityError, }; - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } - const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity); - if (!saveResult.success) { + if (const startup::SaveClientIdentityResult saveResult = startup::save_client_identity(identity); !saveResult.success) { task->result = {false, false, saveResult.errorMessage}; - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } @@ -1917,7 +1816,7 @@ namespace { } task->activeAttempt->discardResult.store(true); - task->activeAttempt->cancelRequested.store(true, std::memory_order_release); + task->activeAttempt->cancelRequested.store(true); retire_active_pairing_attempt(task, true); state.statusMessage.clear(); logger.log(logging::LogLevel::info, "pairing", "Cancelled the in-flight pairing attempt after leaving the pairing screen"); @@ -1998,7 +1897,7 @@ namespace { host = app::selected_host(state); if (host == nullptr || host->pairingState != app::PairingState::paired) { - state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again from Sunshine before opening apps."; + state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again before opening apps."; logger.log(logging::LogLevel::warning, "apps", state.statusMessage); return; } @@ -2011,8 +1910,8 @@ namespace { logger.log(logging::LogLevel::warning, "apps", state.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); } - int run_host_probe_task(void *context) { - HostProbeTaskState *task = static_cast(context); + int run_host_probe_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature + auto *task = static_cast(context); if (task == nullptr) { return -1; } @@ -2030,12 +1929,12 @@ namespace { task->results.push_back(std::move(result)); } - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } void finish_host_probe_task_if_ready(logging::Logger &logger, app::ClientState &state, HostProbeTaskState *task) { - if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { + if (task == nullptr || task->thread == nullptr || !task->completed.load()) { return; } @@ -2118,8 +2017,7 @@ namespace { std::string reachabilityMessage; network::HostPairingServerInfo serverInfo {}; network::PairingIdentity clientIdentity {}; - const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; - if (!test_tcp_host_connection(update.pairingAddress, update.pairingPort, clientIdentityPointer, &reachabilityMessage, &serverInfo)) { + if (const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; !test_tcp_host_connection(update.pairingAddress, update.pairingPort, clientIdentityPointer, &reachabilityMessage, &serverInfo)) { for (app::HostRecord &host : state.hosts) { if (host.address == update.pairingAddress && app::effective_host_port(host.port) == app::effective_host_port(update.pairingPort)) { host.reachability = app::HostReachability::offline; @@ -2140,7 +2038,7 @@ namespace { persist_hosts(logger, state); } - std::unique_ptr attempt = std::make_unique(); + auto attempt = std::make_unique(); reset_pairing_attempt(attempt.get()); attempt->request = { update.pairingAddress, @@ -2163,13 +2061,13 @@ namespace { task->activeAttempt = std::move(attempt); state.pairingDraft.stage = app::PairingStage::in_progress; - state.pairingDraft.statusMessage = "The host is reachable. If Sunshine prompts for a PIN, enter the code shown below and keep this screen open for the result."; + state.pairingDraft.statusMessage = "The host is reachable. Enter the code shown below on the host and keep this screen open for the result."; state.statusMessage.clear(); logger.log(logging::LogLevel::info, "pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); } - int run_app_list_task(void *context) { - AppListTaskState *task = static_cast(context); + 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; } @@ -2179,7 +2077,7 @@ namespace { if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &errorMessage)) { task->success = false; task->message = errorMessage; - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } @@ -2188,8 +2086,8 @@ namespace { 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 Sunshine app list" : errorMessage; - task->completed.store(true, std::memory_order_release); + task->message = errorMessage.empty() ? "Failed to fetch the host app list" : errorMessage; + task->completed.store(true); return 0; } @@ -2212,13 +2110,13 @@ namespace { }); } - task->message = task->apps.empty() ? "Sunshine returned no launchable apps for this host" : "Loaded " + std::to_string(task->apps.size()) + " Sunshine app(s)"; - task->completed.store(true, std::memory_order_release); + 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(logging::Logger &logger, app::ClientState &state, AppListTaskState *task) { - if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { + if (task == nullptr || task->thread == nullptr || !task->completed.load()) { return; } @@ -2244,7 +2142,7 @@ namespace { if (success) { app::apply_app_list_result(state, address, port, std::move(apps), appListContentHash, true, message); - logger.log(logging::LogLevel::info, "apps", "Fetched Sunshine app list from " + address + ":" + std::to_string(serverInfo.httpPort)); + logger.log(logging::LogLevel::info, "apps", "Fetched app list from " + address + ":" + std::to_string(serverInfo.httpPort)); if (state.hostsDirty) { persist_hosts(logger, state); } @@ -2299,17 +2197,16 @@ namespace { } } - int run_app_art_task(void *context) { - AppArtTaskState *task = static_cast(context); + 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 {}; - std::string identityError; - if (!load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { + if (std::string identityError; !load_saved_pairing_identity_for_streaming(&clientIdentity, &identityError)) { task->failureCount = task->apps.size(); - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } @@ -2319,14 +2216,12 @@ namespace { } std::vector assetBytes; - std::string errorMessage; - if (!network::query_app_asset(task->address, task->port, &clientIdentity, appRecord.id, &assetBytes, &errorMessage)) { + if (std::string errorMessage; !network::query_app_asset(task->address, task->port, &clientIdentity, appRecord.id, &assetBytes, &errorMessage)) { ++task->failureCount; continue; } - const startup::SaveCoverArtResult saveResult = startup::save_cover_art(appRecord.boxArtCacheKey, assetBytes); - if (!saveResult.success) { + if (const startup::SaveCoverArtResult saveResult = startup::save_cover_art(appRecord.boxArtCacheKey, assetBytes); !saveResult.success) { ++task->failureCount; continue; } @@ -2334,12 +2229,12 @@ namespace { task->cachedAppIds.push_back(appRecord.id); } - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } void finish_app_art_task_if_ready(logging::Logger &logger, app::ClientState &state, AppArtTaskState *task, CoverArtTextureCache *textureCache) { - if (task == nullptr || task->thread == nullptr || !task->completed.load(std::memory_order_acquire)) { + if (task == nullptr || task->thread == nullptr || !task->completed.load()) { return; } @@ -2381,10 +2276,10 @@ namespace { return; } - const bool missingArt = std::any_of(host->apps.begin(), host->apps.end(), [](const app::HostAppRecord &appRecord) { - return !appRecord.boxArtCached && !appRecord.boxArtCacheKey.empty(); - }); - if (!missingArt) { + 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; } @@ -2425,7 +2320,7 @@ namespace { logger.log(logging::LogLevel::info, "logging", statusMessage + ": " + loadedLog.filePath); } - bool draw_shell( + bool draw_shell( // NOSONAR(cpp:S3776,cpp:S107) one-frame shell rendering is intentionally centralized to keep layout and failure handling consistent SDL_Renderer *renderer, const VIDEO_MODE &videoMode, unsigned long encoderSettings, @@ -2503,8 +2398,7 @@ namespace { const int pageTitleX = headerRect.x + (headerRect.w / 3); const int pageTitleY = headerRect.y + 18; - const bool renderedPageTitle = viewModel.screen == app::ScreenId::apps ? render_text_line_simple(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) : render_text_line(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); - if (!viewModel.pageTitle.empty() && !renderedPageTitle) { + if (const bool renderedPageTitle = viewModel.screen == app::ScreenId::apps ? render_text_line_simple(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) : render_text_line(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); !viewModel.pageTitle.empty() && !renderedPageTitle) { return false; } @@ -2513,8 +2407,7 @@ namespace { const int buttonHeight = std::max(40, headerRect.h / 2); int buttonX = headerRect.x + headerRect.w - 16 - ((buttonWidth + 12) * static_cast(viewModel.toolbarButtons.size())); for (const ui::ShellToolbarButton &button : viewModel.toolbarButtons) { - const SDL_Rect buttonRect {buttonX, headerRect.y + 18, buttonWidth, buttonHeight}; - if (!render_toolbar_button(renderer, bodyFont, smallFont, assetCache, button, buttonRect)) { + 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; @@ -2547,7 +2440,7 @@ namespace { const std::size_t endIndex = std::min(viewModel.hostTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.hostColumnCount); for (std::size_t index = startIndex; index < endIndex; ++index) { const int row = static_cast(index / viewModel.hostColumnCount) - viewport.startRow; - const int column = static_cast(index % viewModel.hostColumnCount); + const auto column = static_cast(index % viewModel.hostColumnCount); const SDL_Rect tileRect { gridRect.x + (column * (tileWidth + tileGap)), gridRect.y + (row * (tileHeight + tileGap)), @@ -2570,13 +2463,13 @@ namespace { if (!tile.iconAssetPath.empty()) { render_asset_icon(renderer, assetCache, tile.iconAssetPath, hostIconRect); } - const SDL_Rect nameRect { - tileRect.x + 8, - tileRect.y + tileRect.h - statusHeight - nameHeight - 10, - tileRect.w - 16, - nameHeight, - }; - if (!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)) { + 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 { @@ -2616,7 +2509,7 @@ namespace { const std::size_t endIndex = std::min(viewModel.appTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.appColumnCount); for (std::size_t index = startIndex; index < endIndex; ++index) { const int row = static_cast(index / viewModel.appColumnCount) - viewport.startRow; - const int column = static_cast(index % viewModel.appColumnCount); + const auto column = static_cast(index % viewModel.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), @@ -2624,7 +2517,7 @@ namespace { tileHeight, }; const ui::ShellAppTile &tile = viewModel.appTiles[index]; - if (!render_app_cover(renderer, smallFont, tile, tileRect, textureCache, assetCache)) { + if (!render_app_cover(renderer, smallFont, tile, tileRect, textureCache, assetCache)) { // NOSONAR(cpp:S134) app-grid rendering keeps per-tile failure handling inline with layout return false; } } @@ -2641,7 +2534,7 @@ namespace { int textHeight = 0; for (std::size_t index = 0; index < viewModel.bodyLines.size(); ++index) { textHeight += measure_wrapped_text_height(smallFont, viewModel.bodyLines[index], gridRect.w - 48); - if (index + 1U < viewModel.bodyLines.size()) { + if (index + 1U < viewModel.bodyLines.size()) { // NOSONAR(cpp:S134) empty-state text height is accumulated inline with layout calculation textHeight += lineGap; } } @@ -2712,7 +2605,7 @@ namespace { int bodyY = contentCard.y + bodyCardPadding; for (const std::string &line : viewModel.bodyLines) { int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentCard.x + bodyCardPadding, bodyY, contentCard.w - (bodyCardPadding * 2), &drawnHeight)) { + if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentCard.x + bodyCardPadding, bodyY, contentCard.w - (bodyCardPadding * 2), &drawnHeight)) { // NOSONAR(cpp:S134) settings-body rendering keeps layout failure handling local return false; } bodyY += drawnHeight + 8; @@ -2724,10 +2617,8 @@ namespace { return false; } - if (viewModel.notificationVisible && !viewModel.notification.message.empty()) { - if (!render_notification(renderer, bodyFont, smallFont, assetCache, viewModel.notification, screenWidth, footerRect.y, outerMargin)) { - return false; - } + if (viewModel.notificationVisible && !viewModel.notification.message.empty() && !render_notification(renderer, bodyFont, smallFont, assetCache, viewModel.notification, screenWidth, footerRect.y, outerMargin)) { + return false; } if (viewModel.overlayVisible) { @@ -2855,8 +2746,8 @@ namespace { const int buttonHeight = std::max(34, (buttonAreaHeight - (buttonGap * std::max(0, buttonRowCount - 1))) / buttonRowCount); for (std::size_t index = 0; index < viewModel.keypadModalButtons.size(); ++index) { - const int row = static_cast(index / viewModel.keypadModalColumnCount); - const int column = static_cast(index % viewModel.keypadModalColumnCount); + const auto row = static_cast(index / viewModel.keypadModalColumnCount); + const auto column = static_cast(index % viewModel.keypadModalColumnCount); const SDL_Rect buttonRect { modalRect.x + 16 + (column * (buttonWidth + buttonGap)), buttonAreaTop + (row * (buttonHeight + buttonGap)), @@ -2894,7 +2785,7 @@ namespace { namespace ui { - int run_shell(SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state, logging::Logger &logger) { + int run_shell(SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state, logging::Logger &logger) { // NOSONAR(cpp:S3776) shell loop owns all frame/update orchestration in one place by design if (window == nullptr) { return report_shell_failure(logger, "sdl", "Shell requires a valid SDL window"); } @@ -2985,8 +2876,7 @@ namespace ui { logger.log(logging::LogLevel::info, "app", "Entered interactive shell"); const auto draw_current_shell = [&]() { - const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); - if (draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { + if (const auto viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { return true; } @@ -2996,7 +2886,7 @@ namespace ui { return false; }; - const auto process_command = [&](input::UiCommand command) { + const auto process_command = [&](input::UiCommand command) { // NOSONAR(cpp:S1188) inline command pipeline keeps one-frame side effects adjacent to the shell loop if (command == input::UiCommand::none) { return; } @@ -3075,7 +2965,7 @@ namespace ui { state.shouldExit = true; break; case SDL_CONTROLLERDEVICEADDED: - if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { + if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing controller = SDL_GameControllerOpen(event.cdevice.which); if (controller != nullptr) { logger.log(logging::LogLevel::info, "input", "Controller connected"); @@ -3083,7 +2973,7 @@ namespace ui { } break; case SDL_CONTROLLERDEVICEREMOVED: - if (controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.cdevice.which)) { + if (controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing close_controller(controller); controller = nullptr; leftTriggerPressed = false; @@ -3100,7 +2990,7 @@ namespace ui { } break; case SDL_CONTROLLERBUTTONDOWN: - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A) { + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A) { // NOSONAR(cpp:S134) button state transitions stay explicit for controller input parity if (!controllerAPressed) { controllerAPressed = true; controllerAContextTriggered = false; @@ -3126,14 +3016,14 @@ namespace ui { } command = translate_controller_button(event.cbutton.button); } - if ( + if ( // NOSONAR(cpp:S134) exit-combo arming stays inline with the button state machine controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) ) { controllerExitComboArmed = true; } break; case SDL_CONTROLLERBUTTONUP: - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A && controllerAPressed) { + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A && controllerAPressed) { // NOSONAR(cpp:S134) button release handling stays explicit for controller input parity controllerAPressed = false; if (!controllerAContextTriggered) { command = input::UiCommand::activate; @@ -3165,14 +3055,14 @@ namespace ui { break; case SDL_CONTROLLERAXISMOTION: command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); - if (command == input::UiCommand::fast_previous_page) { + if (command == input::UiCommand::fast_previous_page) { // NOSONAR(cpp:S134) trigger repeat bookkeeping stays inline with translated command handling leftTriggerRepeatTick = SDL_GetTicks(); } else if (command == input::UiCommand::fast_next_page) { rightTriggerRepeatTick = SDL_GetTicks(); } break; case SDL_KEYDOWN: - if (event.key.repeat == 0) { + if (event.key.repeat == 0) { // NOSONAR(cpp:S134) keyboard translation stays inline with SDL event routing command = translate_keyboard_key(event.key.keysym.sym, event.key.keysym.mod); } break; @@ -3192,8 +3082,7 @@ namespace ui { start_app_list_task_if_needed(logger, state, &appListTask, backgroundTaskTick); start_app_art_task_if_needed(logger, state, &appArtTask); - const ui::ShellViewModel viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); - if (!draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { + if (const auto viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); !draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); running = false; break; diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index c760683..1681149 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -4,6 +4,7 @@ // standard includes #include #include +#include #include namespace { @@ -101,7 +102,7 @@ namespace { tiles.reserve(state.hosts.size()); for (std::size_t index = 0; index < state.hosts.size(); ++index) { const app::HostRecord &host = state.hosts[index]; - tiles.push_back({ + tiles.emplace_back(ui::ShellHostTile { host.address, host.displayName, host_tile_status(host), @@ -138,7 +139,7 @@ namespace { badgeLabel = "Hidden"; } - tiles.push_back({ + tiles.emplace_back(ui::ShellAppTile { std::to_string(appRecord.id), appRecord.name, detail, @@ -165,7 +166,28 @@ namespace { return state.addHostDraft.keypad.stagedInput.empty() && state.addHostDraft.activeField == app::AddHostField::port ? "default (47989)" : state.addHostDraft.keypad.stagedInput; } - return state.addHostDraft.activeField == app::AddHostField::address ? state.addHostDraft.addressInput : (state.addHostDraft.portInput.empty() ? "default (47989)" : state.addHostDraft.portInput); + 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) { @@ -174,7 +196,7 @@ namespace { std::vector buttons; buttons.reserve(labels.size()); for (std::size_t index = 0; index < labels.size(); ++index) { - buttons.push_back({ + buttons.emplace_back(ui::ShellModalButton { labels[index], true, state.addHostDraft.keypad.visible && index == state.addHostDraft.keypad.selectedButtonIndex, @@ -200,14 +222,14 @@ namespace { return lines; } - std::vector body_lines(const app::ClientState &state) { + std::vector body_lines(const app::ClientState &state) { // NOSONAR(cpp:S3776) screen-specific copy is kept local for render-model assembly switch (state.activeScreen) { case app::ScreenId::home: case app::ScreenId::hosts: if (state.hosts.empty()) { return { "No PCs have been added yet.", - "Use Add Host to save a Sunshine host manually.", + "Use Add Host to save a host manually.", "A Moonlight-style discovery grid now owns the home screen.", }; } @@ -249,10 +271,10 @@ namespace { }; if (!state.addHostDraft.validationMessage.empty()) { - lines.push_back("Validation: " + state.addHostDraft.validationMessage); + lines.emplace_back("Validation: " + state.addHostDraft.validationMessage); } if (!state.addHostDraft.connectionMessage.empty()) { - lines.push_back("Connection: " + state.addHostDraft.connectionMessage); + lines.emplace_back("Connection: " + state.addHostDraft.connectionMessage); } return lines; } @@ -262,43 +284,41 @@ namespace { std::string("Target host: ") + state.pairingDraft.targetAddress, }; if (state.pairingDraft.stage == app::PairingStage::idle) { - lines.push_back("Checking whether the host is reachable before showing a PIN."); + lines.emplace_back("Checking whether the host is reachable before showing a PIN."); } else { - lines.push_back(std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort)); + lines.emplace_back(std::string("Target port: ") + std::to_string(state.pairingDraft.targetPort)); if (!state.pairingDraft.generatedPin.empty()) { - lines.push_back(std::string("PIN: ") + app::current_pairing_pin(state)); - lines.push_back("Enter the PIN on the host only if Sunshine prompts for it."); + 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.push_back("Status: " + state.pairingDraft.statusMessage); + lines.emplace_back("Status: " + state.pairingDraft.statusMessage); } return lines; } case app::ScreenId::settings: { std::vector lines = { - std::string("Category: ") + (state.selectedSettingsCategory == app::SettingsCategory::logging ? "Logging" : state.selectedSettingsCategory == app::SettingsCategory::display ? "Display" : - state.selectedSettingsCategory == app::SettingsCategory::input ? "Input" : - "Reset"), + std::string("Category: ") + settings_category_label(state.selectedSettingsCategory), }; if (state.selectedSettingsCategory == app::SettingsCategory::logging) { lines.push_back(std::string("Log file: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); lines.push_back(std::string("Current logging level: ") + logging::to_string(state.loggingLevel)); - lines.push_back("Use View Log File to inspect persisted startup and applist diagnostics."); + lines.emplace_back("Use View Log File to inspect persisted startup and applist diagnostics."); } else if (state.selectedSettingsCategory == app::SettingsCategory::reset) { if (state.savedFiles.empty()) { - lines.push_back("Saved files: none found."); + lines.emplace_back("Saved files: none found."); return lines; } - lines.push_back("Saved files on disk:"); + lines.emplace_back("Saved files on disk:"); for (const startup::SavedFileEntry &savedFile : state.savedFiles) { lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); } } else if (state.selectedSettingsCategory == app::SettingsCategory::display) { - lines.push_back("Display options will be added here."); + lines.emplace_back("Display options will be added here."); } else { - lines.push_back("Input options will be added here."); + lines.emplace_back("Input options will be added here."); } return lines; } @@ -311,7 +331,7 @@ namespace { std::vector rows; const ui::MenuItem *selectedItem = state.menu.selected_item(); for (const ui::MenuItem &item : state.menu.items()) { - rows.push_back({ + rows.emplace_back(ui::ShellActionRow { item.id, item.label, item.enabled, @@ -326,7 +346,7 @@ namespace { std::vector rows; const ui::MenuItem *selectedItem = state.detailMenu.selected_item(); for (const ui::MenuItem &item : state.detailMenu.items()) { - rows.push_back({ + rows.emplace_back(ui::ShellActionRow { item.id, item.label, item.enabled, @@ -347,7 +367,7 @@ namespace { }; } - void fill_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { + void fill_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { // NOSONAR(cpp:S3776) modal rendering data stays centralized to keep state/view mapping explicit if (!state.modal.active()) { return; } @@ -376,10 +396,16 @@ namespace { { viewModel->modalTitle = "Host Details"; if (const app::HostRecord *host = app::selected_host(state); host != nullptr) { + const char *reachabilityLabel = "UNKNOWN"; + if (host->reachability == app::HostReachability::online) { + reachabilityLabel = "ONLINE"; + } else if (host->reachability == app::HostReachability::offline) { + reachabilityLabel = "OFFLINE"; + } + viewModel->modalLines = { "Name: " + host->displayName, - std::string("State: ") + (host->reachability == app::HostReachability::online ? "ONLINE" : host->reachability == app::HostReachability::offline ? "OFFLINE" : - "UNKNOWN"), + 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), @@ -440,23 +466,29 @@ namespace { case app::ScreenId::home: case app::ScreenId::hosts: { + std::string openLabel = "Pair"; + if (state.hostsFocusArea == 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", state.hostsFocusArea == app::HostsFocusArea::toolbar ? "Select" : (app::selected_host(state) != nullptr && app::selected_host(state)->pairingState == app::PairingState::paired ? "Open" : "Pair"), "icons\\button-a.svg", {}, true}, + {"open", std::move(openLabel), "icons\\button-a.svg", {}, true}, }; if (state.hostsFocusArea == app::HostsFocusArea::grid && app::selected_host(state) != nullptr) { - actions.push_back({"host-menu", "Host Menu", "icons\\button-y.svg", {}, false}); + actions.emplace_back(ui::ShellFooterAction {"host-menu", "Host Menu", "icons\\button-y.svg", {}, false}); } - actions.push_back({"exit", "Exit", "icons\\button-select.svg", "icons\\button-start.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.push_back({"launch", "Launch", "icons\\button-a.svg", {}, true}); - actions.push_back({"app-menu", "App Menu", "icons\\button-y.svg", {}, false}); + 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.push_back({"back", "Back", "icons\\button-b.svg", {}, false}); + actions.emplace_back(ui::ShellFooterAction {"back", "Back", "icons\\button-b.svg", {}, false}); return actions; } case app::ScreenId::add_host: @@ -490,7 +522,7 @@ namespace { namespace ui { - ShellViewModel build_shell_view_model( + ShellViewModel build_shell_view_model( // NOSONAR(cpp:S3776) this function intentionally assembles the complete render snapshot in one place const app::ClientState &state, const std::vector &logEntries, const std::vector &statsLines @@ -551,7 +583,7 @@ namespace ui { } if (clampedOffset > 0) { - viewModel.overlayLines.insert(viewModel.overlayLines.begin(), "Showing earlier log entries"); + viewModel.overlayLines.emplace(viewModel.overlayLines.begin(), "Showing earlier log entries"); } } diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h index e8c23c3..036535f 100644 --- a/src/ui/shell_view.h +++ b/src/ui/shell_view.h @@ -94,7 +94,7 @@ namespace ui { /** * @brief Render-ready shell state derived from the app model. */ - struct ShellViewModel { + struct ShellViewModel { ///< NOSONAR(cpp:S1820) single-frame render snapshot intentionally groups all shell sections app::ScreenId screen = app::ScreenId::hosts; ///< Active screen being rendered. std::string title; ///< Shell-wide title. std::string pageTitle; ///< Primary page heading. diff --git a/tests/support/filesystem_test_utils.h b/tests/support/filesystem_test_utils.h index 22455fc..9b857fe 100644 --- a/tests/support/filesystem_test_utils.h +++ b/tests/support/filesystem_test_utils.h @@ -25,7 +25,7 @@ namespace test_support { std::filesystem::remove_all(path, error); } - inline bool create_directory(const std::string &path) { + 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); } diff --git a/tests/support/network_test_constants.h b/tests/support/network_test_constants.h new file mode 100644 index 0000000..9f16c21 --- /dev/null +++ b/tests/support/network_test_constants.h @@ -0,0 +1,72 @@ +#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 index 7aa4e71..1c188e6 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -3,6 +3,10 @@ // lib includes #include +#include + +// test includes +#include "tests/support/network_test_constants.h" namespace { @@ -22,8 +26,8 @@ namespace { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + {"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)"); @@ -115,7 +119,7 @@ namespace { app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); - state.addHostDraft.addressInput = "193.168.1.10"; + 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); @@ -123,9 +127,9 @@ namespace { EXPECT_TRUE(update.screenChanged); EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); ASSERT_EQ(state.hosts.size(), 1U); - EXPECT_EQ(state.hosts.front().address, "193.168.1.10"); - EXPECT_EQ(state.hosts.front().port, 48000); - EXPECT_EQ(state.hosts.front().displayName, "Host 193.168.1.10"); + 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.selectedHostIndex, 0U); } @@ -135,7 +139,7 @@ namespace { state.hostsFocusArea = app::HostsFocusArea::toolbar; state.selectedToolbarButtonIndex = 2U; app::handle_command(state, input::UiCommand::activate); - state.addHostDraft.addressInput = "192.168.0.10"; + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; state.menu.select_item_by_id("save-host"); app::handle_command(state, input::UiCommand::activate); @@ -148,7 +152,7 @@ namespace { EXPECT_TRUE(update.screenChanged); ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); - state.addHostDraft.addressInput = "192.168.0.10"; + 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); @@ -164,7 +168,7 @@ namespace { TEST(ClientStateTest, SelectingAnUnpairedHostStartsPairing) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired}, }); app::handle_command(state, input::UiCommand::move_down); @@ -172,7 +176,7 @@ namespace { EXPECT_TRUE(update.screenChanged); EXPECT_TRUE(update.pairingRequested); - EXPECT_EQ(update.pairingAddress, "192.168.1.20"); + EXPECT_EQ(update.pairingAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); EXPECT_EQ(update.pairingPort, app::DEFAULT_HOST_PORT); EXPECT_TRUE(app::is_valid_pairing_pin(update.pairingPin)); EXPECT_EQ(state.activeScreen, app::ScreenId::pair_host); @@ -181,7 +185,7 @@ namespace { TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::offline}, + {"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); @@ -196,7 +200,7 @@ namespace { TEST(ClientStateTest, BackingOutOfThePairingScreenRequestsPairingCancellation) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::online}, + {"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); @@ -214,10 +218,10 @@ namespace { TEST(ClientStateTest, HostGridNavigationMatchesTheRenderedThreeColumnLayout) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + {"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.selectedHostIndex, 0U); @@ -232,10 +236,10 @@ namespace { TEST(ClientStateTest, HostGridCanMoveDownIntoAPartialNextRowFromAnyColumn) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -251,11 +255,11 @@ namespace { TEST(ClientStateTest, HostGridWrapsRightToTheNextRowAndLeftToThePreviousRow) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Host A", "192.168.0.10", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host B", "192.168.0.11", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host C", "192.168.0.12", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host D", "192.168.0.13", 0, app::PairingState::paired, app::HostReachability::online}, - {"Host E", "192.168.0.14", 0, app::PairingState::paired, app::HostReachability::online}, + {"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.selectedHostIndex = 2U; @@ -269,7 +273,7 @@ namespace { TEST(ClientStateTest, SelectingAPairedHostOpensTheAppsScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -284,7 +288,7 @@ namespace { TEST(ClientStateTest, SelectingAnOfflinePairedHostDoesNotOpenTheAppsScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::offline}, + {"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); @@ -299,7 +303,7 @@ namespace { TEST(ClientStateTest, AppliesFetchedAppListsAndPreservesPerAppFlags) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -309,13 +313,13 @@ namespace { {"Steam", 101, false, true, true, "cached-steam", true, false}, }; - app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"Steam", 101, true, false, false, "cached-steam", false, false}, - {"Desktop", 102, false, false, false, "cached-desktop", 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 Sunshine app(s)"); + "Loaded 2 app(s)"); ASSERT_EQ(state.hosts.front().apps.size(), 2U); EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); @@ -330,19 +334,19 @@ namespace { TEST(ClientStateTest, AppliesFetchedAppListsWhenBackgroundTasksReportTheResolvedHttpPort) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::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.front().resolvedHttpPort = 47989; + state.hosts.front().resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; app::handle_command(state, input::UiCommand::move_down); app::handle_command(state, input::UiCommand::activate); - app::apply_app_list_result(state, "10.0.0.25", 47989, { - {"Steam", 101, true, false, false, "steam-cover", true, 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 Sunshine app(s)"); + "Loaded 1 app(s)"); ASSERT_EQ(state.hosts.front().apps.size(), 1U); EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); @@ -353,15 +357,15 @@ namespace { TEST(ClientStateTest, MarksCoverArtCachedWhenBackgroundTasksReportTheResolvedHttpsPort) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::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.front().httpsPort = 47990; + state.hosts.front().httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; state.hosts.front().apps = { {"Steam", 101, true, false, false, "steam-cover", false, false}, }; - app::mark_cover_art_cached(state, "10.0.0.25", 47990, 101); + app::mark_cover_art_cached(state, test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortResolvedHttps], 101); ASSERT_EQ(state.hosts.front().apps.size(), 1U); EXPECT_TRUE(state.hosts.front().apps.front().boxArtCached); @@ -370,7 +374,7 @@ namespace { TEST(ClientStateTest, FailedRefreshKeepsCachedAppsAvailable) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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)); @@ -379,7 +383,7 @@ namespace { }; state.hosts.front().appListContentHash = 0x1234U; - app::apply_app_list_result(state, "10.0.0.25", 48000, {}, 0, false, "Timed out while refreshing apps"); + 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().appListState, app::HostAppListState::ready); ASSERT_EQ(state.hosts.front().apps.size(), 1U); @@ -390,7 +394,7 @@ namespace { TEST(ClientStateTest, ExplicitUnpairedAppListFailureMarksTheHostAsNotPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -398,12 +402,12 @@ namespace { app::apply_app_list_result( state, - "10.0.0.25", - 48000, + 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 from Sunshine." + "The host reports that this client is no longer paired. Pair the host again." ); EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::not_paired); @@ -413,7 +417,7 @@ namespace { TEST(ClientStateTest, ExplicitUnpairedAppListFailureClearsCachedApps) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -425,8 +429,8 @@ namespace { app::apply_app_list_result( state, - "10.0.0.25", - 48000, + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], {}, 0, false, @@ -441,13 +445,13 @@ namespace { TEST(ClientStateTest, TransientAppListFailuresDoNotMarkTheHostAsNotPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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, "10.0.0.25", 48000, {}, 0, false, "Timed out while refreshing apps"); + 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); } @@ -455,20 +459,20 @@ namespace { TEST(ClientStateTest, AppGridWrapsHorizontallyAndFindsTheClosestItemInPartialRows) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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, "10.0.0.25", 48000, { - {"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}, - }, + 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 Sunshine app(s)"); + "Loaded 5 app(s)"); state.selectedAppIndex = 3U; app::handle_command(state, input::UiCommand::move_right); @@ -514,7 +518,7 @@ namespace { TEST(ClientStateTest, LeavingTheAppsScreenClearsTransientAppStatusAndIgnoresLaterRefreshText) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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)); @@ -526,9 +530,9 @@ namespace { EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); EXPECT_TRUE(state.statusMessage.empty()); - app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"Steam", 101, false, false, false, "steam-cover", false, false}, - }, + 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"); @@ -593,8 +597,8 @@ namespace { TEST(ClientStateTest, HostContextMenuCanDeleteTheSelectedHost) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired}, + {"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); @@ -611,7 +615,7 @@ namespace { TEST(ClientStateTest, DeletingAPairedHostRequestsPersistentCleanupAndMarksItForManualRePairing) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::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.front().apps = { {"Steam", 101, false, false, false, "steam-cover", true, false}, @@ -629,19 +633,19 @@ namespace { EXPECT_TRUE(update.hostsChanged); EXPECT_TRUE(update.hostDeleteCleanupRequested); EXPECT_TRUE(update.deletedHostWasPaired); - EXPECT_EQ(update.deletedHostAddress, "10.0.0.25"); - EXPECT_EQ(update.deletedHostPort, 48000); + EXPECT_EQ(update.deletedHostAddress, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_EQ(update.deletedHostPort, test_support::kTestPorts[test_support::kPortDefaultHost]); ASSERT_EQ(update.deletedHostCoverArtCacheKeys.size(), 2U); EXPECT_EQ(update.deletedHostCoverArtCacheKeys[0], "steam-cover"); EXPECT_EQ(update.deletedHostCoverArtCacheKeys[1], "desktop-cover"); EXPECT_TRUE(state.hosts.empty()); - EXPECT_TRUE(app::host_requires_manual_pairing(state, "10.0.0.25", 48000)); + 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", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -651,14 +655,14 @@ namespace { const app::AppUpdate deleteUpdate = app::handle_command(state, input::UiCommand::activate); ASSERT_TRUE(deleteUpdate.hostDeleteCleanupRequested); - ASSERT_TRUE(app::host_requires_manual_pairing(state, "10.0.0.25", 48000)); + 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", "10.0.0.25", 48000, app::PairingState::not_paired, app::HostReachability::online}, + {"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, "10.0.0.25", 48000, true, "Paired successfully")); - EXPECT_FALSE(app::host_requires_manual_pairing(state, "10.0.0.25", 48000)); + 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); } @@ -666,58 +670,58 @@ namespace { app::ClientState state = app::create_initial_state(); app::handle_command(state, input::UiCommand::activate); - state.addHostDraft.addressInput = "192.168.0.10"; + 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.connectionTestRequested); - EXPECT_EQ(update.connectionTestAddress, "192.168.0.10"); - EXPECT_EQ(update.connectionTestPort, 48000); - EXPECT_EQ(state.statusMessage, "Testing connection to 192.168.0.10:48000..."); + EXPECT_EQ(update.connectionTestAddress, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); + EXPECT_EQ(update.connectionTestPort, test_support::kTestPorts[test_support::kPortDefaultHost]); + EXPECT_EQ(state.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 = "192.168.0.10"; + state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); EXPECT_FALSE(update.screenChanged); EXPECT_TRUE(state.addHostDraft.keypad.visible); EXPECT_EQ(state.addHostDraft.keypad.selectedButtonIndex, 0U); - EXPECT_EQ(state.addHostDraft.keypad.stagedInput, "192.168.0.10"); + 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, "192.168.0.101"); + 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, "192.168.0.10"); + 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, "192.168.0.10"); + 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, "192.168.0.101"); + 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, "192.168.0.101"); + EXPECT_EQ(state.addHostDraft.addressInput, std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + "1"); } TEST(ClientStateTest, SuccessfulPairingReturnsToHostsAndKeepsTheHostSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::not_paired, app::HostReachability::online}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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.selectedHostIndex = 1U; - EXPECT_TRUE(app::apply_pairing_result(state, "192.168.1.20", app::DEFAULT_HOST_PORT, true, "Paired successfully")); + EXPECT_TRUE(app::apply_pairing_result(state, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], app::DEFAULT_HOST_PORT, true, "Paired successfully")); EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); EXPECT_EQ(state.selectedHostIndex, 0U); diff --git a/tests/unit/app/host_records_test.cpp b/tests/unit/app/host_records_test.cpp index e8b4c94..588df46 100644 --- a/tests/unit/app/host_records_test.cpp +++ b/tests/unit/app/host_records_test.cpp @@ -7,6 +7,9 @@ // lib includes #include +// test includes +#include "tests/support/network_test_constants.h" + namespace { TEST(HostRecordsTest, NormalizesAndValidatesIpv4Addresses) { @@ -19,7 +22,7 @@ namespace { TEST(HostRecordsTest, ValidatesRecordsBeforeTheyAreSaved) { const app::HostRecord validRecord { "Living Room PC", - "192.168.1.20", + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::not_paired, }; @@ -40,8 +43,8 @@ namespace { TEST(HostRecordsTest, SerializesAndParsesRoundTripHostLists) { const std::vector records = { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::paired}, - {"Steam Deck Dock", "10.0.0.15", 48000, app::PairingState::not_paired}, + {"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); @@ -50,12 +53,12 @@ namespace { 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, "192.168.1.20"); + 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, "10.0.0.15"); - EXPECT_EQ(parsedRecords.records[1].port, 48000); + 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); } @@ -69,10 +72,10 @@ namespace { const app::ParseHostRecordsResult parsedRecords = app::parse_host_records(serializedRecords); ASSERT_EQ(parsedRecords.records.size(), 2U); - EXPECT_EQ(parsedRecords.records[0].address, "192.168.1.20"); + 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, "10.0.0.25"); - EXPECT_EQ(parsedRecords.records[1].port, 48000); + 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); @@ -80,12 +83,12 @@ namespace { TEST(HostRecordsTest, DetectsDuplicateSavedAddresses) { const std::vector records = { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::paired}, + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, }; - EXPECT_TRUE(app::contains_host_address(records, "192.168.1.20")); - EXPECT_FALSE(app::contains_host_address(records, "192.168.1.21")); - EXPECT_FALSE(app::contains_host_address(records, "192.168.1.20", 48000)); + 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, ParsesPortOverridesAndFallsBackToTheDefaultPort) { @@ -96,8 +99,8 @@ namespace { EXPECT_EQ(app::effective_host_port(parsedPort), app::DEFAULT_HOST_PORT); EXPECT_TRUE(app::try_parse_host_port("48000", &parsedPort)); - EXPECT_EQ(parsedPort, 48000); - EXPECT_EQ(app::effective_host_port(parsedPort), 48000); + 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)); diff --git a/tests/unit/app/pairing_flow_test.cpp b/tests/unit/app/pairing_flow_test.cpp index aa50ad4..466a6aa 100644 --- a/tests/unit/app/pairing_flow_test.cpp +++ b/tests/unit/app/pairing_flow_test.cpp @@ -4,13 +4,20 @@ // lib includes #include +// test includes +#include "tests/support/network_test_constants.h" + namespace { TEST(PairingFlowTest, CreatesAFreshPairingDraftWithTheDefaultPin) { - const app::PairingDraft draft = app::create_pairing_draft("192.168.1.20", 47984, "4821"); + 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, "192.168.1.20"); - EXPECT_EQ(draft.targetPort, 47984); + 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::idle); EXPECT_EQ(draft.generatedPin, "4821"); EXPECT_EQ(draft.statusMessage, "Checking whether the host is reachable before pairing begins."); diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index 4e0f3f9..f670b62 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -1,8 +1,15 @@ +// test header include #include "src/network/host_pairing.h" +// standard includes #include + +// lib includes #include +// test includes +#include "tests/support/network_test_constants.h" + namespace { TEST(HostPairingTest, CreatesAValidClientIdentity) { @@ -16,14 +23,29 @@ namespace { 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, CancelledPairingReturnsImmediatelyBeforeStartingTheHandshake) { const network::PairingIdentity identity = network::create_pairing_identity(); ASSERT_TRUE(network::is_valid_pairing_identity(identity)); - std::atomic cancelRequested = true; + std::atomic cancelRequested {true}; const network::HostPairingResult result = network::pair_host({ - "192.168.1.20", - 47984, + test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], + test_support::kTestPorts[test_support::kPortPairing], "1234", "MoonlightXboxOG", identity, @@ -41,8 +63,12 @@ namespace { "Sunshine-PC" "7.1.431.0" "host-uuid-123" - "192.168.1.25" - "203.0.113.7" + "" + + 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" @@ -54,16 +80,16 @@ namespace { network::HostPairingServerInfo serverInfo {}; std::string errorMessage; - ASSERT_TRUE(network::parse_server_info_response(xml, 47984, &serverInfo, &errorMessage)) << 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, 47989); - EXPECT_EQ(serverInfo.httpsPort, 47990); + 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, "192.168.1.25"); - EXPECT_EQ(serverInfo.localAddress, "192.168.1.25"); - EXPECT_EQ(serverInfo.remoteAddress, "203.0.113.7"); + 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); @@ -73,7 +99,7 @@ namespace { network::HostPairingServerInfo serverInfo {}; std::string errorMessage; - EXPECT_FALSE(network::parse_server_info_response("47990", 47984, &serverInfo, &errorMessage)); + EXPECT_FALSE(network::parse_server_info_response("47990", test_support::kTestPorts[test_support::kPortPairing], &serverInfo, &errorMessage)); EXPECT_FALSE(errorMessage.empty()); } @@ -83,8 +109,12 @@ namespace { "Sunshine-PC" "7.1.431.0" "host-uuid-123" - "127.0.0.1" - "192.168.0.50" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpLoopback]) + + "" + "" + + std::string(test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]) + + "" "47990" "1" ""; @@ -92,21 +122,24 @@ namespace { network::HostPairingServerInfo serverInfo {}; std::string errorMessage; - ASSERT_TRUE(network::parse_server_info_response(xml, 47984, &serverInfo, &errorMessage)) << errorMessage; - EXPECT_EQ(serverInfo.activeAddress, "127.0.0.1"); - EXPECT_EQ(network::resolve_reachable_address("10.0.2.2", serverInfo), "10.0.2.2"); + 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 = "192.168.0.50"; - serverInfo.localAddress = "192.168.0.51"; - serverInfo.remoteAddress = "203.0.113.9"; + 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), "192.168.0.50"); + EXPECT_EQ(network::resolve_reachable_address({}, serverInfo), test_support::kTestIpv4Addresses[test_support::kIpExternalFallback]); } - TEST(HostPairingTest, ParsesSunshineAppLists) { + TEST(HostPairingTest, ParsesHostAppLists) { const std::string xml = "" "Steam1011" @@ -135,12 +168,8 @@ namespace { EXPECT_FALSE(errorMessage.empty()); } - TEST(HostPairingTest, ParsesAttributeBasedSunshineAppLists) { - const std::string xml = - "" - "" - ""; + TEST(HostPairingTest, ParsesAttributeBasedHostAppLists) { + const std::string xml = R"()"; std::vector apps; std::string errorMessage; @@ -156,11 +185,7 @@ namespace { } TEST(HostPairingTest, ParsesAlternateXmlGameElementsInAppLists) { - const std::string xml = - "" - "Steam301false" - "" - ""; + const std::string xml = R"(Steam301false)"; std::vector apps; std::string errorMessage; @@ -176,13 +201,7 @@ namespace { } TEST(HostPairingTest, ParsesJsonAppLists) { - const std::string json = - "{" - "\"apps\":[" - "{\"name\":\"Steam\",\"id\":401,\"hdrSupported\":true}," - "{\"title\":\"Desktop\",\"appid\":\"402\",\"hidden\":false}" - "]" - "}"; + const std::string json = R"({"apps":[{"name":"Steam","id":401,"hdrSupported":true},{"title":"Desktop","appid":"402","hidden":false}]})"; std::vector apps; std::string errorMessage; @@ -216,7 +235,7 @@ namespace { } TEST(HostPairingTest, DetectsExplicitUnpairedClientErrors) { - EXPECT_TRUE(network::error_indicates_unpaired_client("The host reports that this client is no longer paired. Pair the host again from Sunshine.")); + 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_FALSE(network::error_indicates_unpaired_client("Timed out while refreshing apps")); diff --git a/tests/unit/network/runtime_network_test.cpp b/tests/unit/network/runtime_network_test.cpp index fbd0458..f8b0419 100644 --- a/tests/unit/network/runtime_network_test.cpp +++ b/tests/unit/network/runtime_network_test.cpp @@ -1,7 +1,15 @@ +// 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) { @@ -17,18 +25,18 @@ namespace { true, 0, "nxdk networking initialized successfully", - "192.168.0.42", - "255.255.255.0", - "192.168.0.1", + 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: 192.168.0.42"); - EXPECT_EQ(lines[2], "Subnet mask: 255.255.255.0"); - EXPECT_EQ(lines[3], "Gateway: 192.168.0.1"); + 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"); } @@ -56,15 +64,15 @@ namespace { true, 0, "nxdk networking initialized successfully", - "10.0.2.15", - "255.255.255.0", - "10.0.2.2", + 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: 10.0.2.2"); + EXPECT_EQ(lines[3], "Gateway: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpRuntimeDhcpGateway])); EXPECT_EQ(lines[4], "Initialization code: 0"); } diff --git a/tests/unit/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp index c17dab0..c10e0a6 100644 --- a/tests/unit/startup/client_identity_storage_test.cpp +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -12,7 +12,7 @@ namespace { - class ClientIdentityStorageTest: public ::testing::Test { + 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")); diff --git a/tests/unit/startup/cover_art_cache_test.cpp b/tests/unit/startup/cover_art_cache_test.cpp index 9565f8c..531bb0f 100644 --- a/tests/unit/startup/cover_art_cache_test.cpp +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -2,7 +2,6 @@ #include "src/startup/cover_art_cache.h" // standard includes -#include #include // lib includes @@ -10,10 +9,11 @@ // test includes #include "tests/support/filesystem_test_utils.h" +#include "tests/support/network_test_constants.h" namespace { - class CoverArtCacheTest: public ::testing::Test { + class CoverArtCacheTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest protected: void TearDown() override { test_support::remove_if_present(testFilePath); @@ -21,7 +21,11 @@ namespace { } std::string testDirectory = "cover-art-cache-test"; - std::string cacheKey = startup::build_cover_art_cache_key("host-uuid-123", "192.168.0.10", 42); + 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"); }; diff --git a/tests/unit/startup/host_storage_test.cpp b/tests/unit/startup/host_storage_test.cpp index 25961fc..869e012 100644 --- a/tests/unit/startup/host_storage_test.cpp +++ b/tests/unit/startup/host_storage_test.cpp @@ -3,6 +3,7 @@ // standard includes #include +#include #include // lib includes @@ -10,10 +11,11 @@ // test includes #include "tests/support/filesystem_test_utils.h" +#include "tests/support/network_test_constants.h" namespace { - class HostStorageTest: public ::testing::Test { + class HostStorageTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest protected: void TearDown() override { test_support::remove_if_present(nestedFilePath); @@ -37,8 +39,8 @@ namespace { TEST_F(HostStorageTest, SavesAndReloadsHostRecords) { const std::vector hosts = { - {"Living Room PC", "192.168.1.20", 0, app::PairingState::paired}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::not_paired}, + {"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); @@ -49,13 +51,13 @@ namespace { 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, "10.0.0.25"); - EXPECT_EQ(loadResult.hosts[1].port, 48000); + 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", "192.168.1.20", 0, app::PairingState::paired}, + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpLivingRoom], 0, app::PairingState::paired}, }; const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hosts, nestedFilePath); @@ -70,16 +72,17 @@ namespace { TEST_F(HostStorageTest, SurfacesParseWarningsButKeepsValidHosts) { FILE *file = std::fopen(testFilePath.c_str(), "wb"); ASSERT_NE(file, nullptr); - const char fileContent[] = - "Living Room PC\t192.168.1.20\t\tpaired\n" + const std::string fileContent = + "Living Room PC\t" + std::string(test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]) + + "\t\tpaired\n" "Broken Host\tnot-an-ip\t\tnot_paired\n"; - ASSERT_EQ(std::fwrite(fileContent, 1, sizeof(fileContent) - 1, file), sizeof(fileContent) - 1); + 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, "192.168.1.20"); + 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); } diff --git a/tests/unit/startup/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp index 98db60d..9c0d6e5 100644 --- a/tests/unit/startup/saved_files_test.cpp +++ b/tests/unit/startup/saved_files_test.cpp @@ -20,7 +20,7 @@ namespace { ASSERT_EQ(std::fclose(file), 0); } - class SavedFilesTest: public ::testing::Test { + class SavedFilesTest: public ::testing::Test { // NOSONAR(cpp:S3656) protected members are required by gtest protected: std::string testDirectory = "saved-files-test"; std::string hostStoragePath = test_support::join_path(testDirectory, "moonlight-hosts.tsv"); diff --git a/tests/unit/ui/menu_model_test.cpp b/tests/unit/ui/menu_model_test.cpp index 604fca2..6b06333 100644 --- a/tests/unit/ui/menu_model_test.cpp +++ b/tests/unit/ui/menu_model_test.cpp @@ -81,4 +81,18 @@ namespace { 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 index 6b6f881..759932d 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -1,8 +1,16 @@ +// test header include #include "src/ui/shell_view.h" -#include +// standard includes +#include #include +// lib inclues +#include + +// test includes +#include "tests/support/network_test_constants.h" + namespace { TEST(ShellViewTest, BuildsHostsScreenContentFromTheInitialState) { @@ -30,8 +38,8 @@ namespace { TEST(ShellViewTest, ShowsSavedHostsAsTiles) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::not_paired, app::HostReachability::offline}, - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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); @@ -53,7 +61,7 @@ namespace { TEST(ShellViewTest, HidesHostMenuFooterActionWhenToolbarIsSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online}, + {"Living Room PC", test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, }); state.hostsFocusArea = app::HostsFocusArea::toolbar; @@ -68,16 +76,18 @@ namespace { 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 192.168.0.10:48000"; + 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.pageTitle, "Add Host"); ASSERT_GE(viewModel.bodyLines.size(), 4U); EXPECT_EQ(viewModel.bodyLines[0], "Manual host entry with a keypad modal."); - EXPECT_EQ(viewModel.bodyLines[1], "Address: 192.168.0.10"); + EXPECT_EQ(viewModel.bodyLines[1], "Address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); EXPECT_EQ(viewModel.bodyLines[2], "Port: 48000"); EXPECT_EQ(viewModel.bodyLines[3], "Press A to edit either field with the keypad modal."); ASSERT_EQ(viewModel.footerActions.size(), 2U); @@ -151,17 +161,17 @@ namespace { TEST(ShellViewTest, BuildsTheAppsPageForASelectedPairedHost) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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.front().runningGameId = 101; - app::apply_app_list_result(state, "10.0.0.25", 48000, { - {"Steam", 101, true, false, false, "steam-cover", true, false}, - {"Desktop", 102, false, false, false, "desktop-cover", false, 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}, + {"Desktop", 102, false, false, false, "desktop-cover", false, false}, + }, 0x4242U, true, - "Loaded 2 Sunshine app(s)"); + "Loaded 2 app(s)"); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); @@ -184,14 +194,14 @@ namespace { TEST(ShellViewTest, HidesCachedAppTilesWhenTheSelectedHostIsNoLongerPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::not_paired, app::HostReachability::online}, + {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::not_paired, app::HostReachability::online}, }); state.activeScreen = app::ScreenId::apps; state.hosts.front().apps = { {"Steam", 101, false, false, false, "steam-cover", true, false}, }; state.hosts.front().appListState = app::HostAppListState::failed; - state.hosts.front().appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again from Sunshine."; + state.hosts.front().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, {}); @@ -203,7 +213,7 @@ namespace { TEST(ShellViewTest, SuppressesTransientAppsLoadingTextAndNotifications) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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.statusMessage = "Loading apps for Office PC..."; @@ -217,7 +227,7 @@ namespace { TEST(ShellViewTest, ShowsOnlyBackOnAppsScreenWhenNoVisibleAppIsSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Office PC", "10.0.0.25", 48000, app::PairingState::paired, app::HostReachability::online}, + {"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)); @@ -230,7 +240,7 @@ namespace { TEST(ShellViewTest, BuildsHostDetailsModalContent) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { - {"Living Room PC", "192.168.0.10", 48000, app::PairingState::paired, app::HostReachability::online, "192.168.0.10", "uuid-123", "192.168.0.10", "203.0.113.7", {}, "192.168.0.10", "00:11:22:33:44:55", 47990, 0}, + {"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); @@ -246,7 +256,7 @@ namespace { ASSERT_GE(viewModel.modalLines.size(), 5U); EXPECT_EQ(viewModel.modalLines[0], "Name: Living Room PC"); EXPECT_EQ(viewModel.modalLines[1], "State: ONLINE"); - EXPECT_EQ(viewModel.modalLines[2], "Active Address: 192.168.0.10"); + EXPECT_EQ(viewModel.modalLines[2], "Active Address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); } TEST(ShellViewTest, BuildsDedicatedLogViewerModalState) { @@ -308,12 +318,18 @@ namespace { TEST(ShellViewTest, HidesThePairingPinUntilReachabilityHasBeenConfirmed) { app::ClientState state = app::create_initial_state(); state.activeScreen = app::ScreenId::pair_host; - state.pairingDraft = {"192.168.0.10", 47984, "1234", app::PairingStage::idle, "Checking whether the host is reachable before pairing begins."}; + state.pairingDraft = { + test_support::kTestIpv4Addresses[test_support::kIpHostGridA], + test_support::kTestPorts[test_support::kPortPairing], + "1234", + app::PairingStage::idle, + "Checking whether the host is reachable before pairing begins." + }; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); ASSERT_GE(viewModel.bodyLines.size(), 2U); - EXPECT_EQ(viewModel.bodyLines[0], "Target host: 192.168.0.10"); + EXPECT_EQ(viewModel.bodyLines[0], "Target host: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); EXPECT_EQ(viewModel.bodyLines[1], "Checking whether the host is reachable before showing a PIN."); for (const std::string &line : viewModel.bodyLines) { EXPECT_EQ(line.find("PIN:"), std::string::npos); From 9c21433be5859bb5a909aa0673857cec5ac02503 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 7 Apr 2026 21:26:16 -0400 Subject: [PATCH 12/35] Enhance controller navigation and keypad UI Refactor add-host keypad handling and improve controller navigation/UI behavior. - Replace ad-hoc keypad button list with fixed character arrays and AddHostKeypadLayout; add helper to get selected keypad character and robust wrapping navigation across rows. - Add GamepadAxisDirection enum and mapping for axis directions to UI commands; support left-stick navigation with hysteresis and held-repeat timing. - Rework SDL event loop to use a short wait timeout, process events, and poll controller navigation when idle; add per-direction hold state and repeat/seeding logic; avoid jitter and require neutral stick when non-navigation commands occur. - Introduce KeypadModalLayoutCache to cache modal text measurements and reduce redraws; optimize modal rendering and drawing conditions for keypad modal. - Update shell view text and modal footer actions to reflect new controls and improve help messaging. - Simplify host_pairing: remove legacy JSON parsing helpers and SHA-1 legacy support, force SHA-256 pairing digest, and adjust digest/key derivation APIs; return an explicit error when applist is not XML. - Minor input and UI tweaks (constants, thresholds, repeat timings) and corresponding unit test updates. These changes improve controller UX (left-stick support, repeat behavior), simplify keypad logic, and remove legacy pairing code paths. --- .github/copilot-instructions.md | 11 + src/app/client_state.cpp | 92 ++- src/input/navigation_input.cpp | 15 + src/input/navigation_input.h | 18 + src/network/host_pairing.cpp | 382 +-------- src/ui/shell_screen.cpp | 889 ++++++++++++++++++--- src/ui/shell_view.cpp | 21 +- tests/unit/app/client_state_test.cpp | 44 + tests/unit/input/navigation_input_test.cpp | 7 + tests/unit/network/host_pairing_test.cpp | 14 +- tests/unit/ui/shell_view_test.cpp | 19 + 11 files changed, 973 insertions(+), 539 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 03f415d..d5a4722 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,6 +13,17 @@ 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/src/app/client_state.cpp b/src/app/client_state.cpp index c4892fa..8784f8b 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -5,6 +5,7 @@ #include "src/network/host_pairing.h" #include +#include #include #include #include @@ -21,17 +22,46 @@ namespace { 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:"; - - struct AddHostKeypadButton { - char character; + 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. }; - std::vector build_add_host_keypad_buttons(const app::ClientState &state) { + /** + * @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 {{'1'}, {'2'}, {'3'}, {'4'}, {'5'}, {'6'}, {'7'}, {'8'}, {'9'}, {'.'}, {'0'}}; + 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; } - return {{'1'}, {'2'}, {'3'}, {'4'}, {'5'}, {'6'}, {'7'}, {'8'}, {'9'}, {'0'}}; + *character = layout.characters[state.addHostDraft.keypad.selectedButtonIndex % layout.buttonCount]; + return true; } std::string add_host_field_menu_id(app::AddHostField field) { @@ -452,25 +482,46 @@ namespace { } bool move_add_host_keypad_selection(app::ClientState &state, int rowDelta, int columnDelta) { - const std::vector buttons = build_add_host_keypad_buttons(state); - if (buttons.empty()) { + const AddHostKeypadLayout layout = add_host_keypad_layout(state); + if (layout.buttonCount == 0U) { return false; } - const auto rowCount = static_cast((buttons.size() + ADD_HOST_KEYPAD_COLUMN_COUNT - 1U) / ADD_HOST_KEYPAD_COLUMN_COUNT); - const std::size_t currentIndex = state.addHostDraft.keypad.selectedButtonIndex % buttons.size(); - auto currentRow = static_cast(currentIndex / ADD_HOST_KEYPAD_COLUMN_COUNT); - auto currentColumn = static_cast(currentIndex % ADD_HOST_KEYPAD_COLUMN_COUNT); + 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); - currentRow = std::clamp(currentRow + rowDelta, 0, rowCount - 1); - currentColumn = std::clamp(currentColumn + columnDelta, 0, static_cast(ADD_HOST_KEYPAD_COLUMN_COUNT) - 1); + auto wrap_index = [](int value, int count) { + if (count <= 0) { + return 0; + } - auto nextIndex = (static_cast(currentRow) * ADD_HOST_KEYPAD_COLUMN_COUNT) + static_cast(currentColumn); - if (nextIndex >= buttons.size()) { - nextIndex = buttons.size() - 1U; + 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; + const std::size_t targetRowWidth = std::min(ADD_HOST_KEYPAD_COLUMN_COUNT, layout.buttonCount - targetRowStart); + if (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 true; + return nextIndex != currentIndex; } void append_to_active_add_host_field(app::ClientState &state, char character) { @@ -1255,8 +1306,9 @@ namespace app { return update; case input::UiCommand::activate: { - if (const std::vector buttons = build_add_host_keypad_buttons(state); !buttons.empty()) { - append_to_active_add_host_field(state, buttons[state.addHostDraft.keypad.selectedButtonIndex % buttons.size()].character); + char character = '\0'; + if (selected_add_host_keypad_character(state, &character)) { + append_to_active_add_host_field(state, character); } return update; } diff --git a/src/input/navigation_input.cpp b/src/input/navigation_input.cpp index 9d5efb3..4f61ff9 100644 --- a/src/input/navigation_input.cpp +++ b/src/input/navigation_input.cpp @@ -33,6 +33,21 @@ namespace input { 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: diff --git a/src/input/navigation_input.h b/src/input/navigation_input.h index 7e6b7dc..9795a7d 100644 --- a/src/input/navigation_input.h +++ b/src/input/navigation_input.h @@ -41,6 +41,16 @@ namespace input { back, }; + /** + * @brief Controller axis directions mapped onto UI navigation commands. + */ + enum class GamepadAxisDirection { + left_stick_up, + left_stick_down, + left_stick_left, + left_stick_right, + }; + /** * @brief Keyboard keys mapped onto the same abstract UI commands. */ @@ -70,6 +80,14 @@ namespace input { */ 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. * diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 43f14c4..715fc9f 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -22,7 +22,6 @@ #include #include #include -#include #include #include @@ -1086,310 +1085,6 @@ namespace { return elements; } - void skip_json_whitespace(std::string_view json, std::size_t *cursor) { - if (cursor == nullptr) { - return; - } - - while (*cursor < json.size() && std::isspace(static_cast(json[*cursor]))) { - ++(*cursor); - } - } - - bool parse_json_string_literal(std::string_view json, std::size_t *cursor, std::string *value) { - if (cursor == nullptr || *cursor >= json.size() || json[*cursor] != '"') { - return false; - } - - ++(*cursor); - std::string parsedValue; - while (*cursor < json.size()) { - const char character = json[*cursor]; - ++(*cursor); - if (character == '"') { - if (value != nullptr) { - *value = std::move(parsedValue); - } - return true; - } - if (character != '\\') { - parsedValue.push_back(character); - continue; - } - - if (*cursor >= json.size()) { - return false; - } - - const char escaped = json[*cursor]; - ++(*cursor); - switch (escaped) { - case '"': - case '\\': - case '/': - parsedValue.push_back(escaped); - break; - case 'b': - parsedValue.push_back('\b'); - break; - case 'f': - parsedValue.push_back('\f'); - break; - case 'n': - parsedValue.push_back('\n'); - break; - case 'r': - parsedValue.push_back('\r'); - break; - case 't': - parsedValue.push_back('\t'); - break; - case 'u': - if ((*cursor + 4U) > json.size()) { - return false; - } - parsedValue.push_back('?'); - *cursor += 4U; - break; - default: - parsedValue.push_back(escaped); - break; - } - } - - return false; - } - - bool find_matching_json_delimiter(std::string_view json, std::size_t openIndex, char openCharacter, char closeCharacter, std::size_t *closeIndex) { - if (openIndex >= json.size() || json[openIndex] != openCharacter) { - return false; - } - - bool inString = false; - bool escaped = false; - int depth = 0; - for (std::size_t index = openIndex; index < json.size(); ++index) { - const char character = json[index]; - if (inString) { - if (escaped) { - escaped = false; - } else if (character == '\\') { - escaped = true; - } else if (character == '"') { - inString = false; - } - continue; - } - - if (character == '"') { - inString = true; - continue; - } - if (character == openCharacter) { - ++depth; - continue; - } - if (character == closeCharacter) { - --depth; - if (depth == 0) { // NOSONAR(cpp:S134) delimiter matching keeps nested parsing state local - if (closeIndex != nullptr) { - *closeIndex = index; - } - return true; - } - } - } - - return false; - } - - bool extract_json_named_array(std::string_view json, std::string_view fieldName, std::string_view *arrayView) { - const std::string keyToken = "\"" + std::string(fieldName) + "\""; - std::size_t cursor = 0; - while (cursor < json.size()) { - const std::size_t keyIndex = json.find(keyToken, cursor); - if (keyIndex == std::string_view::npos) { - return false; - } - - std::size_t separatorIndex = keyIndex + keyToken.size(); - skip_json_whitespace(json, &separatorIndex); - if (separatorIndex >= json.size() || json[separatorIndex] != ':') { - cursor = keyIndex + 1; - continue; - } - - ++separatorIndex; - skip_json_whitespace(json, &separatorIndex); - if (separatorIndex >= json.size() || json[separatorIndex] != '[') { - cursor = keyIndex + 1; - continue; - } - - std::size_t arrayEnd = separatorIndex; - if (!find_matching_json_delimiter(json, separatorIndex, '[', ']', &arrayEnd)) { - return false; - } - - if (arrayView != nullptr) { - *arrayView = json.substr(separatorIndex, arrayEnd - separatorIndex + 1U); - } - return true; - } - - return false; - } - - std::vector extract_json_object_blocks(std::string_view arrayView) { - std::vector blocks; - if (arrayView.size() < 2U || arrayView.front() != '[' || arrayView.back() != ']') { - return blocks; - } - - std::size_t cursor = 1; - while (cursor + 1U < arrayView.size()) { // NOSONAR(cpp:S924) permissive JSON scanning uses multiple early breaks for malformed payloads - skip_json_whitespace(arrayView, &cursor); - if (cursor + 1U >= arrayView.size()) { - break; - } - if (arrayView[cursor] == ',') { - ++cursor; - continue; - } - if (arrayView[cursor] != '{') { - ++cursor; - continue; - } - - std::size_t objectEnd = cursor; - if (!find_matching_json_delimiter(arrayView, cursor, '{', '}', &objectEnd)) { - break; - } - - blocks.push_back(arrayView.substr(cursor, objectEnd - cursor + 1U)); - cursor = objectEnd + 1U; - } - - return blocks; - } - - bool extract_json_field_value(std::string_view object, std::string_view fieldName, std::string_view *valueView, bool *isStringValue = nullptr) { // NOSONAR(cpp:S3776) permissive JSON parsing stays centralized here - if (object.empty() || object.front() != '{') { - return false; - } - - std::size_t cursor = 1; - while (cursor < object.size()) { - skip_json_whitespace(object, &cursor); - if (cursor >= object.size() || object[cursor] == '}') { - return false; - } - - std::size_t keyCursor = cursor; - std::string key; - if (!parse_json_string_literal(object, &keyCursor, &key)) { - return false; - } - - skip_json_whitespace(object, &keyCursor); - if (keyCursor >= object.size() || object[keyCursor] != ':') { - return false; - } - - ++keyCursor; - skip_json_whitespace(object, &keyCursor); - if (keyCursor >= object.size()) { - return false; - } - - const std::size_t valueStart = keyCursor; - std::size_t valueEnd = valueStart; - bool valueIsString = false; - if (object[keyCursor] == '"') { - valueIsString = true; - if (std::string ignored; !parse_json_string_literal(object, &keyCursor, &ignored)) { - return false; - } - valueEnd = keyCursor; - } else if (object[keyCursor] == '{') { - if (!find_matching_json_delimiter(object, keyCursor, '{', '}', &valueEnd)) { - return false; - } - keyCursor = valueEnd + 1U; - } else if (object[keyCursor] == '[') { - if (!find_matching_json_delimiter(object, keyCursor, '[', ']', &valueEnd)) { - return false; - } - keyCursor = valueEnd + 1U; - } else { - while (keyCursor < object.size() && object[keyCursor] != ',' && object[keyCursor] != '}') { - ++keyCursor; - } - valueEnd = keyCursor; - } - - if (key == fieldName) { - if (valueView != nullptr) { - *valueView = trim_ascii_whitespace(object.substr(valueStart, valueEnd - valueStart)); - } - if (isStringValue != nullptr) { - *isStringValue = valueIsString; - } - return true; - } - - cursor = keyCursor; - if (cursor < object.size() && object[cursor] == ',') { - ++cursor; - } - } - - return false; - } - - bool extract_json_string_like_field(std::string_view object, const std::vector &fieldNames, std::string *value) { - for (std::string_view fieldName : fieldNames) { - std::string_view rawValue; - bool isStringValue = false; - if (!extract_json_field_value(object, fieldName, &rawValue, &isStringValue)) { - continue; - } - - if (isStringValue) { - std::size_t cursor = 0; - std::string parsedValue; - if (parse_json_string_literal(rawValue, &cursor, &parsedValue)) { - if (value != nullptr) { // NOSONAR(cpp:S134) JSON field extraction keeps conversion adjacent to the matching field - *value = std::move(parsedValue); - } - return true; - } - } else if (!rawValue.empty()) { - if (value != nullptr) { - *value = std::string(trim_ascii_whitespace(rawValue)); - } - return true; - } - } - - return false; - } - - bool extract_json_bool_like_field(std::string_view object, const std::vector &fieldNames, bool *value) { - for (std::string_view fieldName : fieldNames) { - std::string text; - if (!extract_json_string_like_field(object, {fieldName}, &text)) { - continue; - } - - if (try_parse_flag(text, value)) { - return true; - } - } - - return false; - } - 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); @@ -1401,58 +1096,6 @@ namespace { return {}; } - bool parse_json_app_list_response(std::string_view json, std::vector *apps, std::string *errorMessage) { - const std::string_view trimmed = trim_ascii_whitespace(json); - std::string_view appArray; - if (!trimmed.empty() && trimmed.front() == '[') { - appArray = trimmed; - } else { - for (std::string_view fieldName : {std::string_view("apps"), std::string_view("Applications"), std::string_view("applications"), std::string_view("games"), std::string_view("Games"), std::string_view("applist"), std::string_view("data")}) { - if (extract_json_named_array(trimmed, fieldName, &appArray)) { - break; - } - } - } - - if (appArray.empty()) { - return append_error(errorMessage, "The host applist response did not contain any app entries"); - } - - const std::vector appObjects = extract_json_object_blocks(appArray); - if (appObjects.empty()) { - return append_error(errorMessage, "The host applist response did not contain any app entries"); - } - - std::vector parsedApps; - parsedApps.reserve(appObjects.size()); - for (std::string_view appObject : appObjects) { - std::string name; - std::string idText; - extract_json_string_like_field(appObject, {"AppTitle", "title", "Title", "name", "Name", "displayName", "DisplayName"}, &name); - extract_json_string_like_field(appObject, {"ID", "id", "Id", "appid", "appId", "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; - extract_json_bool_like_field(appObject, {"IsHdrSupported", "HDRSupported", "isHdrSupported", "hdrSupported"}, &hdrSupported); - extract_json_bool_like_field(appObject, {"Hidden", "hidden", "IsHidden", "isHidden"}, &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 append_applist_status_error(std::string_view responseBody, std::string *errorMessage) { const std::vector roots = extract_xml_elements(responseBody, "root"); if (roots.empty()) { @@ -2016,19 +1659,18 @@ namespace { return parse_http_response(rawResponse, response, errorMessage); } - const EVP_MD *pairing_digest(int serverMajorVersion) { - // TODO: remove legacy support... it is not needed - return serverMajorVersion >= 7 ? EVP_sha256() : EVP_sha1(); // NOSONAR(cpp:S4790) legacy servers require SHA-1 compatibility + const EVP_MD *pairing_digest() { + return EVP_sha256(); } - std::size_t pairing_hash_length(int serverMajorVersion) { - return serverMajorVersion >= 7 ? 32U : 20U; + std::size_t pairing_hash_length() { + return 32U; } - bool compute_digest(const unsigned char *data, std::size_t size, int serverMajorVersion, std::vector *digest, std::string *errorMessage) { + 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(serverMajorVersion), nullptr) != 1) { + if (EVP_Digest(data, size, output.data(), &digestLength, pairing_digest(), nullptr) != 1) { return append_error(errorMessage, "Failed to compute the host pairing digest"); } @@ -2121,7 +1763,7 @@ namespace { return true; } - bool derive_aes_key(std::string_view saltHex, std::string_view pin, int serverMajorVersion, std::vector *key, std::string *errorMessage) { + 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"); @@ -2131,7 +1773,7 @@ namespace { 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(), serverMajorVersion, key, errorMessage); + 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) { @@ -2284,7 +1926,7 @@ namespace network { if (!trimmedResponse.empty() && trimmedResponse.front() == '<') { return append_applist_status_error(trimmedResponse, errorMessage); } - return parse_json_app_list_response(trimmedResponse, apps, errorMessage); + return append_error(errorMessage, "The applist response was not XML (payload preview: " + summarize_http_payload_preview(trimmedResponse) + ")"); } std::vector parsedApps; @@ -2619,7 +2261,7 @@ namespace network { std::vector aesKey; trace_pairing_phase("deriving AES key"); - if (!derive_aes_key(saltHex, request.pin, serverInfo.serverMajorVersion, &aesKey, &errorMessage)) { + if (!derive_aes_key(saltHex, request.pin, &aesKey, &errorMessage)) { return fail_with_phase("phase 1 (derive AES key)", errorMessage); } if (fail_if_cancelled()) { @@ -2663,7 +2305,7 @@ namespace network { return fail_with_phase("phase 2 (client challenge)", errorMessage); } - const std::size_t hashLength = pairing_hash_length(serverInfo.serverMajorVersion); + const std::size_t hashLength = pairing_hash_length(); if (challengeResponsePlaintext.size() < hashLength + 16U) { result.message = "The host returned an incomplete challenge response during pairing"; return result; @@ -2685,7 +2327,7 @@ namespace network { clientHashSource.insert(clientHashSource.end(), clientSecretBytes.begin(), clientSecretBytes.end()); std::vector clientHash; - if (!compute_digest(clientHashSource.data(), clientHashSource.size(), serverInfo.serverMajorVersion, &clientHash, &errorMessage)) { + if (!compute_digest(clientHashSource.data(), clientHashSource.size(), &clientHash, &errorMessage)) { return fail_with_phase("phase 3 (server challenge response)", errorMessage); } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 513e792..5790263 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -58,10 +58,14 @@ namespace { constexpr Uint8 MUTED_GREEN = 0xAB; constexpr Uint8 MUTED_BLUE = 0xB5; constexpr Sint16 TRIGGER_PAGE_SCROLL_THRESHOLD = 16000; - constexpr Uint32 CONTEXT_HOLD_MILLISECONDS = 550U; + 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; @@ -168,14 +172,47 @@ namespace { return renderResult == 0; } + /** + * @brief Caches cover-art textures keyed by the app art cache key. + */ struct CoverArtTextureCache { - std::unordered_map textures; - std::unordered_map failedKeys; + 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; - std::unordered_map failedKeys; + 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( @@ -224,6 +261,55 @@ namespace { 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) { @@ -528,6 +614,188 @@ namespace { 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, @@ -575,6 +843,60 @@ namespace { 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.keypadModalLines) { + return cache->modalTextHeight; + } + + if (cache != nullptr) { + if (cache->lineTextures.size() > viewModel.keypadModalLines.size()) { + for (std::size_t index = viewModel.keypadModalLines.size(); index < cache->lineTextures.size(); ++index) { + clear_cached_text_texture(&cache->lineTextures[index]); + } + } + cache->lineTextures.resize(viewModel.keypadModalLines.size()); + } + + int modalTextHeight = 0; + for (std::size_t index = 0; index < viewModel.keypadModalLines.size(); ++index) { + const std::string &line = viewModel.keypadModalLines[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.keypadModalLines; + } + + return modalTextHeight; + } + bool render_text_centered( SDL_Renderer *renderer, TTF_Font *font, @@ -1261,6 +1583,272 @@ namespace { 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(logging::Logger &logger, const app::ClientState &state, const app::AppUpdate &update) { if (!update.activatedItemId.empty()) { logger.log(logging::LogLevel::info, "ui", "Activated menu item: " + update.activatedItemId); @@ -2330,7 +2918,8 @@ namespace { TTF_Font *smallFont, const ui::ShellViewModel &viewModel, CoverArtTextureCache *textureCache, - AssetTextureCache *assetCache + AssetTextureCache *assetCache, + KeypadModalLayoutCache *keypadModalLayoutCache ) { int framebufferWidth = 0; int framebufferHeight = 0; @@ -2710,10 +3299,7 @@ namespace { const int buttonRowCount = std::max(1, static_cast((viewModel.keypadModalButtons.size() + viewModel.keypadModalColumnCount - 1) / viewModel.keypadModalColumnCount)); const int preferredButtonHeight = std::max(40, TTF_FontLineSkip(bodyFont) + 16); const int modalInnerWidth = modalWidth - 32; - int modalTextHeight = 0; - for (const std::string &line : viewModel.keypadModalLines) { - modalTextHeight += measure_wrapped_text_height(smallFont, line, modalInnerWidth) + 6; - } + 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)); @@ -2727,14 +3313,18 @@ namespace { fill_rect(renderer, modalRect, PANEL_RED, PANEL_GREEN, PANEL_BLUE, 0xF0); draw_rect(renderer, modalRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); - if (!render_text_line(renderer, bodyFont, viewModel.keypadModalTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 16, modalRect.w - 32)) { + if ( + !ensure_wrapped_text_texture(renderer, bodyFont, viewModel.keypadModalTitle, {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; - for (const std::string &line : viewModel.keypadModalLines) { + keypadModalLayoutCache->lineTextures.resize(viewModel.keypadModalLines.size()); + for (std::size_t index = 0; index < viewModel.keypadModalLines.size(); ++index) { 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)) { + if (!render_cached_text_texture(renderer, keypadModalLayoutCache->lineTextures[index], modalRect.x + 16, modalY, &drawnHeight)) { return false; } modalY += drawnHeight + 6; @@ -2745,6 +3335,13 @@ namespace { 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.keypadModalButtons.size()) { + for (std::size_t index = viewModel.keypadModalButtons.size(); index < keypadModalLayoutCache->buttonLabelTextures.size(); ++index) { + clear_cached_text_texture(&keypadModalLayoutCache->buttonLabelTextures[index]); + } + } + keypadModalLayoutCache->buttonLabelTextures.resize(viewModel.keypadModalButtons.size()); + for (std::size_t index = 0; index < viewModel.keypadModalButtons.size(); ++index) { const auto row = static_cast(index / viewModel.keypadModalColumnCount); const auto column = static_cast(index % viewModel.keypadModalColumnCount); @@ -2764,7 +3361,10 @@ namespace { 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 (!render_text_centered(renderer, bodyFont, button.label, buttonColor, buttonRect)) { + 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; } } @@ -2847,13 +3447,10 @@ namespace ui { bool rightTriggerPressed = false; bool leftShoulderPressed = false; bool rightShoulderPressed = false; - bool controllerAPressed = false; - bool controllerAContextTriggered = false; bool controllerStartPressed = false; bool controllerBackPressed = false; bool controllerExitComboArmed = false; bool controllerExitComboTriggered = false; - Uint32 controllerADownTick = 0; Uint32 controllerStartDownTick = 0; Uint32 controllerBackDownTick = 0; Uint32 nextHostProbeTick = 0; @@ -2861,12 +3458,18 @@ namespace ui { Uint32 rightShoulderRepeatTick = 0; Uint32 leftTriggerRepeatTick = 0; Uint32 rightTriggerRepeatTick = 0; + ControllerNavigationHoldState moveUpHoldState {}; + ControllerNavigationHoldState moveDownHoldState {}; + ControllerNavigationHoldState moveLeftHoldState {}; + ControllerNavigationHoldState moveRightHoldState {}; + bool controllerNavigationNeutralRequired = false; PairingTaskState pairingTask {}; AppListTaskState appListTask {}; AppArtTaskState appArtTask {}; HostProbeTaskState hostProbeTask {}; CoverArtTextureCache coverArtTextureCache {}; AssetTextureCache assetTextureCache {}; + KeypadModalLayoutCache keypadModalLayoutCache {}; const unsigned long encoderSettings = XVideoGetEncoderSettings(); reset_pairing_task(&pairingTask); reset_app_list_task(&appListTask); @@ -2874,9 +3477,11 @@ namespace ui { reset_host_probe_task(&hostProbeTask); logger.set_minimum_level(state.loggingLevel); logger.log(logging::LogLevel::info, "app", "Entered interactive shell"); + bool keypadRedrawRequested = true; const auto draw_current_shell = [&]() { - if (const auto viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { + if (const auto viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache, &keypadModalLayoutCache)) { + keypadRedrawRequested = false; return true; } @@ -2891,6 +3496,8 @@ namespace ui { return; } + keypadRedrawRequested = true; + const app::AppUpdate update = app::handle_command(state, command); logger.set_minimum_level(state.loggingLevel); log_app_update(logger, state, update); @@ -2931,11 +3538,6 @@ namespace ui { } } - if (controllerAPressed && !controllerAContextTriggered && SDL_GetTicks() - controllerADownTick >= CONTEXT_HOLD_MILLISECONDS) { - controllerAContextTriggered = true; - process_command(input::UiCommand::open_context_menu); - } - if (state.modal.id == app::ModalId::log_viewer) { const Uint32 now = SDL_GetTicks(); if (leftShoulderPressed && now - leftShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { @@ -2957,120 +3559,144 @@ namespace ui { } SDL_Event event; - while (SDL_PollEvent(&event)) { - input::UiCommand command = input::UiCommand::none; - - switch (event.type) { - case SDL_QUIT: - state.shouldExit = true; - break; - case SDL_CONTROLLERDEVICEADDED: - if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing - controller = SDL_GameControllerOpen(event.cdevice.which); - if (controller != nullptr) { - logger.log(logging::LogLevel::info, "input", "Controller connected"); + bool skipPolledControllerNavigation = false; + if (SDL_WaitEventTimeout(&event, SHELL_EVENT_WAIT_TIMEOUT_MILLISECONDS) != 0) { + do { + input::UiCommand command = input::UiCommand::none; + + switch (event.type) { + case SDL_QUIT: + state.shouldExit = true; + break; + case SDL_CONTROLLERDEVICEADDED: + if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing + controller = SDL_GameControllerOpen(event.cdevice.which); + if (controller != nullptr) { + logger.log(logging::LogLevel::info, "input", "Controller connected"); + } } - } - break; - case SDL_CONTROLLERDEVICEREMOVED: - if (controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing - close_controller(controller); - controller = nullptr; - leftTriggerPressed = false; - rightTriggerPressed = false; - leftShoulderPressed = false; - rightShoulderPressed = false; - controllerAPressed = false; - controllerAContextTriggered = false; - controllerStartPressed = false; - controllerBackPressed = false; - controllerExitComboArmed = false; - controllerExitComboTriggered = false; - logger.log(logging::LogLevel::warning, "input", "Controller disconnected"); - } - break; - case SDL_CONTROLLERBUTTONDOWN: - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A) { // NOSONAR(cpp:S134) button state transitions stay explicit for controller input parity - if (!controllerAPressed) { - controllerAPressed = true; - controllerAContextTriggered = false; - controllerADownTick = SDL_GetTicks(); - } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START) { - if (!controllerStartPressed) { - controllerStartPressed = true; - controllerStartDownTick = SDL_GetTicks(); + break; + case SDL_CONTROLLERDEVICEREMOVED: + if (controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing + close_controller(controller); + controller = nullptr; + leftTriggerPressed = false; + rightTriggerPressed = false; + leftShoulderPressed = false; + rightShoulderPressed = false; + controllerStartPressed = false; + controllerBackPressed = false; + controllerExitComboArmed = false; + controllerExitComboTriggered = false; + controllerNavigationNeutralRequired = false; + reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); + logger.log(logging::LogLevel::warning, "input", "Controller disconnected"); } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) { - if (!controllerBackPressed) { - controllerBackPressed = true; - controllerBackDownTick = SDL_GetTicks(); + break; + case SDL_CONTROLLERBUTTONDOWN: + { + const Uint32 controllerButtonDownTick = SDL_GetTicks(); + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START) { + if (!controllerStartPressed) { + controllerStartPressed = true; + controllerStartDownTick = controllerButtonDownTick; + } + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) { + if (!controllerBackPressed) { + controllerBackPressed = true; + controllerBackDownTick = controllerButtonDownTick; + } + } else { + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + leftShoulderPressed = true; + leftShoulderRepeatTick = controllerButtonDownTick; + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { + rightShoulderPressed = true; + rightShoulderRepeatTick = controllerButtonDownTick; + } + + command = translate_controller_button(event.cbutton.button); + if (is_navigation_command(command)) { + seed_controller_navigation_hold_state( + controllerButtonDownTick, + command, + &moveUpHoldState, + &moveDownHoldState, + &moveLeftHoldState, + &moveRightHoldState + ); + } + } + + if ( // NOSONAR(cpp:S134) exit-combo arming stays inline with the button state machine + controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) + ) { + controllerExitComboArmed = true; + } + break; } - } else { - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { - leftShoulderPressed = true; - leftShoulderRepeatTick = SDL_GetTicks(); + case SDL_CONTROLLERBUTTONUP: + if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START && controllerStartPressed) { + controllerStartPressed = false; + if (!controllerExitComboArmed && !controllerExitComboTriggered) { + command = input::map_gamepad_button_to_ui_command(input::GamepadButton::start); + } + if (!controllerStartPressed && !controllerBackPressed) { + controllerExitComboArmed = false; + controllerExitComboTriggered = false; + } + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK && controllerBackPressed) { + controllerBackPressed = false; + if (!controllerExitComboArmed && !controllerExitComboTriggered) { + command = input::map_gamepad_button_to_ui_command(input::GamepadButton::back); + } + if (!controllerStartPressed && !controllerBackPressed) { + controllerExitComboArmed = false; + controllerExitComboTriggered = false; + } + } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { + leftShoulderPressed = false; } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { - rightShoulderPressed = true; - rightShoulderRepeatTick = SDL_GetTicks(); + rightShoulderPressed = false; } - command = translate_controller_button(event.cbutton.button); - } - if ( // NOSONAR(cpp:S134) exit-combo arming stays inline with the button state machine - controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) - ) { - controllerExitComboArmed = true; - } - break; - case SDL_CONTROLLERBUTTONUP: - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_A && controllerAPressed) { // NOSONAR(cpp:S134) button release handling stays explicit for controller input parity - controllerAPressed = false; - if (!controllerAContextTriggered) { - command = input::UiCommand::activate; + release_controller_navigation_hold_state(translate_controller_button(event.cbutton.button), &moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); + break; + case SDL_CONTROLLERAXISMOTION: + command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); + if (command == input::UiCommand::fast_previous_page) { // NOSONAR(cpp:S134) trigger repeat bookkeeping stays inline with translated command handling + leftTriggerRepeatTick = SDL_GetTicks(); + } else if (command == input::UiCommand::fast_next_page) { + rightTriggerRepeatTick = SDL_GetTicks(); } - controllerAContextTriggered = false; - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START && controllerStartPressed) { - controllerStartPressed = false; - if (!controllerExitComboArmed && !controllerExitComboTriggered) { - command = input::map_gamepad_button_to_ui_command(input::GamepadButton::start); - } - if (!controllerStartPressed && !controllerBackPressed) { - controllerExitComboArmed = false; - controllerExitComboTriggered = false; + break; + case SDL_KEYDOWN: + if (event.key.repeat == 0) { // NOSONAR(cpp:S134) keyboard translation stays inline with SDL event routing + command = translate_keyboard_key(event.key.keysym.sym, event.key.keysym.mod); } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK && controllerBackPressed) { - controllerBackPressed = false; - if (!controllerExitComboArmed && !controllerExitComboTriggered) { - command = input::map_gamepad_button_to_ui_command(input::GamepadButton::back); - } - if (!controllerStartPressed && !controllerBackPressed) { - controllerExitComboArmed = false; - controllerExitComboTriggered = false; - } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { - leftShoulderPressed = false; - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { - rightShoulderPressed = false; - } - break; - case SDL_CONTROLLERAXISMOTION: - command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); - if (command == input::UiCommand::fast_previous_page) { // NOSONAR(cpp:S134) trigger repeat bookkeeping stays inline with translated command handling - leftTriggerRepeatTick = SDL_GetTicks(); - } else if (command == input::UiCommand::fast_next_page) { - rightTriggerRepeatTick = SDL_GetTicks(); - } - break; - case SDL_KEYDOWN: - if (event.key.repeat == 0) { // NOSONAR(cpp:S134) keyboard translation stays inline with SDL event routing - command = translate_keyboard_key(event.key.keysym.sym, event.key.keysym.mod); - } - break; - default: - break; + break; + default: + break; + } + + process_command(command); + if (command != input::UiCommand::none && !is_navigation_command(command)) { + controllerNavigationNeutralRequired = true; + skipPolledControllerNavigation = true; + reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); + } + } while (SDL_PollEvent(&event)); + } + + if (controllerNavigationNeutralRequired) { + if (is_controller_navigation_active(controller)) { + reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); + } else { + controllerNavigationNeutralRequired = false; } + } - process_command(command); + if (!skipPolledControllerNavigation && !controllerNavigationNeutralRequired) { + process_command(poll_controller_navigation(controller, SDL_GetTicks(), &moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState)); } finish_pairing_task_if_ready(logger, state, &pairingTask); @@ -3082,13 +3708,9 @@ namespace ui { start_app_list_task_if_needed(logger, state, &appListTask, backgroundTaskTick); start_app_art_task_if_needed(logger, state, &appArtTask); - if (const auto viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); !draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache)) { - report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); - running = false; + if ((state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || keypadRedrawRequested) && !draw_current_shell()) { break; } - - SDL_Delay(16); } if (pairingTask.activeAttempt != nullptr) { @@ -3122,6 +3744,7 @@ namespace ui { close_controller(controller); clear_cover_art_texture_cache(&coverArtTextureCache); clear_asset_texture_cache(&assetTextureCache); + clear_keypad_modal_layout_cache(&keypadModalLayoutCache); destroy_texture(titleLogoTexture); TTF_CloseFont(smallFont); TTF_CloseFont(bodyFont); diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index 1681149..02fd91c 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -210,7 +210,7 @@ namespace { std::vector lines = { std::string("Editing field: ") + active_add_host_field_label(state), std::string("Staged value: ") + keypad_value(state), - "Use the D-pad to choose a key, A to enter it, X to delete, Start to accept, and B to cancel.", + "Use the D-pad or left stick to choose a key. Hold a direction to keep moving.", }; if (state.addHostDraft.activeField == app::AddHostField::address) { @@ -235,7 +235,7 @@ namespace { } return { "Select a PC to pair or browse its apps.", - "Long-press A on a controller, or press Y/I, for host actions.", + "Press Y on a controller, or I on a keyboard, for host actions.", }; case app::ScreenId::apps: { @@ -378,9 +378,12 @@ namespace { viewModel->modalTitle = "Support"; viewModel->modalLines = { "Moonlight Xbox OG prototype UI", - "A / Start: close", - "B: close", - "Y or I: open context menus on hosts and apps", + "Use the footer actions below to close this dialog.", + "Open host and app context menus from the Y action on the main screens.", + }; + viewModel->modalFooterActions = { + {"close", "Close", "icons\\button-a.svg", "icons\\button-start.svg", true}, + {"back", "Back", "icons\\button-b.svg", {}, false}, }; return; case app::ModalId::host_actions: @@ -447,6 +450,14 @@ namespace { viewModel->logViewerLines = state.logViewerLines; viewModel->logViewerScrollOffset = state.logViewerScrollOffset; viewModel->logViewerPlacement = state.logViewerPlacement; + viewModel->modalFooterActions = { + {"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}, + }; return; case app::ModalId::confirmation: viewModel->modalTitle = state.confirmation.title; diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 1c188e6..48d03b4 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -713,6 +713,50 @@ namespace { 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, SuccessfulPairingReturnsToHostsAndKeepsTheHostSelected) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { diff --git a/tests/unit/input/navigation_input_test.cpp b/tests/unit/input/navigation_input_test.cpp index 0fbe51f..2eede67 100644 --- a/tests/unit/input/navigation_input_test.cpp +++ b/tests/unit/input/navigation_input_test.cpp @@ -16,6 +16,13 @@ namespace { EXPECT_EQ(input::map_gamepad_button_to_ui_command(input::GamepadButton::y), input::UiCommand::open_context_menu); } + 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); + } + TEST(NavigationInputTest, MapsKeyboardKeysToNavigationCommands) { 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::enter), input::UiCommand::confirm); diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index f670b62..157b9d1 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -200,20 +200,12 @@ namespace { EXPECT_TRUE(apps[1].hdrSupported); } - TEST(HostPairingTest, ParsesJsonAppLists) { - const std::string json = R"({"apps":[{"name":"Steam","id":401,"hdrSupported":true},{"title":"Desktop","appid":"402","hidden":false}]})"; - + TEST(HostPairingTest, RejectsNonXmlAppLists) { std::vector apps; std::string errorMessage; - ASSERT_TRUE(network::parse_app_list_response(json, &apps, &errorMessage)) << errorMessage; - ASSERT_EQ(apps.size(), 2U); - EXPECT_EQ(apps[0].name, "Steam"); - EXPECT_EQ(apps[0].id, 401); - EXPECT_TRUE(apps[0].hdrSupported); - EXPECT_EQ(apps[1].name, "Desktop"); - EXPECT_EQ(apps[1].id, 402); - EXPECT_FALSE(apps[1].hidden); + 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) { diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 759932d..521d687 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -56,6 +56,8 @@ namespace { EXPECT_EQ(viewModel.hostTiles[1].statusLabel, "Online"); EXPECT_EQ(viewModel.hostTiles[1].iconAssetPath, "icons\\host-monitor-online.svg"); EXPECT_TRUE(viewModel.hostTiles[1].selected); + ASSERT_GE(viewModel.bodyLines.size(), 2U); + EXPECT_EQ(viewModel.bodyLines[1], "Press Y on a controller, or I on a keyboard, for host actions."); } TEST(ShellViewTest, HidesHostMenuFooterActionWhenToolbarIsSelected) { @@ -280,6 +282,23 @@ namespace { EXPECT_EQ(viewModel.logViewerScrollOffset, 1U); ASSERT_EQ(viewModel.logViewerLines.size(), 2U); EXPECT_EQ(viewModel.logViewerLines[0], "[000001] [INFO] app: Entered shell"); + ASSERT_EQ(viewModel.modalFooterActions.size(), 6U); + EXPECT_EQ(viewModel.modalFooterActions[0].iconAssetPath, "icons\\button-lb.svg"); + EXPECT_EQ(viewModel.modalFooterActions[5].iconAssetPath, "icons\\button-b.svg"); + } + + 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.modalVisible); + EXPECT_EQ(viewModel.modalTitle, "Support"); + ASSERT_EQ(viewModel.modalFooterActions.size(), 2U); + EXPECT_EQ(viewModel.modalFooterActions[0].iconAssetPath, "icons\\button-a.svg"); + EXPECT_EQ(viewModel.modalFooterActions[0].secondaryIconAssetPath, "icons\\button-start.svg"); + EXPECT_EQ(viewModel.modalFooterActions[1].iconAssetPath, "icons\\button-b.svg"); } TEST(ShellViewTest, BuildsConfirmationModalFooterActionsForResetDialogs) { From 2ddedec3569528c250f5c13f0ab850e8cff9ad14 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:28:42 -0400 Subject: [PATCH 13/35] Add docs runconfig and update README/screenshots Add an IDE run configuration (.run/docs.run.xml) to open the generated docs HTML. Replace the old loading.png with three new screenshots under docs/images/screenshots and update README to show the new images and clarify that streaming does not work yet and 1080i does not work in Xemu. Also tidy the TODO checklist by marking docs via doxygen done and removing the duplicate entry. --- .run/docs.run.xml | 7 +++++++ README.md | 10 +++++++--- docs/images/loading.png | Bin 53481 -> 0 bytes docs/images/screenshots/01-splash.png | Bin 0 -> 155999 bytes docs/images/screenshots/02-hosts.png | Bin 0 -> 267210 bytes docs/images/screenshots/03-apps.png | Bin 0 -> 347056 bytes 6 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 .run/docs.run.xml delete mode 100644 docs/images/loading.png create mode 100644 docs/images/screenshots/01-splash.png create mode 100644 docs/images/screenshots/02-hosts.png create mode 100644 docs/images/screenshots/03-apps.png 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/README.md b/README.md index e7b1412..b505d48 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 @@ -190,6 +193,7 @@ 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 @@ -207,6 +211,7 @@ 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 @@ -235,7 +240,6 @@ scripts\setup-xemu.cmd --skip-support-files - Misc. - [x] Save config and pairing states - [x] Host pairing - - [x] Docs via doxygen - [ ] Possibly, GPU overclocking, see https://github.com/GXTX/XboxOverclock
diff --git a/docs/images/loading.png b/docs/images/loading.png deleted file mode 100644 index bd3e572f1a650f4b07883305a713fe18e01a42a7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53481 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�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 literal 0 HcmV?d00001 diff --git a/docs/images/screenshots/02-hosts.png b/docs/images/screenshots/02-hosts.png new file mode 100644 index 0000000000000000000000000000000000000000..94679a42a0e3732a66c80ad31abdbecefeeb3525 GIT binary patch literal 267210 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

m zOTlOHyXm-^(*%;kJ><$4AouonXWY>JwV-!qCE$$O!{zNnAMmO zC)TKm#@3YH*fIbzSIN8CD!Vg^vPiUPbbu_fRShFX8=_GIXoiN+xCvL`yGH|*`cR&^43!=Yq!NrAAC+^XPx`(LEO|b(SDF)k4o^PKT zi2>!BvG$xPc7EImMAKKS6FZPQ>{?uT^P-bTqiByJ6+6;m5cD1Qdc+qeeVfO90nBQy zRu;5TYr?Wq6WvbdiebYx$-9^TnkC_VcHnmutW--7Qgblv$-%I(43I&BB}mDa6h3Z% z;hR22MZAJrh)uyu58%4U3Kw&WTjB!@E#-QXg0z<

sdypy_gOYfBUfWBH~j=hXR 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

uUu=fzQImlFAdP;Gz3a=IHTrX8 zjbzvNTA-Y^Rju?-RgG;jMl5y2Ye?TWtyY^4Ot9u9=sR9aN;wngs1=rBhA6o_HIAf+T~%>o1?m|$KHw0)q0sLbof2u2l~STOQagj&7&6j-c=|Et0b4bIGOBu zshLTmQOju&Th1>+uQhWmgJerDC8T#5DSl5C?w7Gy~ti|58*NV$8@qG#Yv$`mKsS!e{==Qyl4+5Iin?ckfGko}jM zMJ9*LygZGF*QL>QtVQS7#XQQcC5+r&DSMLLbpIQNuu)7$w{A&^Jy-~u#tj#UO~^45 zzYkWboYFLYFoa-~>{;yd52}*x7qdV9>KnAGNXjA0b2|vBeAC*GZXl_9z)-K;Ja}u*6qpK5;q#d!TAfwEIbG5U7>ei~E z?#|AmkJO<(R-&v!X)UNGQ5|Ijf-5n*?prxW$wE=GfphDw&2ew+X2sE~Nz36I9`PK@ z4~*O&cg{EY#zRh{VMhgw1_pLT~uhMAhV@XR__vYO_ZoS zd6^R{`mHYkg)B;3w_+hP%9=f?zy6YV8Cg+rEukB~+Jc-juUI2^>5dqWG^mEDlvF+~ zm}!f39u3MGL!Yb#Ae_q;R&T^9$lMOSWPc-&(@x5HslvYGrtwIB-r&a89!-b(J6ABS zs2AdAiHjAhRMFXLV8_QcGLu$GGiw~x?F52R<@eB>aAid>Ru1Onui_Q2li)x#!kk+i z|87Htm#xoR*q>3JTSn!#^%?{<42zvGv5R`tl^;(RCSQqU`TE)`wZna{^up1kUdlp~ zJbH$G>s{G*7e9U3vse<8hP9?Mv!5mm30Ez$icYvI_i5N;#SBLxT;61yRl=WPj@-&6mZ4)`O57Qs}~aLuxY?=qfJRfP^*x4}ZUqMW*NA+3u;e>n_zF zbghTEkdC~+V^uBub>!{NFstiiFnSu&f11YlVqV^zUvw}nVTWEB1fhPS=Hi~V zj=ThNZ#7BlY>C!PtIxIqc-JMk2|--2)!^8`4TkqP7N6srB1HEz!P%E{f~h5QoavrO zt4|IEosE-&2>vSvQcQp-)RJ-BAc4L&!~?AE=bEAczd9mw$@DG`6jKAxQb^YZ83XB~ z$c#K00EAx+(d?##(Q+cw+;s;AEge@ym5=!y#pfRjWCpUrn?^GlG$uJ$_Ny8I!VzF2 zyRQR>h)(1gmy=yQ1uUFH1`J$o&RqDe3CiMX__xe5;n--v zI#)>r5|h{w@cd}9oico=PUq=1IUJ%GGNiuRu!y61vYQGW_28&nup{X$L={kW-9BmF z>5_F%aaIaA4kLcXPv_Qsh_2&mTJoqjSlY}-!k>16g2pb3SJ7eEfVgLycvd(uWFD?h z+72EQ`$|zA2vb%4GNmi;@SxjiKo^;Isyzall8ySuBX2lbJTOywp$#IlUsm4IFeX4Q zi7Gv9yN~91IGlbb9ut{O!ND~%d(YGZNWgxoW1n*zX9AS(gmCr<%_MjhUL0^}{slZY zW-K_67EAxn4e*#=wO_x1h;iK^WVwt1+pBd>{{Pgpr-!u5n0`{{h$|7@u@K$%ryRp&)<1%INz~pGm6e?nj5!OSgopH!(`pe$fVK8B@V2y5hlDGT;p2mSbe{ zLFkLl8_4HT7n-BMGMvUb?1BIi}7V_V?qG_#*(a+LHkvyEt-v_`{uPCLz? z$nol{07Ap50Y06Zx1S%o8Rm06-Vn|!HQi=NXkBa&E6?YNH1ez4<7_gTrK|0!6|A%= ze0&Sp)@AC_jf{&61k;N=^;PIV}4RN|KA-Sqe_ajulF zZqJH$xuN!jmaS`Ji=n3bac{UqRQASR_E9Eo3i~A#3J(y*fJYOwc>N48TPhRA_Hx)X zA`=6A>9D)jK^5%6w@ZBF6TpW_teP}ke992(W+cJ({~JH#81PpqUEjH9MHKM$e4637 zB9e38jIwOU(eXpKh~vpf_j=}J&S(fBmL3OU2`mFaoSW>TqqJ zu!GW+% ze<6ftx&LRirae3lCjV|8D0)hoIkP_1Sf;M+0{Sqq;J)7fWMtP_5y4%Lo7dT8N?1A! z_(P6oDTBatD;9DfJg?j}pWbfXZ1V=kWT9T0g#fhcP6mneJ%?655J1OYJfKH?i80Yz zuY;M+P&iMk=-$U|J%|?x300?E2$mtk<`39f%0p@JG`jp)Oh8nq{&C`Aql2i>xqfi< ztkg;TB?fWfQ3Fax2f=&-97@diYf)iU-S3`=FTMnF1J!})ZSSD#xX>C&Yg|(CXh4u} zS@j|>^J#MMheG3V0pMcO{q#0gl+AzkbeJ>D?Zc*1MH&zB%n0$`8rccZjp zn5pL6zRlU3Ii?rcly3jg&U}6IdD@{~&V>eb#VB{l`xS^>zQ~9#%MfLqagpCT+EHz) z(_aWG{;m|<-a~~QDL}O2C#iG}+Ua_lF3fEl=-BWmNJz-pgPB##1-y(@{#B}ya?ULj zk6NKp9j+x z^~-~rltW0~XD@uf=bwnk23-W3>RWJLO298T+H_*cHq`JhUt;7Ry6_qkX{oWj?oyL~ z@b?7-kump`ydS`O9Eay|VSYi@uj8Z`^iOjG|MpiP_4_xO0m=^?e3x9$?GOsIr-1|j9rI*!QzlHLdFgd&{dz#zTfElWZ|LFeeclJsC$9HqWMcFR^2IaP z@0tKu|B6O~b!vhsR^M0&?YVK$lt>I4oEz&&e`}4Pr_l3or>Tih(KPo zNt^w|An&GP*BZow(=cwDPj5vYqInqZ<+sw~!8u7a=x+XL^SQwnZ;Xi%o2vgi9NzV% zdu2*?@mTvcE@R*SGv$5o?^9kl|GV#kdL|$QhrFhD*w;Yx(O+W|y^Ujns=eP7ar+)j zEaY(67>K%x<$Qmc^WkAfCK^5ZX!lgYA`h5Y_k&SG>J` z=bi{$_;LT9dH$c8X4NkC45R$S9HO9!pT1wp4{vp-clA>&Mpn;-Dyyoa+5Q^b&g7gZ z+_7o{P?%#SYiX%_b3to*FH1F8R%%;qSTNH}c1bw&icFR#EoI6NL_y9Cc91KXuh=@P z$&w~b*K(9*=lN#0U(d7i>)iNZ98#8VawsBDL_fTur<}mcUrDk25}a1|iEoqZ!Yf|D z=NdkHd7X0eF^RLHdtv^Y#>quphoSoh210uqGY7D8Wl*x8Hfx)*9MolNno57N+ZJ8A zd^JIjH_Xtegll2Q>{88aTiR4{W{$LuJQ`$Qdqlij&Fa_I<;M@LGwDx8W3txAU;fxd z-T%z!sMeg9k)7Hx6czF9^OwzxEK}{POMlS0e^uo1k6!5f-j$Kb7Gpr2fZ}Kv)zgkt zv?Sa4YL#TNeMA1o*z_5|euRlh1jMT-F}c}AfSA{W&O1MV=*B;@5z1BrJ%F0JY%JoZ zG!84p+4_&CLIlDGXkvd`4Bv-F1}>f~EnNX%(vz3)AxP1> zwFRsu;y}j?xdwTfqq#38|wC=Eoi5ic1mT=9*g(R!stzJ(GX?-BoqdwQKrMK4o z@r&Dn5PN!-Y1qEYbK}Kg-%?5+0v;n(U*3vurXzjb{+lQJI*p1w_y|k#%b~Yq$XfzR zY8Jzd6Jh>!r?BP1DdO~+(P(nPc%7pq;zooRA~w`2b|GSI zoNyzki=$YeNLd*Ru_brwosDTKo7i#D#S;D($Ic}x*-hIDQnJ(aQ=8ui_mkZ#x zZI00(wwWc$DWt#PGq6nIqI+o3NCP5x`^uH6$OQAot+MH{#5~r{f#8Thf1w|aPx)Cr z_sw%e?mWvblRk?Zv}2r-rm$-3@|oF6XC!1!imzx$&alME-Bm!O5#*c!WxsPyj95bl z!XFvBQVZzugrC>ugJaB zf3%WOgUSs9*?5M&fpmp_(!Ti^xs}|^*3fhw(#dY@>8QEW)hi-)P5_mO+5=e`ep!M~ zL-JvJ^uI(vXlUj0Lz4n=wrMARF@&$a^=^T|{F1ApzhmPKGBv({l&w7Nvd4c+1-M{U z(d@02fzY%y^3&b(E34x4)F`r_RWVcGDC!+yXTSxc9;yp5=b`zkiA7tP+wzZ{c)i(>0UUUyhXFl)|Go* z#i`gIkh@Ng5y%%FZ(OO{Gn+*_itS_)3K()D;fVE>5NI+{zjZ_8s4$}XF!78V&S;Pi z;E94n|8jpkdSGZJ35Xs7Qba{`l#cw%gpRf$TbBdM zp3k*f9$`d0IdACG=*-*sL>3UYBCOc*(I9Spj#Z7cpJ3ANG`7H7WWl6|{_QeUp+m02QofO$GWy2EJ3;&$Wdr6+V6h5?{dBL6~7(6y}W?Q^FlQ_CfKnm3@0YXioWTgpw4n<;EZaA!xK=>GA+Py{%6h)isOv_$g+^2b=7J)R95SDJlp1k^N zz@A73>SbCh3Lm;76jaG03;j|zOy_(LOT|j|+ly4NRLnk+N(&D;Dz-dVEyadBLRW># z3KKR}KoWZp`>pH^v0^|B1h`yn)!NoBr!6*TP%7Xj$;9QjU-5&&hEdX3% z1p=ms#k2zStI>JWUJ0nh91=P*%mA_8XXYD^0$piCoAB zdwR5j97`lw<8%K7@;b$ac{#oCiCy2djkq6J?6C;;p3exoBVm@{R&JSvtf!M!Z4XXz zMuXd8^6T2W7GgqTVj^4iBi1%DkBiukf{w-XXs3>8*&q5pC_y^5`*=4hX*f?gv&U14 zUfyXLmYUn!{qPc?_oUHu%FHN&#C_Q6V-`Y1M&vRBfFzP?Lo+;n8Ni1_tBPr`Nno0U zr4p3Zl1}%;ap|7RiemPR!sH~bqGNLjDNWrxZdv|K=PwfH%32Wy4kgShx}zZjWciB4 zH;_y@dV>)$=cn5}^&%-oLRmM~)p@s@L($IYi#jyy6w9r$>e(d;4MiVRHMDsfd)u-} z_d0h?O6fg!dNbW#X6bLUW{#?>XmP~QMK&^N>}@CM*v_X%Ei~w>#Upyyna;>wu6Z)T z)*MhKr7^-Ua3!Z@so}+nRYQ+(?VT;vov-caq8@bbI@1PAbrg~AFs zAaQ?r;}z7OC4gYmm_haoPcUh+G;9!?(48us2mCKZNrBc$@GpDg`;enDLscnO=?D*d zAa%H{RnfOf*Sn=Nl6vw<(W(I%JUuU)qp3VIrf$t0x@q2tPzz|EfdlGSOhAp-+gW&d zbn9Z{fxu|iiUxb^ost=68ociD`xO0D7u>A7rVF1>N3tYkrv@XPT?LBM&-kYXk8nIt zTVk)Uusj_0Of7Lp3T3KJ`~Z~ZKv-e|3OF5iI&%qt1Qxjio^T1_&KR}3LR)-066kN= zVh{K1+!J$BgH>seZGqpP?;y6vdfSaYtOu=pX-v_uBesw_+i%Ge@!Y;SHY`iM@91p2 zAOIO9r1*1!+qB7+CAMPpMV`l~U%JHBqUw@&(P8Uf8M^)6KnQy z?vcR=2$bQs8Z(M68L9R8FF5GNh5SfBvd8tMwjN}vv80@=DXV@kivWOV^xj4c=O3Kr zJ|J!{!c;_-7h2Pvzr4=m9_JeBrWo7OsOOQS7oWcIuHpkhQ@9GA{Luq*>XlJc1oCL> zgWGk(WACWfE;S?b4ypvxP0LiAYTyhN!}i6G59*JexH50^gzadA+jAcNLD6GH|grSV32tpzuwqs`;6 zqqh$+A%X&{>JLSdf+ATU9di80jfd-7)px#msQLx1$`NJC-2SuHK=XgE`v02|{zv%R zBEy8g_6Ii4e(vtI=_4;IB!tcDCpde8@E33z{?d$|k8L?Ji`{y0SRO5#G1#drpOy9W zvO+*(Q&Bv*`!JtF!eiIW4Q&?>4HrZOhlc&FUi*WZ;g@k5brHjm<|+hbTCiDeb>e^0pX zAC!82U2jw~BFKs?AR)&>{W+Xjp554|X-eFjR1V=A;&ncJu6sP(ci@nd+A$Cu@+00K z7(BG&hJrnh^>wm`Z}bTu)q)j`Kiov>;))QaWf(}Hv)Q##7hXNUa6e1ns!G1eS26<# zNc{Qkm(IJsslju&t0X?v5#N1)Nd7qXX#XLG0M2e?m}G;RcJ2{GS^_`h}&SYa< zR_tHY^<>Tx7b#|~j*81eHf3xX;kRT!?*4MPoaN|bU$}AfBM_xW1|tH!4P~q>SF&)h z#N}{rO&9`7cH!e3!!H~#C-PMjYIu^8KpB$MkCS&@iBy|ELb)tU`W&v#(FzHK3_Tvt zB{&yP)d*zoy5XKYH^BmKNA*_Ztev}uw8z$51nLpEO*SUwOyBP2xjAU8U;n;~@Xk5X zmG-T-1jk2H=g<5&3)zcZ&ni@z*$JE6>>%80L+g}*QekgGIBT8o)Dxseq!ig=8UJHq z_gev&THn#dlEl}q3(&fLVR|eeJ>`*?!Xj8hQT3tCiX@SNLYpLP1~DaC28gB z5>t@g@RQv2mB<)hlGw$wO8K0N^QRo0`*%6I;WgnQ=j(RGrp~O<;MR>a|AVZT(IP~%fS`0lyUG)q~fsay6! z(?yE?xrI*Wt<(~pp5TY79Eqdr^}b?YY}fie(!7J!PC-p2Q#Z_lFI4Rilj-xbpA(op z@`>D^;{w7T}2wxfdI>A_WXxI;dC63RFTyb%!E`2pi#Ygu*5I-jng^@LGP(xYoNK0bw_APH5TX-?=Wei2WhC{E9Tq4x(MTM4gj zp~40QFFcN0kC_mO6q-cKwbIlCTAj4j(#-NU+Y7+Q0GtA!+S3wC20?|XJ}U*3whG?{ z`J7<|MBW-plpWZq{Q@KuUnct>f#p8=_Fr^&)b}(SM>Z)_6b<~Xi8hh^wF5crn6gt8 z@CogOJ<{q1n@fqbP*e;t@#yE;e`{+gHnBnvpkJX$S4}B^S-ZA*lGp@rxvEra8$KLH z22gan^o5W$1>C_dNGiR#O^G^+<)SAR`;0L|@DLB$cau%$HUl|LI{r{R9i0su$Py%; z$_+~F`X;ltdAMIYEjsS0@T&u1e%h(vCE&aft83O{Ad5Q3^NDqe6k>*E8a*~abX=UkR022lLGY>Y&l7F_q;IHI$F$KS48vlJ9o<#wF}tzpTR{8#8YK6DA% zoMgMNc@8w~uiX^Fi3XmW+*25X%l9~0H8Su7AOgxTkH)_ok}7zxh4BM%lVW=z5-yqf zf&7lqth#WfUYh!YAef2%kZocFqv?lyy@XT6#T`M`+5;39AzkiaQ0ilG6QLFa>;KjH zS(!Z?2p@8>z0Ab@2H>Sr6g9Ry5DEJ)^H{zUX9grm>UGa}GMIp~33bDmN27H7jht}h z6(37HK@q<|N*yxsQ(gz zmM|7fcGcO<0Db>pXW0XUeB$2NRb;O zrm=cfj(v5>F$eD}wKHUuG(v6TM$ha!xo90Pe^uR^Rr^uAtoDuZyji=Ju+Ly2^^#mO zsezDCukp5CTF}&Fnc`-{oB>VU*$*k1Uo6zSX_>=m#aMQFO8`vDGn#Z-7<42Ud53H)x7u1LpS-|hA1xADH;!-Tv3 z4`v>sa=`UR``4sDg#P@X1yu&Ieg&jkQY80*Nje49XMxhYd-jaFuCp#d=(Lyqtt?6{ zoy>vsoVw5c+u42D2E~pl#uo06&*JSk^sfsTJzrHvl7S$xIUVs5r4iOLUFfVq2BD05 z=nIl(_rb2Sy#rHjU{x@=%hCuJ)^!Joc9N%1YlI+}3fhUVj=Gkrx5Yj}s%-pX%fivN zNn>Oo^W_UrRDqU}6tAD}tPHD(#pjr-CF>uHCY)h7K~x!6ZZ(F+t*_`UhO#c;K=xkN z=>kkT_SkQ?2>LnH3{5M(V7pDbdxGlNi6H9s^?q?yZwy zfqmrV{&LS8LXl}zlYHIPHH^vfV8Pbj(#qP-G>K7tWw9Zo)4xLyuDRwaycGau++JX?2L+>w?-0RMUSFv==qPftM;U1`f&D2dG#VcCmhO_@{IB;e!gQj zrz}%36@;AF5wF89eg)}cOB(t8AO?;Nf0fIW2C!p;W%>QFzX)GcWH8nSFDmvx9pL4fg;RRYWXLIy9y^EC3;%WQ|a@5V75_ zIMIH&`)$PUJU9Wm+0)HXJU*WPAg!DLLFi!IE!Ww4P?!Z_d3mD_0b4#9Lh{Ot! zsEgQNiBJ74iP3uPaA_46Bu)P?Kt=#f%RlAI`=QHNRX=6g-id+|)Gq@YHtrh$cOr%?2hWfwQqUcyboDs}me+?w5B0T|b=y~|&3 z-O#ydlU%dA2P^Tzu=b!wj({T5gTI5HDzFQV7Ym5%3tO--c8;7}VZbI-Ry z^Yu&8fesCHA|2dmK~XzBaf;0y$6sbat>p2Nt49GdFhJZ9-{GaRYMR-Yb{EKx=tyIu zDae=Y>DWL$P|$Txm2L}~43M**R=$af3C{)&IX*cS=a~)eC>@?9XAiTd(j&!rEM0!C zCr3vy%R@I&UAl|FZYa7YLiB2z1XfFjL6g9?Y16}TFxPKWD1cDAw)(zSy8q~Sf@yKx z=oa3hGzPS%SPapIrGq1a;EX-=WC_^4n$UK-rRJO?_Jlgh53!6R9GRR^w6}=$(v@_Vp07Yhl zuBe1)0~D$5L?n4!UQrpS__DWSGr9Jy{IC|2mDz}~^*tsH(;DSzjtdVlH;;K^eV&8b z$=N~rN^h%Jnjhhbly9E&+2OzpFFlLloyZo){pfde-++CK=iMFqt!#yu06TZODOqy{nCL4IX>5cz3dX*Ae#-(vbgcm;w7^4XWxx296ygfcQhsD*&(&A{^r#|cXuT*Wr{Uo7 zG&C0IVwul2Vrx3}<@-8UK$+U?8W+x>9IQS$kb|;T7H7V<6d-RAy1RJ!@PF-9(%*Lu z0)k9<6^-7AbDkQkzot+3fpd(w=Yf^%9|mig=fE;Z%I<0%0}Oky&o5KMC}Y6Iqjmlr zC=BW+4|0dq(FBzFfDQcp;3&n$I8q?%uD%$CzTYB&CS$Kf0piNF1uV|Mr&PAWg{Y=+ zReL86Fdw!!yR|X?P4&721Dj+MEh-fsI)|zF0!jy7MA`cq2Gpw=4(C$`MM+vjtH09(V}**PqN|01eM^RKxA9@b;GrSb7+*eyr5rj0?YFLFu^MpMMj_%@ozsdl&j6KMMA5=XS$- z-YUs*W&SSS>Ama%D!eA-2Zr*Wz3TsKLkYYY;JZM*=bN!1-fm}c*4H;+!STUe9#1oZ zFp&x6ZoEcvdM|6hPm8v@@;568Sl}QUDQ}P6mWQqjHnIuU0b*A7M7!|H1wd_nJSfm& zgIyrtGVaEvs1k$rCgRl;(D(qtX`2yj#jjST1M#;?y7-`Ng6Z^LCP3ps-3-^?4Nbvk zRl7_xCi57h{B2HZ(KUH%-Od9-9zS=TGgTs59PWZ5e4ZcYse}B>;!*LbBRX7`u_S+P z8koV%1IZmF=w)FFpWKWhHi3I?>IC;})s1}`tQ|E=ZA}ei6oDpn;$|vdc{5nRBVHjT znJFgZ;hln+jn;coSNi1gRKHLr42@(o%^PVD%+I46I1W0Z9=kR#4)@(HSPsj|`?{@N z??JukG?_3XtbDUSF1UO!#`I-|rx+^ct1E4jnWu2dhp-psoxd~l@Ss$`I_IJfWEz8C z8nzWCwJw-}al6|n_3Z|$^?Y#t`tZpTuCmez&FoChkt71?GSCK5OX$7FJ5Lwr16MA?mSteMl7rPy z&5{<*<3Hs1Q!kaAx#}frFfT~;M8`-*8P%?6<{SO7XPL3<}wZzo|7zi zfVApIYK#DcA0{uF5AuPCE7*GzA1!aI5TXDj!Oefw}@^fqO0E4-dHw6 zQJMY7lfs7e(O0z;v;N7gGO4{D))-p@k9uEyK+Xin9YHO0^Cc>yXOu3WY|^Z~?T87p zCxEEvY5eBHB4Dij2E&2jt0YUGKE?`x|MgJPb?C;ooO`8q%CNMQT62uy0QBa+u_ag| zN#>Rw%J96XTyygD5xEr{N_u9RN;w2zI*Tc`*He7Y1~X0$=t=f0@z@CcC`wia@sXn` zPSp;|eR4@-G+XwYTZ6a3{?&l8j|9zyubJU{KJN$V|55LgI3x)wZP)&YNc|!G1e+$* z5Zp7_rT-oG9G)qwR&l6kxa)6F%*qW+0ndxoSq*xL<5|-iJR*-rTk#I>}Fc_1w3&G8$m3{1_|E2eXU*eq})Fs&OaG7RAkjCwKX$+!PD}7b)-xU zAW+x2D$;L*mYdtlBkG8|2WPvA?PdbqR)63o=J@;IyOA_zhBnVX8n4XOmd&HsxVS$$ z(tJ#^C|g(3IW9&sIV3DQm1$0vm6sIik-9cU(bibww3!g8D7qB}YMTE3{te)TZ%q4? zwZA-dD~o_fC@Ll5rxulCpxj8bqGBwuAockf;S6r8KtvRQf8w^09K3-hk8 zgDe;YR{81I;xB&ZYje|GU4TJm{+X|Nl|f?`9avRSQNX?u6*Ua$SJcHj(zD}X6Z?u{ z4gcYKUts#abbu=jO$8l9?f3R9s7B=8H2nm4%Q+5e!}fU3p)-yu>|;d^+Qv{>ri9f01-Kuk^)vt^8OJBes5Y7y%{*yBIpOK@tw)7B| z6(|R8s8JHg(PPy0Zjk9Z?e?#q0bopc28SL3BElFGCHkihaqity7omiX@C(4#Yn&a) z{;G-LgL%V_2ZqJjjO$_H4?reE$}SwU4H6mQcAU@Dc;Z!BfRdGmzpK9V0AA>zo>YZH zizqzwf)KB3AS^ubPdC8m)d|{=C#V6FT|58r>vHQ8{niB}`Q zRezUWf%XK-HrHSI1aXVedj*YM2YUg|uMmkh6i{ces7_bX?e2p7b6DcSv5x8te|72o zKQmQ=HWP0}jyAh&Cw?}>57sHfhvj&psce;XlCIhTx95+8m1b0&5PGx7YB*%H@~9A{ z+~<;{`SrdMrQJOU8~@rLvMKY+x+l)*C)(Yfi0HcaXx7P$VKGHT$$P84U+3K6zs|1A zIo7U70$}hn4zTlL&X(hQ^wx|0H=NfSS*1>J=X~ld^^XUP4PUF!9k`dsp@!{Be>sOOg^2>87Gidlp{!V`{V3FjloOvhw+}r zd!?`T)MU7Md7E3Q1Pl!g2LyXsV+5wQ`t%~%L7DCVk=S#`i58pKtEvWPdxL~DG<3a* zGp8XL^l4XBeR>!l9rokLj|ZRT^>WLd))K*M;)euJ4iC%S_UF>_K4Inw(}$xDS|!Cy z7p=s=mTbd?x8x`wEY_6t#HV{-|LROqFb6(#`(B3p4p0(EAEe@kRCOfPPvX~DMK6qv zHuEC6_Qh1BZ>XuO8@jk?_V%g;bb+^rs(KvfCoH|g0ZjoXO;a~P8us(40v>ai7a?SH zGisRllj*GW=uYK{yO8Vlm$i+`N{^25x#Q#G2YB=1HB`vsf?0a-PXrj;_Z#aIshs|K zVNEuWlvs~<@ScV06#}L9udNjn+=?m@Wm;HRxc@LAOOJVf3VHZikC|YcScwVe*}YG} z;IEeH4nK=1&yc@&adkC(X|A%rzt6~!cw*#T)H9m<>5W(F=Qp0=sy2dLVYVslF?vUp4&Qu@ej4dHXMiRABZtc)h#0^q9sK zY#(~?qFCZzPB_>W*Tm7;t>RIH*u;1MNXqn6l2(C-iPaPgnJfndZJ z1d;p~iBp7k6$7}W2o68(EAbfEs>t^!^$mpc8m>q_nPU_ApM{rYkn&hxq9-OiRVW3G zCqK#JL$s3V0STa|m+4_D0o>C&#+;-NZi*3p7VPHl#Nd=stSY=uH(79^nZXFk=%Wa+ z@P5K0*e}~$_v{q)!BNClX?Y5}uLF^?CcT&h1ZE-dvE{6h>o>;VyZ;8`zc!R%f=eKH zmX|p3h78u=<=zkxR)949FC#E!gBQZ)z^>2-4|rbf4uRtTERwl`(Tl~wdn<8TXlSDl zN>|4A!X+_7&j5uOFEryd({&LMu9^0P$nkL|S2HpD{V=x!;nDD}G)3vWw19w%Q3*&+ zDyo>eJ!8w!5I=I>rk16lx^~W`id3&E77)0G{6B8C@I@z{5xt5=wKfP5D^ymci ztp)qYF#PcVy9{y9)%OnK+4JX;mD>e5jSUUy!HVjRk+at&9UUD3uAxIg>?@2}e%U}(_2}#$^?*l>2xgEwF#W(M#yjn`z8=eG`zQs>17mkx zJ22dkTzh<0`cqDhqCz}%xm1Qi7&g9<7+BdI}2ihz!iMA9grl0|Z8B&tYIP*M{b1SB^(Hk?|G z&irPdd-i?qv-df7@8|f>IL>tUTI*Z&RlW6A)%(3Tf8cb9v5YphpkSY-X4spG0=o_4 zg2_v5+L#k4NowC%7W9X`54)$jUTxXn{H*oUspV-k_4Q+B=e70=t@@b0>9XCsl5Jcm zs!B}9bknWI)sK(A#+ABhe*5-q&y}Pzf&(l7s#IPb6Fal7wWmkq`Sa(05^HOQ9%=AB zQr+lVcCQJzKszOLzs}NxKd+*qg4^o}^-^_mOEL<$6kQ z#?2f2j~a*f%rhzAmcx>^jI(_=OPq*q!~9lOR@Z!el^@OJja?&Q)>JK&63XFQLmi=|kiep8xxo)Rs@-ieGcf0FQg+kDsFTXo(icjREha zO%DCHtq!jhycT}M^!a!%;pA7}F<=giwnvcxszRZscNv#BpEO+`(;0pZv8Mu?Vqv<# zt@rP|X$v5515;QocW>(%ynUV%4zRL2d7}>C*xv$5q!d`d902UYyJQfRp!#t0*U%jZ zVjwsb$`#p6XK{~NNr>5J7i#}-ZJrJbjEan+k=?=x)@AYIM3npXGEM!kxeBOK<^U!S z(AeKq?Z7+W)M4%uN~Gp$1Iqx{ow)=CU@l8>nd>|m5))4WdbSBYr_D)06@isD+g0f0)nk7fvN{=3+dB7iK}DR40IoQx~VU{B9> zQWXmW9pCf3*1ngCAiBz|!Iwp@p9Yp2gC-X`#yRwD^(j_EbR!3}&dWheSoO z9b~1>;;5lzBs?PlDRxM>_*p-OJpsP=V!0LsC$(e?NI8NpI0=#0B1{iwQjNyH7N1|V z-5mSz<6-wsTo4V%cb(1e8qTJmzmQR86u~3EXH9mA6ESjuR)v73rgK*e3?7PsJ8z5; z|FE@@q^d_&rCHxnVA0>z_2_Ks7cZ$#D6$rW>@pVks7W5Mu5ujK%DN>nYGwj8a0OVs z{E0pZ0XH|deRo*m0}+!w8jzKh<&C9!dwug{a)y4+`xaLeVf^ydgWamoo1u4a)puJC z0Rw|e7!SL{R6l@?RU|fMBAbC0(9lqU~CzJhX#sjHZG5*?BUvGUiXoES};mc54 zJ*L+7V`?hubDq@L#KgS44uT=n1WurBY-|p@tQThH*5TGziJI)Eu(v0?g<|pW5BLsEy6#iJd5KFR$+__lEu?k|Qw@MQ$jZOsq*LQGAn^ zXYtGy6bkZ_J`_NTzRrLbGVHjsdxNAorWuS%-m(s&eh%4fXoQ%wILgq z1>}_eAA#J%^I$G4hm?9$SYgkGRM)FB-avH5?CL&TL+*h7oIf=7eV#Q(lgj6R?nlK| z)igx&gQs)kM7He;vU(x(mze$?R1Nq>;QI3$ur|Tqg6hv-DrDUaS9_Fl-ID5v7F0{O z5skmv3#6m)e$0ofr%0);5Ls0$uV`T#EBGK%*ClwqYp{*si-VlMx`QSEr|w|H>jeZN zGl1vuT}vqllw+0QyQ5}=KsgcZ{Q%_z9B+U-q7RRtWcgoY)m03TH>kQM-$FA#a!xmW^j8;{E*K)vcuL zxE#36OK{e&-gt2q?H_*rRvUB^_mKZ_^UmJ$Esi=`+8<@HtM|2DXWWD{yH7MHsY=v9 z_sy8=*~4*}FWSZgO3oiaF6hqk^3tJ;;xR8SzT!$ZH8yzI7$eb$h-x88T?f|BB(2>I z3p=^nL}C6|)wbMR;2q}E$%&D2@{pO96aIVvUGF5^mJhh?och*`!T!hZH1-m-@~#8_ zp*4^gc|fy5)gvZ4`g`|R8<^6PQdSnjt~MDtYnse5J$v(lR4W_?rG_zKsjl?~jlD(~ zcLp-5PFU02hNn@3IzM}4@=yM&n`)QF5qIo3ETtkyBogbN>Le)a>t9^__?6>4ru#GBOSyRo<5& zA_8JM0B5SJl zH&x#u?hr-RwaC!Z(=%>(craFv58ou)_hjtV!J*N$b3;V{oR1C*FL7_|VM-UI280%qA?u(E+vOa7(28nO^QYB(n`eGdYYE-*# zT3%kBpvTC_wX0WqS5_Am-j*+p7{TVv%g+~zkB@&HoX{zGU5HUm6bU^08eAAd6a6=L zS7vCghh{qEea?G4w=v(+*ojC46;R5%PyWc}kfNJn;F@LX){!KI9yPV~5MSTGIfibH z8(0gj?j5)aMeGEH*3eRxk@M`}$+!~VjEKksAHGE|G411bJimkd+4cy^5i+&(2LC!` zNIM9ZI6p#{z%#(1a_TMBj4RAP+l%Ohz_xzsC*BbRs_@c^xX766M}vI&C0<4MO#7;t zmur)=4N0994M$36RL#=!iq^R?zP@Em$~%$W%uj{$zT5wl75KmDqa)Mb>^_aPsH}d! z8mF({)=GBFHxh)XQZHUU_wJ8N8JB#GyrU+ z8Kcruk)Fs9mz2~Gxu5Te>$1k6Rfs=w{;mxvM6B;e{aD+ZTNw#9uk!l%IY8H%5c^^l zu5hEhw+94&~0N$;DKlCi?MU@%+LJv$cRVTlag552rk z>)XcWF+t3?MH{`ADtcRd0DN10cxHC?bY+E32>H38rKz5|=9%_*c`K_2N&E}kz7}oI zy{O(oat11-(2w4^H<{aeZHf;N(mfWTxv$oVj~+c@x_ZTTJSOmM!;gs>X}@etzzuVA zwYN#;2=XTc_%AVtG&YtwtV+{O@xRqKFp|a|^l^9B>+@bVfuc!ohXx~I{+Qh@LIMfe zasUEMVjZ6cF~@3+%P)6KW( z57dHJMt%x1o>=oI-0&W@6=6mG#%OiOG(zX;52ql+?gaR2%g9=EtO^Z#gJ;1H{M@ioTpT_$Nd&0$T6JZb24Um;f+t^8K8{_1m&Xk}3`VG8&wRcrclZ zZ{SrWAA^VECa<$&%vJM8B~!c?B?Iye#!L|M4TbJQ1fQ~wutxtAT0{eBXn5*9Ik-)8 zFrk25r%LjE5z=}85`b(GXcYd$mK@OOLwLBKJxws(Pj*-n;lqwh??L!5fBzzI(6>eC zg7!Tmy`@`d|6NKxwk6jg>4;`J40$Uf5)jPqT(HmlWuKYw0)9#w{U3Zq{xY~Yty8Xy ztw(|*c^=Vlh`)a%q!FI9yx`iHceUX~Sy{u%ZAjuOD^)%WyX0B0kA}a{z4_tVL{f;4 zxP&i}2<|kT^G#Rg4}Qyqqc4E!IT0@jPWO0gANA-+=)a`e+>EC z$X32T6C*jf_@Z5`Tke79@$e|(QT94f+Wz&3GcWI#wjPnSkAx(D70TViWa9~xQ~YaD zkv~3paLGPTqU+LlGO-dmRb7~Qi~qvtXtTGx{tK}Iy0eTOz%FZt;$8QHVHHi(4 zYKDb550A6Ke{3C9+qxS`*b+UT;!W~2iXhXmDSG~xCzuKTCj0fe6)G215NsNLm9Hv` zj8^Lfsww}uCT~e|FYYqN>WeY zYHl2T?~TjnIwPKydh{JxVGs4Bw9jpALc-e59-4m5q`L`eStAaIvzKl-GW}Yz zQGofu8{N<;ukl6MI|ALf zFr^k6ZQ^+iVd1iWi9aMVy&M&EjRX3Sa%Frq^uZESNw3+d*P}v9G*4~^@0lH*+$j^q z@#E$5q9q0$l~p!*zm4K9jhFd{4iOVE zszF89^XB=Pe!G4=e^v4RS1$lSV(#VV#Jfw0i#~yVRQyb}7m`mikO&WZXfUG;31Ggn z7kczB7M{Le4{fG<Q^=zNtgjk82O*d4J=5XHmA*<&Y zkG)GaKoSjh@LC9OgG8qjrw86~Z?^YTArwQ5so$0T`}a4gSURd{C#mqhh%ELamS&2^ z`_{I%pSwLDE_-5q7YNx2_Rr7-FrBS$N3iW3l2!~2%*nMjgboU#$dIZofr*bt9eUWv znNR$LY3d-F{i_QeVox+UW{|)ferKgi@+;712Yv-AuE;PU(#Cp@m(xBNftVni46@E| zfRR10Hxe?sdobYuLYxsnMBUg6s<~{1y_tORR_4OsM zY`cJGwzjsM6Nw2>r|tLEu(Gy-N%QRNkAPH>ngU{UT*46Ho%C?KfY+(AMt7@fK~@ag zA@9?LVg5wlrQ5rCrPryZ_)ncWb!UMIstJD*18;u9B|_o1DiUyl=DHFeE7%J;Fs%WRR6mMA=2?Jo$F}q1>hBkpQ{61OYa`?y*+YP1oru&=!n!u!`r;}>5 zI65*CPE<>FLV~|oeKKmt8Hfm(eZeN!rzrj^gQ{%jeyS-b*dG7@kODBtC;$|hH zVPRqQz7vH095akb+LLXd<*znGNUJCZ9l@ zXpb9sbBladct%Y$Lf-Lj5JH@_gEq8=`nb`;FR;z=%9guM+TFTb5qH(g>-BWJeD+Jn z0UK4~E8_~UquErQ9LR<`jX(%;k;e#Cm#aO1Tb-X9&&c_>O2aX}5=s__8>I{QMpO7U z*{`O*saJL*ZtGSKLM!TQC$Vo*?;GqyvO6V`pZo%&Ad&O62>I**0m*6!y6~<4h=PJ6 z2o(*JI@XQ<=9Z}8nf3wtvbqA~&0KLlY|E-0u#(D*0T5@&sNM#U{E401DJ`UY0Q3){ zPe{RxvNbqtkI<6l;Et;_2w!2<14X%jq5bJJ>q$)019%e9Q{qBiuI;u* z>Aoc2T>u`X(W7PZA0FlDb}oa!NFH+S&@r!Z66x| z{yKQy5R`_Ik_DprWk!emyUaG4w0q4nWrz#~msz|2i`kN+n%*F>#K{_oC6o|Zyadqu zkh{CF1E4+>R2?70lBzQ`q|HGCnn&ERK&6(#`-XAQr)H z=>U6wF1D$WvZO{XN=KsB#2%u|YyJSbAAVX07;=?6IiwUf{%04^FCll%7z zhqK(;a@-DCwJRk{3{DG7VKg+(s~@jlF*ZIj?L9tnZgp`Yr}4=g3#prv@dl};Wfugy zrsIJkvTI@iM}8tA%6}Zl&wt)#7*9P<4+Bqc_~KvoCW0(nsm_yt#ihj6?)<|bWsRg3 z4UzmY|F5`WI(X|>;Vm3WzMXO+(@0ntkOpehb;ZHlyviVLiJ?V#xqOc4Do|b+IVtBo zr}?E66-BxMNPL>weS0(^?C|rAz?+$wnW6M#gF-$kz3n@J`XQAtqAAGK_;o0c!iE-v zd>^Jl6mr_Bp|1Lq{NC&;!S47wry@m~hb^P-HJDYOzGCd-Lp&?hVdHcGLnE@}i5#L?>&sg=bS$ZaT0Geo!8czi^5W31?5|2z{#Opm!8^2_hGyIUbl7C%>i2cFlO6yjPU_721Ul@k8h z{PON^rePapAi@>(bLON#Z3Kb30$L~$&j=bGNZd)Iriv;NTl!M_%| z#0G?zxAvisKpnLms9%42Bz_7dd9mDE_#c59{Gup>3=(yq_Y~C%y{EJYz|h@xqT^ML z(ntVhCk4=Gkk&XUc$){!cv=bZp?hY{&?tBWz3r%z^^s@H5>YxS^Kkx0)cH?-jHRu7 zkI@!WzU71;+_iglk)!iyT6ez5GI=A`*cdMTn9KUm<5zMn!f~nt%}Ga>m*ZGpPWkcG_;$MRZ*1Wox6_;xGe(5_&UVYS5YwvNQ#hAdeBc>6!$5_5F%b z=ErPJm;`bKD9BM1D30&-G6&J*BU%2afpQbB9YSjURhi^GIQioEufel5zdZj`b!rlYGU|2B{L&2%?p_Zd53Lrxq_JnC4U}F8fu6LA+9fn zTy2PKk(*YhWC+wZ^^@mOaCNN5s`{>JLF4KPIhPSF-FX=UG&xl}9I2XFfnF1ao5fMR zP_V4K&n8HeZ_5|_M#Z4A;Mke#SM$m3L?*wCeK)I~jGhLMqPys1KgUGq%BWULzz)jl zMu`f}FG?o`=@;9)())cj@yJb=ZDD#l`mM6P@eeNmS%gb=+sZ81R z{X4;c9mQaQEGIyIi1Z~jf3-A!l==ST5#%Sfe@lU6RcY*~NyquP!+(7)e|w3oiaym= z{p%wCx`FqsZ~`=WJ80ef`sEPPD{Dvl^V+NE?*H|<8+yrlyn?9C$>=yvu=sZctSHgY zERmmIf^Wy6(QpbF?3Z8P_;Eh@+q>RaLsV`}ll8~HZnTNI{k0Gr@zHyjWd{dJv^e^w zdH=cr0l=1$EazSR{$*ZjWWjZ5Q0~R6T5-QVmwY9_&@B*Uo^NDjIRL-WaS@d(P?-JK zdQe@1Q*W)%u)+TNhI)V@rN`A*?#uU5jrTpK&Oi%JOuP9K8IG;4FUpLyCx$OhG#H6Jh|HDRPOpQt`S#QoAeZ-4@ ze9i;+5O24xLThB^DJ|-|OeD-|@P|AW*6C{<{Vt>6F>Ne|I0uhSpJ|Pw9j34vf9(2? ztVRslf7HH2p2xsPg~!PCYM_WjU@m@R(6KFDm;&YSK(Y*yCK>!927KkB(dwCBOG$<< zfPG?<aky6|A$lfx&Ak_5xeC)i^?s@x?1&@-SX0q?3SZB4a5xo z3tR0KbQrrBwPv*FF6eW8I_nZGW`sCTwrC0L?Ru=)o9}748zb={Ed)`YfPCwgU|nII zVEEHSs!KOrJNUUqWH5BiHibHFZUl9X*cLnDtE)8*tE0)Ef}^l??xKB)3b>EnGSm=F zbYg9cNmsCR%Vdi-ON}sK{<1apGU8=%C`FleLDh$p2s!WTyiRkMGKC@nbQBk8p*ZP(#MfSccUv@AAx=qM_T3Hs+&!pm(wWk%)%YM%lQg27a zqw20S*^gD_QS{rmPsXp{DNqg_x%k$UyVBa<7sjOVP}swNij%g}YCo`x@rO3BZDspp zEf7b2kvDJ8H}qIbTp}H}Odutn&w&@J)?Rwq#=9X1Laa*+l7K0Uyj+Y~rqEzgj4CBP z50wOV!SXZ&;}>gq&rIcZCK^m+tYCm1X`5F7!0SC$e=b>-%r+y>mA}vh zq-!T+A9W=%3#bjaz!tM(gq`q7dti_blM!sRVdZs^NLY065wh(m^c_(cT>6TiE}PNQ z_!Lpmm89)fTQ*b49pU41oj1>WIh!z_L==7DWtk@2%jKCD5xW{(Wp#}987hE62-&H$ z2T;Z4g-`A=8W7Oqp%mC!7lmVPwa=W_Bmo>f+76O@c6UCdAYQsTtpqh-`*s5=@kf!J zKVH;PgfjQGv8pAfGEPFggZwIhDk@`UM@MUkPp7Kh^=^M!j1-Kyfi!<>^B_vCA1+nd zfho+_*Z8FOsRh8>{cT?w#p^ufM2#}-xzX%*9Oc-2z?YE>tqZ8<*F-!YYGS#!2Gfh1O%!ts8M$lUoJ9q8_RhOtrmAdzc&h&g4wU-nFJn^ zlOijP^%g{q2eNLEi_(~7*!x6ED3Q=LrTGOs8O^-ElNv-gDr&6rM0 zHHVz{lC9kBxof8}rkg$spYeom@eaXA!}h5_a(YGL!xq7j+b;p zycEyMGG^HWpHp6{VOK?_1 ztTm0L9K1e5lGV#iq&_X812-)W1u{}T^l%@*ox1c{YXF9Y&^C|-r-v6--l^B|SGx)M zR%#uG6^4GAeWdg$TRpC<@BaxK)!;$R*^}y%c0r#zH96H=VADI-sJTEsHI;$~7sYNCOO!7*ik-NnNAAUp zBwjKAk#Bayi3AimC&n)2@JcQNk&5byJRB{%8{0X>fR!;IP z_!h&ecP2MygSwLLmIc8fqbHMvNnrLWSyiHYLJ9K?BQ@U7OKVLrpEHC;8qac4lA&d{ zQ`Pd%Z1I>Wcrn@ce!rOCFXp8Gf6|WWI>Y1es~6z+?)lGmYgON}jA}Ztn~S+_J%zhf zt(%?};466*tGf$qtT)!jGJ@~zBJR%55re}b^gm!z1%R(+m-7uQ@LB% zdEv)r!Ht;B1%%#Ff!x2j;j+|zh{5@4ulX;oI4q8Tm8O^e%3!uRr*FxdcfB5~vshDq zj{7Uq{Ow1hj<&BNI*T045QH#EGawoxGtl|8@FHIMBGq|bsgN-Ia{vXpq}1gM z{QTG(1j?ozNREizGmYN3;Z}<_zNEWH&76@G)5V|B9rzdG1~M0zz@Wop;h!xl@*ee$ z109L?Wy$Rm05`i16cJtMH{a|`pDkxuSs99~$@WCW&<~1ZmL|J+6F2K5s@G>)d}{h% z>81y(mqDV|QYOC}5G?s05Gj0tVF>8j^@lbem**h>`A0eQ)%1P;wAo}(u z^S@!ExY*8X94U2-0vz*_JO|kMG;tCO^la@5`vfF`oIuDOy9K%GHfhs&#f7Y@nH}f4 z%XKeYlx_7icEz-+ZMHHn)<1!M@l&YgII3P5k;By^b3OB%AO z{`NqI4Yey~el_^f-^Qrl(4G}@abxU5Rt?~_d#{o6Z)l?4k zHJ3EuuM7=Wr&1zrTuli7LoJd-dVWLuD^IJq>~Bp2LZZnn%uXZv3 zlO@F7B6lBe75O!zrb+*mkp2Bd^)*%AuS{$ldFCCj*yZHgnXGd%g|rjzyVX8&9B=AXg}lS6^N+ldSjYaHa%nj`@4nH6<&bOxb_ zqqu)_&A++k-vx`Gm6hKG3&0uMWtD%!2K?_SSp2Sf0iGlKH!R>^HLTxNFVgBD2Kl>m z`nz=czqNGw+Y|lvME~LX`)?!qZA8C~=yxa3|Nospzdg}!PxRXp{q{t^J<;#RR)pRA zZ{FDYJDmNO31@8N59NRL0{r*wl>Ti{|78s7w-Lcu%F^v_Z*JivXyh2yCmm6`PgxVfOnkx{^7RdS#?r4&p?LNxc?e0haoyWWM<6s z8QMSJL|zq+6#4l?$=hlYxqVMt+*;87E@Er)Ny5J*C1NSjiXO;dAOEJ@WI1M3@+qSF zv4fI6P}UTqtTg{Ar<#)AN0Q;xO)BJ4jz>ZN#QW~ApGIZ|NWuz{x+up&k>WPpXd2L%;gA{=~f6z^rg0)m8_Fn1jD3T&eV61T<$YIuO!}s6|BeF>cZov@S($2qL5g9t4jii5)4imrn%`l@&%Jtw5Uka02@H2}> zTzd0S1IA%~&7*&*4~zuBK%@ z_ZKxjxC=; zZgnjEWW9910%*xNh*&DY6=M_NMkdQQj*y`RMX{?Rj_WX(GZOExTMiB=;Anm(FYUvx z!iVKfl6Udaj*S*KVb{wv-fbVz-_W*9s0)+uV^F<4*9N)^AxJm)=nH=SFELero(v0q zDn6C-HYNtf^-0Pn4VY*+5;RdowcY!sL{h}j{twVG-c{TnVO)Hkr_nR)@x((^z{Iwc zOd_Znh{y5^NivYPGtHYb(ON8_lfF{rJ9vh(>t>6!Gr3e+s=&wPx5rJJKck!PMl!*I zQ2m;sWD2nBDGYM(n>Vr;B`$}2Ey{_YOps+DCd&Ek;ZbsxET3KD#*d|AcB^=95%&zm zIV8XYOc9$DA8Axt4dv)=>IWkuj579p%zWO188CFLNEx9k#pCw=8OSwL)bu;+&rAq!p%#yyvRv*L2pWI2Gg1 z=OT@I?g#P18*QAoKw&3zx=76h6~->H*(mZ;#7S^-@bgxu|RX-$tkC!(&%G=0k9)`P9V5f_}Wjo4`ZsRCkz_j=J&P1f-vj|6oFfi~^%~TTg zJZEKF=q+BAcwUs7NYO~5^62+AE56zr>^PAn5*+EfJSAHbxivde<2_S;l%sd$!NFak z5h2$#W9p2SEFI^gjU4D22gAL`P*c@G^SUKcbB1ljM-rn%9B6DhvKS_0ZDHQR?jBi{ zV)e(Uj9`VCG){URw12uz9co}!5aoGbddgP{w}yYWL+Z!cXUDeduh-Lf$&d8S6^WVJ zFsyo`$+Jl&W=aWXY(7>~goSkf_Il*>s3z|t`#*IGs>NnwLegg8=)-%2&v=H>ji!r; zz|WDTjF!EUG22U8Vq906v=3#_YW8zZJ@&QQ8r>_`^NVoEWU%UbzA6KQ>fC#_92B3a z3%i-(QhdJ>|K_;F=ALpwHBHSiJqJNGkV3Lo=LanpmP41(Q8z_ebQ94vP5eB<)J_^HSnbcHRr)Upk)ii~2S1cB}_?py4pIX+sk$V#Ta> zhpdg$k^RVk7YxUYaG>4_?s_Yf9C5=s>sEwoWjj;+m!@Sizc~-t{+5Cdj`_r#qzGAD z%T&`6vLwbcmy#xmuH zy|`pgnc5k@vNz(HC}KT@zJoC@l#)!#zRZGpR0saz1?1|Hq$QWB#;4V zR`@lJt;=Rm=5E)yTOAXrIy+48?%#Cpd~@3cf^DZ`O+SG;V?`mum2+WQaV8M660*ex zO1WLR6BWzBjOIF#D07_*yKE2yI{{;mLB+2}PMDXX>8H~sybE;tW9^eY2Q#o`0edLM zB6wEiCL?pweb=*Os~h-vv!!!eym+fIvq!N-4GM=SrY;g3de`XGTg5A6L@Mazh+1A> zFMmLpdu2JSsopNam53>=5U$qP1&TP7iHVxBHZb4C@cVg6PIeJZSANtmgg9ERUC-&U zH)8u%M;I)e@(8)?v=Mgo0O7<&mpbKgh{;SPuc_~9(d_;zqV8?FT(H2RaqM0N;oK_H zy!>v^qUfwYoIoL#9Pu*0d_0ufCCLgU8HDNw>7gy+b9_sQXFIZ(of;~4)pl*`CEfcf z))s;?-#wbb0Td&`Fqywb8x`8_GV`7LKsZ5*QeigR@}9WQHmk5yQ8UGmp_fH3qMuDG zGN`KPNig>u(Z^D^)WJjY5;lqJ=?dAaOQ29TZ+G%R2tbQ`7Le3Ph)Dts7c;X}kL3!* zoe{1q-AB+S+%opm49aUKir4$rt9d76Xp8bvM|+FqVd~>ZmIx8j;99L@8YchP>>;ev=ik;d<9v?;jrHjm!7>iSrL7WX~99N44x}EhEV`l%dRL6hChmF&|j;)#G&<&OR^<2U*DV zPlP%-Iv37!`>A~69kZIfoT1O)c3~~(<@{)KT~C8fL)gAPTGjO!eg@eGEp^W$&TD+C zQ<>XMes+8MPczd#PK6xRD92Fow>1|pgV=*d<$Qu5s@QIQ2_JQS%njS1BG3LU(}d+) zLyxG8T{mlUt>qM)b<1Oq+DPZ$k}F^C!?D`xCg6BLf^LAva_s=b;!BJm~EH!&ym!gqMz*NHuW7V!dG~*45Zwp92C3bvVO=MSz6oWq9kFpfpOBLoe-R9 zrU~Bj{P=n%sOw2_Z0S8POHk@*(Q-RUjk52|J*Y%zeLl+(R#>}sW6UveVYVS;NTRTU z4U_j{EJZvnOoKNiA~wwTyEN64?Rnd1gWkiwRH$etJ}*EvBx$m=JrAk#T~8yiI}l(f%9_C33di z&eom_vpKQ^ zi)PMO1unC)XVsXQy7&YA_qO6P^d-1wTzzv2a)@vWVm=26Y`+4`+M!K@E+9Yao5Y4H zcMj4lF|#o%xTs-l!`HFCJoeyV5LJ;KR@i6Qrk~UPjLy*urT3J24<(}g9V$0(t^6RQ zOc3Ai#%RN`zwcJ#VtFaB4c2ozb;mEF9M9%T`(~12nIALATB8I|XHFcu1Y_WA0sCgp z3-g{=Ui$(5XmhfqriMY4CtBQy9fbR{2sQkhlI2)y9Ru%%?p&8qBHl(vnN7wXCx>I7 zjUP>7z%0gKm`pwKL5?Gz;_Q$6qy2Tef>l5$7+nVnI$;+kLaXjD9dPsiK49<1hfCGR zr`^p|oHpREaayL8y^?vwyMfS+Tj+k}+8X62IR4l(!_=#8`cKZ{GU-0rSo?NO9atE` z*}UtGA>134bM3N2?~D{2jbw10G4+dmWe!0drne9B+{b=8O23url$Jd3ho@Rh{pN~( zC3|7NTML0}oRxj!2O*?Jq|DTFIxZK}-zUH7(c_sup(q^oU7d=t_&6!C+YPEhTIeaN zmqO#2mk%!6G-XFA3>wZC_hEGkK6iUQIx&YKJZc!48jKI&8fpz7;)gjf(r`p8PbY_V zt(o@6+lU)~Mtm+7z_pF3xH6OzR47pCLkdIbzx3*KnTn4FZ+)@JxR^VoQ@P&jxJ6uM z2vL%#Sh%PjFc57M@!#N$6LtW{JKklPU(8T8q^{etbpp~4z zpO$kQZ@*M8+pls7J1~>2#uU7?{VnL6S{J0`~=g!D&;NKD6t$PQXWqT3u z4aW^zZcfL{7VECe6kg5gzvhv z?O*f^CJ(wDM9J;zj8oD31!V76fgD3kM1o=si;j={Mqd$@x3G3c7K^ZU;OGz?rhh#H z3!({|UgfL!Z-mq3f&@5rY)iiK(8@c!Y*8O%5d?;<2@Ob`ljzFar?`&$E5jj)%9(HE znFVqmg6I;*-1t-OfH7n|J7S_hrQpeWLH#59wI1dg%-9mvd!|e^)1=70cyZ zp~lgCtB-S|hnJpLNs^qb?Hq@W??g`xozGaDp1G^iG@I0uV!_mt&!PHwJR#*SzD-BRXZlG;rU{2TZmSOOQfQAA$$T)c%5UaSt{6<2w7H6< z&Do*Ci&+(^n3Efo>Kj`d$qYwi%tR5n03|L+6rMn$kXqRSdRryJXTi@17yjjEIPUTJ zP2MxmsP#9@jEfe%m$MZffHleUJ55%_ur$es4v_9Wh`djm&-@2j(B=E)U_BgGfc5Jv>5TI4jwOmH+URm6^-RQmT+}ON`n@r)W_1cUUYDq zZ5oT9R{F|jfzWQ_1%GG!De9X}Xj$IyB0$j6PjE?Ls`V^k~!j=~lQBM^3q! z%tB1&7mm`@#^~UEvzM=jaWrfWh9s)StSL$3zQnGr@8#1<+wxPE^>|{C&K*}I1GGi) z1pvF;QEd@wSad4=!;$d;d(S8vCEF`pSfR9;)Ul(vvZ*uJ*`)NDhOsk6MG0a3nk{uR z$zwZ%7kUYw#@c2T$KvpcgSLt7`s3DJ*1lp_p01Uy^!ok~Aqp?D?YKHNuhZan(NlcU zdv*Eq!qeWKj!fQ4)pTp%0cx)5_#Evk_828#brLsRiz~z{nwJua`Z!HB726*BO%FxV zGx&b0du*S5TGrqN%A9(!ml^AV;legxHfA;O<~~7RH{}v5_$PO13~p)nj}_JFk46_u zIV%+%X`ULZ59h>b;vZg%OI#j0)+3GG7<1e)aL+*9WTHO22e7MAQSZ@D@)TrkV*X=i zDBW9>i1`FlAGeOYs~RD0pKd?!D6N#Yc`inVmZm{uMkC?)7W#dyph!44-*J{#N_9LG zhj}jkHR)Bb=irC*>Lmk)ULG^bXMQ&iRLsc|_gwU%aP(p$7%P!-yh)*?W63YJCFi~$ z(kYT4s~TG~ogMp0vs7IopAOGiAbRw#YL}A{?&bQfFL~Z@gAFV}f!bP4+LE92nyJ9o zVVfe_cAq>x*I};r_{PgW0NZurfTG3J6bn=|3@UT8wnK1k9o$49`hR3}R>vF?m zqjOg9(h6iev^gBw7A3&^9l*JDnBXHkHSt+eS~1o3uUhywrx>carDEY!a|K7OW7wXd ziLkcvLfmcZPENC$D0?NXl){BNVWO|XhL#{!GX+~SE8sLk$sgG3y;R#H)|XJ+MKC0* z@|Aj4EWU=@yy)Ay_4y-V*pci6>mvX_DVGr`&sa+B5XFZ$N$A zNcy~@@t zb!L_K`Y9qC)H)vt@dq(5@I6Q=VW<(FRlK?u6g%O2F(KD-*lu=xrruG5_I{RWzSRmv zQhZGR;Xry_a><~LFUwOpI*FY2?=|05?3mRnZJ!yI{a}YHAGREj*tzxP@g0A40ZG69 zb2@8%PpfTeB);&hU!xLjUYP7z_w(HBGNXG}Ug9ZEfoc=+m#v-YzR5E)<0a9zQ6%BR zdN;UX&}04atX{>Dh9eyp*RLxT6?kOwn_lo)u2`na**Yh?oN8#^;XM`g8SWr@R*K3D=A$7SWH&jm5m_gS;K`4(ny_yl>CqWlz5nYG*3(*7F7d zHInRJs=k*OaTiLv#eMMcPZ{C>dA8GJC6_{d#{wv;J+>BfYOY2qtB^N<3LcN&;zHZ( z*Geos9IRSBW4}~3FdZ8~h(c`g#PT2i@ZznlvmV{~pz zFskl#nlvi1uh@f%wdagG2%Hw5Rch;yNKQGkAMulPjosZCRec&%hDAiz3QJq`%@0Svwzx?kyCk?Tue3eSi{2j zh-+vZJ*Bl*T7kApX`C-75YtwIATUTHstT$r0<9n4nXC;*x+?a*{#w+a))(y9P`Ws) z%6*(VH+AMq2vI>6wN?)mgz}mf0x1p_!S~rAANt zi6IF^<%Rb{HFW+riEUZjW5Ep`Ln1|fYb{&eo{M^=_ht=SQU|U-Z5Wo7Ow;GVZhq9v zi|}+Qo|O!eci{s&b!hIs0TPxV{0*|;&*5+zby5=OZDEf(&+Fu*vuFrr${poLX4@8> zrOrDSB%yo+Cy?kTb%<#Bji!a@^-^7B({tMXhCaXp=jF^L^0V9OK$XNgm_ z!XUm(676$2^pJk*iyapzJsPa!t8UG&-0u$w(f?4%?NG5EvbFdeZ-r%L*CfkZcwycJ z2F&h2z2Uqvr(by(hmN)HUul|e3Bj&4VmYht#MoC9Ck8~^y*?IPx;&KS4czmrqtF?z zgek{(6WW$kCpy{C+8wo{iQY>tJN(B^l^1y6n09zB(-(228LfP)z5Bk$KCEcdWQlr! zwZ3e@@Xe)%5dQDBvNl70eS55+UUltre@IbZ>D&MT1gbsy{1p4NSL!9dv8LhJ9eB%E zs0UWg_Tky8IM&PBTk7;t{9*NF!HmOTR^I&G76p{Jt#&F{G#zBwI*EK^V`-b~5 zZ4#IlGofSN9;GR33uBMf86_)Bm+vlngecDjm$8yR8!K73pYArxalyTBD-|Cc!m6Ov z-`{2v(w0$fe_z&2!q9cL?8f*+b&zHbN<|U3x%^QR8}3`Kh}#)!+7+L;%7xEhl75M^20h5g!185e%s)MgmC@$}+wU001ZjD{g*yZvuMiJiTRPIyS zwcLtdCc-2bMAB+n_i5VSKUQ(QMr`*@(VM2AJc^OUwR)^d^LcokGb}wbWA0^VNGp+s zGA#|C`Sv4T#o9B-u-sUJf)ha4K~_(r@)8N(*|R#-Llq##e0RAGyD>~SI}k_DED}G1u;ujlt-S^_Ys^zXf{;1SX&owQb664iMorxV&bS@<$5IxIG zye{i{)!BI!Os#O7yvQg{F<59{rl>?92-OGzSS}0;zMHrpKh~nzf0LsQlX(2DtnZSq zBE<(&%;IoLQcVa0o$QV@Yh6H$w7Di;6ok12>H*GcWgBc&V|vv`Viv-VB&|~qn5ilU z?j9X1F|ZcpS2^Tcq*G*#>C?T1ebDgmPLkPbw`|DWqJ@v6-~9$_XYm5_V`dwBeR8dr z-JFa!PUabfa4S_Vh4<4K74;u|t4a2GK>@#T`yf@U=~&b}C(uGJ-%f$rnH4*U6$(cs z^PWxS4Y=4(<8TD^GNRa7B9(h0aV9sS_%nds3X4O;;|5-fGbTV*jp2r>S83F<2=*H% zU-T=jXW;i&mH3e8Wt@&m*uO(BYg2r&ar2nO$p?O~Bu@47n?8!aV}ZRV+fN)e+fc<9 zNpVJ3d&JNef8hu^YV$3yfgiJHV+^yVQ^r9d=^d?e5Z=rlE zxc9$2?|0twoQrd}FSnayWv$;_bB;O2EW#HJZHnfM0T5c`p)Eqs+iE-$aPT%e*2wE_ z4b*}8cmv?ni$6EFe&REaYqD9^_!cX5SI|SY=8$LN)qcLi3F8cz(LvM3@9Asp@YMJ~ z_q*M(V0U8jRmN7&BXysNk&nEX%ax`JZ#~A1tE=XRidb&?XFi+a@!i~JrvGE!61q2x zZr~z4L_FBTw?BD=PEC4DAMEKlPzYOm-Y~r%1h3ecaOtakY2fANaGykR@dYQ(>0a}E?5BBpOR&jG5Xr72e zaRybU7UD7!;+QuF(w84+Ceo=n3IuZ*A$JF7_8N_j#ZOr$pQpqwiVv^2<|AjYfPT+w z2PAnf$Z$SjGG*gBMoh+Vf99iDyK!vzOd&&au7{fO+Ulr4Gnd`6Mj1}h!;i$#;K(9* zr`BtC&Bn|F+dH$Qmd3*X$}BRzM3DJ@c-_McQ%7sKn_wRce=XKw;a%gjv3HOxMs!Ik z%%t(rruQBok(qipo^4c5T@%r#cK|DxG`l`B{=KAd`3G|2G$lP%IXn|P5A_SULv#X2 zxMm~*nQXP12Orw%`3Wx!LEfjr*v-1(ATQ98w+fyZ|j-dhaJfdiIuN0>uTaPO`;?L;7ZhU$Btt*AEjaun8Rllrl0r@gZ zX5ub4MSI4hs%y5m{@6Of8sB{zBR_T^>2M=~WEx|Lz4u$S=<2BqBPuRt4&T5^IT{21 ze&PF1p`P0lIkAR_B7T6A8uIuRH@?EuQE~170HJ|0GvD`0!OV_V3F*}CE5jXE0gtFchjP*^2Aq(LF^{y*= zm&P;npy4gpqh-rFd}!5bGvbsZXyDQ-zf$O_Sa@0KwaD_5Wh73L1U>2LU73yj-gwfC zXn{9Y%=m8tlr4uTqJ{38@a4wZ9fp&Q*TkfE1bFjkC{F znyLWU4jmW~VVm$}?zd8ji>Q=q{KvI^xMb!xF)6Nz6EifH3c_+VJ5Ae<7zHGnIbO?7 zy@0QGsuI`EiDrPFUs?q=Lk|aRcDNzZsE_OZ|*>Is5|ThAvg9KGXq_KSVSnS1*%- zpn<>l5zpTwN75w>-AiMDV4k8iBuIK_u{1Zhy1Z9;Z5{^@I&B+D;v4AWD%Xa%K=Wl( zBYFZSV!<%>D6~)t=x{BW3FSEzIzEcGx_G8MXU7)Xid6F<+Tg0tU)|5ns*}Xf z$Jt*13X#TNf#%^nbu>nY$HL9I{)JX`vE;s1XrG@hpCU*~Y3tJ~%>sJ1;&N=V5i0=+i+9wL=!uluyHcjCqc zV;~;RG#xVQ$2R#D*6obXELOPE8Q$2ga)J7d0_rIDWM{15ZnbD#(>|O+lDv;vsjdp(nHk5ZoVllR;2lM*);M! zWh5o|O83CYBrKdOJp8juY2T=x-crTZhViG`;SIg6wbO&iUEh4xV=+uoUj#>mq$uT> zRnZB?cNB4a4aYsWU!G9+ZP#U2v_%E`0rae?-VOp96S0PXTeW|eyB^eT`8u^`RWbO&UW-J9X`mpD#Mxk47_Tk8X-bwwu@*qtv!4uQSld+>r$ z!eF*=hP`5q_pLrIUBc~?8{EMDg6KFo@A(JHGo7-=0^NZ%Ie7hRo?X*$y z4d#N(%dAv%8HNch$Z_i)SNiqEvVz2g=bMJL+io1+bRu+;5$e5)lx9)@10Ny*HDeYq z;lUu@3LnN51_I+ZMYO;o%l1nIFxO~Kb=om(-Dx$VJLSD81z= z0HDQfY~9`OF0l-A$cP;rBd)D?m@HcryG2+lX0u!arwwg{z+Q+U5Mr=5V#?4_Ok%V zJW;^BjKWChO(oEsO`~GeCTiN|q&3SRLzMWp-r3QvFVCK8_E1z?#KG5^W+CingJmLr zd5pTcMxxpjCr@XTjdAm7E$hL3+!vn>fA8sIFVUXb`PA5FKa7(25F!#44t>z|NKtaj zPv5u*VzgbXsCXvbNLs>mVjM3Wf9_3gJ~mGV3I~Lar$LgkiP8V}e^&&h;)q z8ok8);?>W5Q<$wQUxjOc4UwN&DK%!fJ%va>qUn@xCunQ}M8f znT0!R6}o597mmpjJB;m=BFOa}?7cHGGA2)CW-Ea4*LFOWbgKozn9otc7S3nSww{H% zVr#ZJri?z8u0JJd#{f0QB{;>xzlvKw`PBk z1o`azzz2{RCF+GZ!ZN_1lCkGZhGFcH)f)+1xF+4JEg3uI(pg(>To!nIpEc^zKMMqXy z_)@69)ynXOfo#zuv)?>#myHfZV9tK_^)KXNF#R%82J5TKYLfxaxc!l}5_NRgO5+R@ z7^Rx6(|)f3x^!SJ*o1g*y*!i?TH;JS8x5kXnf|hvd?!9{^E$sbn=gvZd0xDZ%uYHy*$$U>Ykzh>|npD{@%=kXu+K9$J*lM=pF%~LyRwd>IF zc!$?KZQ_+3#SYs7eO@yeod@!iU-;{9V3(wD@u&@nd7|j%DxS{kPC$|E=?P=aW+o3T zZ^%;VrL#}8V{!USn$^8Z3!BuhjVY2$cvtcga3!|SK2wm)Lj%+^(CecWllgkWwd01G za8M#HrSpJ2onM!9TGd5IyWF&l2mP79u4I)?ZK~IKTI_j-_d?D0aqpg3EJL=ZtR2Vj zXSbZ_qNB*Dw4j%EL$OIGHBwcp5IPHD5SI*>1bV-69G3B%vZ2hJLy(*b{2M(~M zWC~{rZ=a`sd|lR@Usgg-TV$}O{H=oX>3HSxXyL4ERYa=2_)LP^)%@K+T9$V{SGfk9 zsQTL0(Bk&n3@ddTHwl?|#RI;~k`Ve+EverEU3TKR3VKD7n9c+W$fLaD$ub>9X$y?{ zpOjPTsCvQ^hx>$x9}@M^WEMEcPeFU{q8459nt9kCylzpN>LVT%!HBe zjIJId&uC0Ova@q0g9^X5RfowR#7joG3t=#)RCzJ6T{b)Z34Kpjrd-d&=0pMCh6Jm?)+4Rn zfOtTH0x&g2Y3%5k3 zf?h%o6USxBEbi zLF&X44ud>azSN+o-%HIDlDxtVB@u2VwujF9Ir(bX7s(Kay!og{^$%p7&G7rk#txUb zogkfU<@i-NdHt4_CfK6`a@Gzkj;PW5=#hQR(ZyxktkBXXmEWYh1iyyeSq?;C%)Q(% zOvuU_pdJ!u@6v3;Z;~XR?tYOoI3Z0_MP)&=aD18w>C!Xd(E2X^RJNHE1Zx>*VNQ{d zav9>pwI;1baeAG08KhPYkwy3ohn0)5v(;FvxEo#4E+F4$#O!mA^$bnb)%(nJk>;Yb z)T;+=>{3`koF#sH+oeH$8dMKcq`b$Z*d&oiq_5-=46#bQcr8vY6b|O}R8;91kF}h% z%!s!;b3K#|U)4ev9zgUjfP2eJH`r!TUgJ~H^7_?e=D{O7DHvEIdx@%zT#9`er5Ssd z(X_5=L2NI1Mv+9?SmfN4mR^G13tS2~_{+DHJ~{U-ODT>P3)k%rfdBm7MEp+=7&Cy55w&av|MeuY9=98LmcphMCQ6I6X>0FTZt4mxM5X*pl&+nR6^&^;`LDhcT1 ze!h8;QP#v)HC5#h^{}bI!F#qHi!J&oMbr9oIfD8qlw691oawy~D6u>RBh4KN%#O$M zjP2UMbAB}`^o}*)3Selwn%hl)#_s%+iL$?{bQCpm|B}G z<@ql?+zE5mOT(T7(~%Y&-N>TuvnqM!mGRR3F%1{;8q=wH14EKKS9O*9G*YicKI5IB zjvyTQJ}7g~!6@M{7R*R`5%k9ILpuP#97bu6%P8o-m@W>s&8*BXy~zE)pw5dsO_mAx2YM&Gtz(P{~BO)`4xpXk_WvM z*oRD){=z0BYnXQ-AYNg8wY|3>UYU{0z+!H~TWK=kLEb<)v2$VVY4v!KNwXQaPC9wK zYq*C&X2Dz|a&H20HqCJS3lW0D%(dw@56GAch!<`r+)2(Q>&3*<~`lC5Ham=C|ta zs$KHomkoD7yRUDAlUu7P+KOaVjzFZ`?BhkH+ILNkc4+P89|Y7pI=3Y)u~4hMnCFNg z`7OkpCgabZ=+kU^G33%9_Br>2iBn&Y%Kg44_poD!yP8d&iBeMR#K^NrWS>4*I+-|R zzyF1EB&VR38z~%i=clOJz^JuN?KDmd`MYvzm?`XVF~;G2?Ti8Ng5C@G0zq_?JOH(m z221;p1nn9;rQbY=Ckgc~SDy7qt=PIcz_~s^l%6FdwX~>noG!JpR<+2JzWnh$cf?)t zZj0M^V6t%d(Sc+>P+HxI5Zs`@YAJ{WAjSu=zmpTnH~tjgIj@%bo?P62DLl!G_~0g4 z9L2>PkNL$?C-(FEwC`@NQ374(On|D7NbGmxmOq_6ARkMe?qnIp zWtDx2-grzkFZX@#6o<{=YKKGZJ3T9&RtF9mZfrRA?O?gE=Ab!%~DV2Y0;9!}{z~Jwv&qKFPy&gXi~` z7zeeT<2d5QhLb>0?b)o}vjjDL)2S~PV%67m=pC2V8F(&6H+%T`6{2mGLVAW|wX9xO zX&LPoH$COzTpyL}QR&fxrKc*1i1bE}??3XBgNIZi(NbqQLEFz)6j}CIKt7gTWJ*d= z))I@W9*r_-_bHZo1urVC;uB5=yVOI#HN6)-KTd(ipt!@y&5MK|Z&3~*n4MbY#OSH_ zZBuhMC9$*uR@GZ?<*7j2?zuyGhz0imMQFo)G4877E4Y)#f(DRk&+RAWKg5R^@jQeQ z1x@lJCgMMt=c2K|?cWf9fSCRlc$6ko&#B1E>n2+;^j<^TAHKa_SHGgkn)m5b^X~qx zHpkH4e91IOTL@k#NMOz_)^HIX1_F9XM#Em7b1` zEUA;MLPGzOnE6Y3AdyIyDaY+z*Aw4lpq|7KNcvN?Inr}>-4jEnxTuW6Gj_F?Ekt>8 zTsPAnT{-2vY#-n2!s#;2fJb^J5NQw%t6ghgLkkn7<}Ft45Ohm{Qpv$bbNlSvygc`9 z1r|+R-Sy~eksLuSFNqeJ?i5d-5bsaeah!1oONad6*1bF9&LKPQz#VW~^wsNc660Bd zIi6i1PIH-7H7-UULy(l3h#M>B@!ZG`R?8RE{s5rJ?D3;x zvVwwwUa60#lBK8fZ`e$dWc;&r%Y!Ud=HKl{IJbKDCq2E?tvLVW3(Q{wBFsrJEtT+H zWhI*9HKCt@fx%?0!_;JL0as6?VTZR^WaE6)V`s_?9%%YEOrDbcjrG{nyMzoMrtlswd4Mpyt_>H8c9bnv+AFeK*n&$!ZO=*YJWVd>N-o%J;o||RdoG{W)v4faEED`c z2gJW#EvsAjw>M-CIf-(gdAb?EO@G($Je}Hh$sB01)73$iO5Zmj@_GX5M1bAk>=A3jxQ4vCemDBNM*KeX~IcX8^S*x#INht^qYA5L$* zWnZyMD~;{V(3Qo(5If%q{BF)K(v+4$w|lsz1jE@Q0G~k%dBg4$E3QcBPis#f@rdam z*aQiM$coo>03mnp$Bn}6JyaSeBj40)UsgA>kM9ZC#V2*q1r9m6EBRd9v%8JNL=ex& z@b=Q0Ni!w>@tc+|&`)b*fZsg+55GyR176ZAteGV;cV1CDob2sYLVTCstpn)UN??;) zCfDhJOmssO)dU{g&^gpBtAEtA@b~5lK^(b$LV%84mz0Jvm?Y=P_)F8kj^>=f+zDXe zEWma`Sl9_X=Z@SE4M9%z2ZwzVKW@u{o#4KrdfUCFsQ^=3$ym1=g8(J*nPoz4ItiX7 zo#E>JmXwGn?}xJ-b-SkM1S?vdCqvRebRKGc;;_*{ zP1g?7U+~LH2u*?rxRFuEztqo%UhKU4Y${p)a;+Y0+R zH6@q_itcw^|ANv@DQl+*+F|>_kR#kn(V(oZD7?>|C~0z`q~ZB<#Nn}2ls-2}KraOp z(8PrPFFwsa0KnBPuq3Tjug|}bt?|R!$ViCaBlv>Z!q1BBC}o8&!-AQ#+(vbn+M_W< zEpL8a4JHKlk*TUKd|Vr%>XL{T-CRIE7v$MwIz)jO)Z4-oEZ9X=FsHyE+#% z8`=M$cip>o&Lb7!UY|S>@Ot??vPQpbWdg#}AMQ*C)A9!W5JWS35y=0p3Oxdh%IsF! zeDZ1P@zE|FhGNpAS!^#m$kxj0^?1EQDioA++3__hiUFQ0+_i5vUg1qJ83}`9Y+Yv5 zJatL2zPnvUNBvEZdATH^O8hw+H$L2-h4XltV1U;Dno1QjIgf(^QpyF#I}bkU6a0O| zH69_jTnE2#mxBEKU2Y!s*i8Pe`r}|y28w&yA?4E{mF7?4dj;s;{YhB3Nr8j~W^nYX zp@oIlkP4A4&$!J0Mt=1s<_yY%Sy@}#G|m5@n=ZO{z31*;VBr;IyS)W7*mRUYD*e&V zK5hB?*;AveH>v9J3BbJemm#Z@PUeS7!CBpWrj@EHUJm2%!UNCIpc>FKZYs!Eh8OQa zyvPTpacly4+mSV_sF{9A>ti-yiMgDm!6Vt0ip|gT&^`~S)FxxLhPo!&jV@)2+~4_( z#?I2<_o+jI~D;F9afJm3z<3`Jb@%L1Zaq&7BQBsKIjq ze-f3=BHU>F1%R<|RLF$_@2trzGw?z@1Ut8Iltk)5oNFzWkX>4N8>F>BpLA7()agj< zd%OF7lLkPOu|i26z;YJ^vNz&>W-1m`SC0{U1FO>kY;ENE`o8GkUni=mp|Lj1YLeuu zG5qtuL|0E2vp!+uMKB#Q$A($xO4gB6x3HOv-%}IwtHaaoMk_fQxwgt(wc}G-4`s(? zNf`>Q#snE#E~T}1)rZy}#{FfFf@Wzc

uNi%R;p!b{0uhX~ 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

m6e+7zt+u5I5cq2-!RqD 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 z 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ξ&nxej@l9^k-49cCTwUlNbTZW2j@ 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
V-2e}XZ0aAC`Qom$g%G;FAA}% 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 literal 0 HcmV?d00001 From 65d4df79460850daffa214bb3451a6efa0698a67 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 7 Apr 2026 23:38:12 -0400 Subject: [PATCH 14/35] Ignore test-output and remove test logs Add test-output/ to .gitignore and delete generated test logs under test-output/logging (long-lines.log, moonlight.log, reset.log). These are ephemeral test artifacts and should not be tracked in the repository. --- .gitignore | 3 +++ test-output/logging/long-lines.log | 1 - test-output/logging/moonlight.log | 3 --- test-output/logging/reset.log | 1 - 4 files changed, 3 insertions(+), 5 deletions(-) delete mode 100644 test-output/logging/long-lines.log delete mode 100644 test-output/logging/moonlight.log delete mode 100644 test-output/logging/reset.log 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/test-output/logging/long-lines.log b/test-output/logging/long-lines.log deleted file mode 100644 index cb1a034..0000000 --- a/test-output/logging/long-lines.log +++ /dev/null @@ -1 +0,0 @@ -[2026-04-05 13:00:43.210] [INFO] app: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx diff --git a/test-output/logging/moonlight.log b/test-output/logging/moonlight.log deleted file mode 100644 index efcd447..0000000 --- a/test-output/logging/moonlight.log +++ /dev/null @@ -1,3 +0,0 @@ -[2026-04-05 13:00:01.234] [INFO] app: first -[2026-04-05 13:00:02.345] [WARN] net: second -[2026-04-05 13:00:03.456] [ERROR] ui: third diff --git a/test-output/logging/reset.log b/test-output/logging/reset.log deleted file mode 100644 index fbea71f..0000000 --- a/test-output/logging/reset.log +++ /dev/null @@ -1 +0,0 @@ -[2026-04-05 13:00:03.000] [ERROR] net: fresh From 6c864af31d627a464c3549d98707ea0f61deb33c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 8 Apr 2026 20:36:30 -0400 Subject: [PATCH 15/35] Copy vegur font; remove file; update CI/deps Stop tracking the binary vegur-regular.ttf and instead copy it from the nxdk samples during the CMake xbox asset sync. Remove the committed font file from the repo. Update CI and docs: drop mingw-w64-x86_64-make from the workflow and remove the failing OpenSSL debug-logs step, and adjust README to use mingw-w64-x86_64-cmake (plus minor formatting). --- .github/workflows/ci.yml | 17 +---------------- README.md | 5 +++-- cmake/xbox-build.cmake | 3 +++ xbe/assets/fonts/vegur-regular.ttf | Bin 21116 -> 0 bytes 4 files changed, 7 insertions(+), 18 deletions(-) delete mode 100644 xbe/assets/fonts/vegur-regular.ttf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd19a20..b4ca6b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -110,8 +110,8 @@ jobs: mingw-w64-x86_64-clang mingw-w64-x86_64-gcc mingw-w64-x86_64-lld - mingw-w64-x86_64-make mingw-w64-x86_64-llvm + mingw-w64-x86_64-make - name: Setup python id: setup-python @@ -146,21 +146,6 @@ jobs: tar -czf ./artifacts/Moonlight.tar.gz ./${CMAKE_BUILD}/xbox/xbe cp ./${CMAKE_BUILD}/xbox/Moonlight.iso ./artifacts - - name: Debug OpenSSL logs - if: failure() - run: | - for log in \ - "./${CMAKE_BUILD}/openssl_external_host-prefix/src/openssl_external_host-stamp/openssl_external_host-configure.log" \ - "./${CMAKE_BUILD}/openssl_external_host-prefix/src/openssl_external_host-stamp/openssl_external_host-build.log" \ - "./${CMAKE_BUILD}/xbox/openssl_external_xbox-prefix/src/openssl_external_xbox-stamp/openssl_external_xbox-configure.log" \ - "./${CMAKE_BUILD}/xbox/openssl_external_xbox-prefix/src/openssl_external_xbox-stamp/openssl_external_xbox-build.log" - do - if [ -f "${log}" ]; then - echo "===== ${log} =====" - cat "${log}" - fi - done - - name: Upload Artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/README.md b/README.md index b505d48..d0e3c96 100644 --- a/README.md +++ b/README.md @@ -36,14 +36,15 @@ Port of Moonlight for the Original Xbox. Unlikely to ever actually work. Do NOT 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" @@ -211,7 +212,7 @@ scripts\setup-xemu.cmd --skip-support-files - [x] Enable sonarcloud - [x] Build moonlight-common-c - [x] Build custom enet - - [x] Docs via doxygen + - [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 diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 3faa551..29d7d17 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -37,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" ) diff --git a/xbe/assets/fonts/vegur-regular.ttf b/xbe/assets/fonts/vegur-regular.ttf deleted file mode 100644 index 1299b45525545da6a3027a99437f8686fa679b7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21116 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 From f6c2fa782199d4703320d144956237a5a6ec0fe0 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 8 Apr 2026 22:12:31 -0400 Subject: [PATCH 16/35] Add host snapshot, probe queue, and caching Introduce a lightweight activeHost snapshot and hostsLoaded flag to allow unloading the hosts page while preserving host-specific screens. Add utilities to remember/restore selected host, unload per-screen state, and route lookups via find_loaded_host_by_endpoint. Implement a thread-safe HostProbeResultQueue and refactor host probing to spawn per-host worker threads that publish results into the queue; the main loop now drains and reap workers, updating host reachability and persisting metadata changes. Extend host-record serialization to include cached host metadata and app-list entries (with percent-encoding/decoding and parsing helpers) so app lists, artwork cache keys, and related flags are persisted. Update persistence and shell logic to merge activeHost into saved hosts when saving, lazily load hosts for the hosts screen, release page resources on screen changes, and adjust related client_state/shell behavior. Tests updated to reflect the new unload/load semantics and probe behavior. --- src/app/client_state.cpp | 194 ++++++++++- src/app/client_state.h | 12 +- src/app/host_records.cpp | 243 ++++++++++++- src/app/host_records.h | 5 +- src/ui/host_probe_result_queue.cpp | 68 ++++ src/ui/host_probe_result_queue.h | 84 +++++ src/ui/shell_screen.cpp | 322 +++++++++++++----- tests/unit/app/client_state_test.cpp | 112 ++++-- tests/unit/app/host_records_test.cpp | 40 ++- tests/unit/startup/host_storage_test.cpp | 35 +- .../unit/ui/host_probe_result_queue_test.cpp | 107 ++++++ tests/unit/ui/shell_view_test.cpp | 19 +- 12 files changed, 1092 insertions(+), 149 deletions(-) create mode 100644 src/ui/host_probe_result_queue.cpp create mode 100644 src/ui/host_probe_result_queue.h create mode 100644 tests/unit/ui/host_probe_result_queue_test.cpp diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 8784f8b..f2ae70f 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include @@ -127,6 +126,100 @@ namespace { ); } + void reset_add_host_draft(app::ClientState &state, app::ScreenId returnScreen); + + void remember_host_selection(app::ClientState &state, const app::HostRecord &host) { + state.selectedHostAddress = host.address; + state.selectedHostPort = host.port; + } + + void clear_active_host(app::ClientState &state) { + state.activeHost = {}; + state.activeHostLoaded = false; + } + + void clear_active_host_app_list(app::ClientState &state) { + if (!state.activeHostLoaded) { + return; + } + + state.activeHost.apps.clear(); + state.activeHost.appListState = app::HostAppListState::idle; + state.activeHost.appListStatusMessage.clear(); + state.activeHost.appListContentHash = 0U; + state.activeHost.lastAppListRefreshTick = 0U; + state.activeHost.runningGameId = 0U; + state.selectedAppIndex = 0U; + state.appsScrollPage = 0U; + state.showHiddenApps = false; + } + + void copy_host_to_active_host(app::ClientState &state, const app::HostRecord &host) { + state.activeHost = host; + state.activeHostLoaded = true; + remember_host_selection(state, host); + } + + void unload_hosts_page_state(app::ClientState &state) { + if (!state.hostsLoaded) { + return; + } + + if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { + remember_host_selection(state, state.hosts[state.selectedHostIndex]); + } + + state.hosts.clear(); + state.hostsLoaded = false; + state.selectedHostIndex = 0U; + state.hostsFocusArea = app::HostsFocusArea::toolbar; + } + + void unload_apps_page_state(app::ClientState &state) { + if (state.activeHostLoaded) { + remember_host_selection(state, state.activeHost); + } + clear_active_host_app_list(state); + } + + void unload_settings_page_state(app::ClientState &state) { + state.savedFiles.clear(); + state.savedFilesDirty = true; + state.logViewerLines.clear(); + state.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.activeScreen == nextScreen) { + return; + } + + switch (state.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.selectedSettingsCategory = settings_category_from_menu_id(selectedItem->id); @@ -169,18 +262,21 @@ namespace { state.modal.selectedActionIndex = 0U; } - const app::HostRecord *find_host_by_endpoint(const std::vector &hosts, const std::string &address, uint16_t port) { // NOSONAR(cpp:S1144) used by endpoint-aware selection and background update flows + 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_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, address, port); host != nullptr) { + return host; + } + if (state.activeHostLoaded && app::host_matches_endpoint(state.activeHost, address, port)) { + return &state.activeHost; + } + return nullptr; } std::vector visible_app_indices(const app::HostRecord &host, bool showHiddenApps) { @@ -369,6 +465,7 @@ namespace { } void set_screen(app::ClientState &state, app::ScreenId screen, const std::string &preferredItemId = {}) { + unload_screen_state(state, screen); state.activeScreen = screen; if (screen == app::ScreenId::settings) { state.savedFilesDirty = true; @@ -714,11 +811,15 @@ namespace { } bool enter_pair_host_screen(app::ClientState &state, const std::string &address, uint16_t port) { - if (const app::HostRecord *host = find_host_by_endpoint(state.hosts, address, port); host != nullptr && host->reachability == app::HostReachability::offline) { + if (const app::HostRecord *host = find_loaded_host_by_endpoint(state, address, port); host != nullptr && host->reachability == app::HostReachability::offline) { state.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.statusMessage = pinError.empty() ? "Failed to generate a secure pairing PIN." : std::move(pinError); @@ -744,11 +845,12 @@ namespace { return false; } + copy_host_to_active_host(state, *host); state.showHiddenApps = showHiddenApps; state.selectedAppIndex = 0U; state.appsScrollPage = 0U; - host->appListState = app::HostAppListState::loading; - host->appListStatusMessage = (host->apps.empty() ? "Loading apps for " : "Refreshing apps for ") + host->displayName + "..."; + state.activeHost.appListState = app::HostAppListState::loading; + state.activeHost.appListStatusMessage = (state.activeHost.apps.empty() ? "Loading apps for " : "Refreshing apps for ") + state.activeHost.displayName + "..."; state.statusMessage.clear(); set_screen(state, app::ScreenId::apps); return true; @@ -759,6 +861,7 @@ namespace { if (app::host_matches_endpoint(state.hosts[index], address, port)) { state.selectedHostIndex = index; state.hostsFocusArea = app::HostsFocusArea::grid; + remember_host_selection(state, state.hosts[index]); return; } } @@ -941,7 +1044,12 @@ namespace { return true; } - app::HostRecord *mutableHost = &state.hosts[state.selectedHostIndex]; + app::HostRecord *mutableHost = state.activeHostLoaded ? &state.activeHost : nullptr; + if (mutableHost == nullptr) { + close_modal(state); + update->modalClosed = true; + return true; + } const std::vector indices = visible_app_indices(*mutableHost, state.showHiddenApps); if (indices.empty()) { close_modal(state); @@ -953,8 +1061,10 @@ namespace { switch (state.modal.selectedActionIndex % 3U) { case 0: appRecord.hidden = !appRecord.hidden; + state.hostsDirty = true; close_modal(state); update->modalClosed = true; + update->hostsChanged = true; clamp_selected_app_index(state); return true; case 1: @@ -962,8 +1072,10 @@ namespace { return true; case 2: appRecord.favorite = !appRecord.favorite; + state.hostsDirty = true; close_modal(state); update->modalClosed = true; + update->hostsChanged = true; return true; default: return true; @@ -987,6 +1099,7 @@ namespace app { false, false, false, + true, 0U, HostsFocusArea::toolbar, DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX, @@ -997,6 +1110,10 @@ namespace app { ui::MenuModel(), ui::MenuModel(), {}, + {}, + false, + {}, + 0, {{}, {}, AddHostField::address, {false, 0U, {}}, ScreenId::hosts, {}, {}, false}, {{}, DEFAULT_HOST_PORT, {}, PairingStage::idle, {}}, {}, @@ -1035,11 +1152,28 @@ namespace app { void replace_hosts(ClientState &state, std::vector hosts, std::string statusMessage) { state.hosts = std::move(hosts); + state.hostsLoaded = true; state.hostsDirty = false; state.statusMessage = std::move(statusMessage); - reset_hosts_home_selection(state); + bool restoredSelection = false; + if (!state.selectedHostAddress.empty()) { + for (std::size_t index = 0; index < state.hosts.size(); ++index) { + if (host_matches_endpoint(state.hosts[index], state.selectedHostAddress, state.selectedHostPort)) { + state.selectedHostIndex = index; + state.hostsFocusArea = HostsFocusArea::grid; + restoredSelection = true; + break; + } + } + } + if (!restoredSelection) { + reset_hosts_home_selection(state); + } clamp_selected_host_index(state); clamp_selected_app_index(state); + if (state.activeScreen == ScreenId::hosts) { + clear_active_host(state); + } if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { rebuild_menu(state); @@ -1078,6 +1212,8 @@ namespace app { } if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { state.hosts[state.selectedHostIndex].reachability = success ? HostReachability::online : HostReachability::offline; + } else if (state.activeHostLoaded) { + state.activeHost.reachability = success ? HostReachability::online : HostReachability::offline; } state.statusMessage = std::move(message); } @@ -1092,7 +1228,7 @@ namespace app { state.pairingDraft.statusMessage = message; state.statusMessage = std::move(message); - HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + HostRecord *host = find_loaded_host_by_endpoint(state, address, port); if (host == nullptr) { return false; } @@ -1101,7 +1237,11 @@ namespace app { clear_deleted_host_pairing(state, address, port); host->pairingState = PairingState::paired; host->reachability = HostReachability::online; - select_host_by_endpoint(state, address, port); + if (state.hostsLoaded) { + select_host_by_endpoint(state, address, port); + } else { + remember_host_selection(state, *host); + } set_screen(state, ScreenId::hosts); state.hostsDirty = true; return true; @@ -1121,17 +1261,23 @@ namespace app { std::string message ) { HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + if (host == nullptr && state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { + host = &state.activeHost; + } if (host == nullptr) { return; } - const bool hostIsActiveAppsScreenSelection = state.activeScreen == ScreenId::apps && selected_host(state) == host; + bool persistedAppCacheChanged = false; + + const bool hostIsActiveAppsScreenSelection = state.activeScreen == ScreenId::apps && state.activeHostLoaded && host == &state.activeHost; const HostAppRecord *currentSelection = selected_app(state); const int selectedAppId = currentSelection == nullptr ? 0 : currentSelection->id; if (!success) { if (const bool hostIsUnpaired = network::error_indicates_unpaired_client(message); hostIsUnpaired) { host->pairingState = PairingState::not_paired; + persistedAppCacheChanged = !host->apps.empty() || host->appListContentHash != 0U; host->apps.clear(); host->appListContentHash = 0; host->lastAppListRefreshTick = 0U; @@ -1170,13 +1316,16 @@ namespace app { mergedApps.push_back(std::move(appRecord)); } host->apps = std::move(mergedApps); + persistedAppCacheChanged = true; } else { refresh_running_flags(host); } + persistedAppCacheChanged = persistedAppCacheChanged || host->appListContentHash != appListContentHash; host->appListContentHash = appListContentHash; host->appListState = HostAppListState::ready; host->appListStatusMessage = message; + state.hostsDirty = state.hostsDirty || persistedAppCacheChanged; if (hostIsActiveAppsScreenSelection) { state.statusMessage.clear(); } @@ -1192,13 +1341,20 @@ namespace app { void mark_cover_art_cached(ClientState &state, const std::string &address, uint16_t port, int appId) { HostRecord *host = find_host_by_endpoint(state.hosts, address, port); + if (host == nullptr && state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { + host = &state.activeHost; + } if (host == nullptr) { return; } for (HostAppRecord &appRecord : host->apps) { if (appRecord.id == appId) { + if (appRecord.boxArtCached) { + return; + } appRecord.boxArtCached = true; + state.hostsDirty = true; return; } } @@ -1221,6 +1377,9 @@ namespace app { } const HostRecord *selected_host(const ClientState &state) { + if ((state.activeScreen == ScreenId::apps || state.activeScreen == ScreenId::pair_host) && state.activeHostLoaded) { + return &state.activeHost; + } if (state.hosts.empty() || state.selectedHostIndex >= state.hosts.size()) { return nullptr; } @@ -1241,7 +1400,10 @@ namespace app { } const HostRecord *apps_host(const ClientState &state) { - return selected_host(state); + if (state.activeScreen != ScreenId::apps || !state.activeHostLoaded) { + return nullptr; + } + return &state.activeHost; } AppUpdate handle_command(ClientState &state, input::UiCommand command) { // NOSONAR(cpp:S3776) top-level UI command routing intentionally remains in one place diff --git a/src/app/client_state.h b/src/app/client_state.h index cda35a4..2726c3e 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -151,6 +151,7 @@ namespace app { bool overlayVisible; ///< True when the diagnostics overlay is visible. bool shouldExit; ///< True when the application should terminate. bool hostsDirty; ///< True when the host list changed and should be saved. + bool hostsLoaded; ///< True when the hosts page list is currently loaded in memory. std::size_t overlayScrollOffset; ///< Scroll offset used by long overlay content. HostsFocusArea hostsFocusArea; ///< Focused region on the hosts page. std::size_t selectedToolbarButtonIndex; ///< Zero-based selection inside the hosts toolbar. @@ -161,6 +162,10 @@ namespace app { ui::MenuModel menu; ///< Primary vertical menu model for the active screen. ui::MenuModel detailMenu; ///< Secondary detail or actions menu. std::vector hosts; ///< Saved hosts currently tracked by the shell. + HostRecord activeHost; ///< Host snapshot kept for host-specific non-host screens after unloading the hosts page. + bool activeHostLoaded = false; ///< True when activeHost contains a valid host snapshot. + std::string selectedHostAddress; ///< Last selected host address used to restore hosts-page selection after reload. + uint16_t selectedHostPort = 0; ///< Last selected host port override used to restore hosts-page selection after reload. 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. @@ -356,9 +361,12 @@ namespace app { bool begin_selected_host_app_browse(ClientState &state, bool showHiddenApps); /** - * @brief Return the currently selected saved host on the Hosts screen. + * @brief Return the currently selected loaded host for the active screen. * - * @param state App state containing the hosts list and menu selection. + * 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); diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index a552b76..781632e 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -3,6 +3,8 @@ // standard includes #include +#include +#include #include #include @@ -58,6 +60,221 @@ namespace { return segments; } + char hex_digit(unsigned int value) { + return static_cast(value < 10U ? ('0' + value) : ('A' + (value - 10U))); + } + + 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; + } + + encoded.push_back('%'); + encoded.push_back(hex_digit((character >> 4U) & 0x0FU)); + encoded.push_back(hex_digit(character & 0x0FU)); + } + + 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()); + for (std::size_t index = 0; index < text.size(); ++index) { + if (text[index] != '%') { + result.push_back(text[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 += 2U; + } + + *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 { @@ -204,14 +421,14 @@ namespace app { } const std::vector fields = split_string_view(line, '\t'); - if (fields.size() != 3 && fields.size() != 4) { - result->errors.push_back("Line " + std::to_string(lineNumber) + " must contain three or four tab-separated fields"); + 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.size() == 4 ? fields[3] : fields[2]; - if (fields.size() == 4 && !try_parse_host_port(fields[2], &port)) { + 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; } @@ -237,6 +454,20 @@ namespace app { 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)); } @@ -257,6 +488,10 @@ namespace app { } 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'; } diff --git a/src/app/host_records.h b/src/app/host_records.h index 6556701..7f03c2a 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -178,8 +178,11 @@ namespace app { /** * @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 a future persistence layer. + * @return Serialized text suitable for disk persistence. */ std::string serialize_host_records(const std::vector &records); diff --git a/src/ui/host_probe_result_queue.cpp b/src/ui/host_probe_result_queue.cpp new file mode 100644 index 0000000..6ec3c67 --- /dev/null +++ b/src/ui/host_probe_result_queue.cpp @@ -0,0 +1,68 @@ +// 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::lock_guard 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::lock_guard 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::lock_guard 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::lock_guard lock(queue->mutex); + if (queue->targetCount > 0U) { + --queue->targetCount; + } + } + + std::vector drain_host_probe_results(HostProbeResultQueue *queue) { + if (queue == nullptr) { + return {}; + } + + const std::lock_guard lock(queue->mutex); + std::vector results; + results.swap(queue->pendingResults); + return results; + } + + bool host_probe_result_round_complete(const HostProbeResultQueue &queue) { + const std::lock_guard 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..de37d87 --- /dev/null +++ b/src/ui/host_probe_result_queue.h @@ -0,0 +1,84 @@ +#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/shell_screen.cpp b/src/ui/shell_screen.cpp index 5790263..cfda77f 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -31,6 +31,7 @@ #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 { @@ -1864,8 +1865,73 @@ namespace { } } + 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(logging::Logger &logger, app::ClientState &state) { + if (state.activeScreen != app::ScreenId::hosts || state.hostsLoaded) { + return true; + } + + const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); + for (const std::string &warning : loadedHosts.warnings) { + logger.log(logging::LogLevel::warning, "hosts", warning); + } + app::replace_hosts(state, loadedHosts.hosts, state.statusMessage); + return true; + } + bool persist_hosts(logging::Logger &logger, app::ClientState &state) { - const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(state.hosts); + std::vector hostsToSave; + if (state.hostsLoaded) { + hostsToSave = state.hosts; + } else if (state.activeHostLoaded) { + const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); + for (const std::string &warning : loadedHosts.warnings) { + logger.log(logging::LogLevel::warning, "hosts", warning); + } + hostsToSave = loadedHosts.hosts; + if (app::HostRecord *host = find_persisted_host_record(hostsToSave, state.activeHost.address, state.activeHost.port); host != nullptr) { + merge_host_for_persistence(host, state.activeHost); + } else { + hostsToSave.push_back(state.activeHost); + } + } else { + hostsToSave = state.hosts; + } + + const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hostsToSave); if (saveResult.success) { state.hostsDirty = false; logger.log(logging::LogLevel::info, "hosts", "Saved host records"); @@ -1885,11 +1951,7 @@ namespace { } void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { // NOSONAR(cpp:S3776) host metadata updates intentionally stay grouped with pairing-state transitions - for (app::HostRecord &host : state.hosts) { - if (!app::host_matches_endpoint(host, address, port)) { - continue; - } - + auto apply_to_host = [&](app::HostRecord &host) { bool persistedMetadataChanged = false; const bool hostRequiresManualPairing = app::host_requires_manual_pairing(state, address, port); if (!serverInfo.hostName.empty()) { @@ -1919,13 +1981,24 @@ namespace { host.appListContentHash = 0; host.lastAppListRefreshTick = 0; state.selectedAppIndex = 0U; - if (state.activeScreen == app::ScreenId::apps && state.selectedHostIndex < state.hosts.size() && &host == &state.hosts[state.selectedHostIndex]) { // NOSONAR(cpp:S134) selected-host UI update stays inline with pairing-state demotion + if (state.activeScreen == app::ScreenId::apps && state.activeHostLoaded && &host == &state.activeHost) { state.statusMessage = host.appListStatusMessage; } } } state.hostsDirty = state.hostsDirty || persistedMetadataChanged; - break; + }; + + for (app::HostRecord &host : state.hosts) { + if (!app::host_matches_endpoint(host, address, port)) { + continue; + } + apply_to_host(host); + return; + } + + if (state.activeHostLoaded && app::host_matches_endpoint(state.activeHost, address, port)) { + apply_to_host(state.activeHost); } } @@ -1981,6 +2054,15 @@ namespace { 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(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { if (!update.savedFileDeleteRequested) { return; @@ -2168,22 +2250,24 @@ namespace { }; struct HostProbeTaskState { - struct TargetHost { - std::string address; - uint16_t port = 0; - }; - - struct ProbeResult { + 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; }; - SDL_Thread *thread = nullptr; - std::atomic completed = false; - std::vector targets; - std::vector results; + 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) { @@ -2331,14 +2415,31 @@ namespace { return; } - task->thread = nullptr; - task->completed.store(false); - task->targets.clear(); - task->results.clear(); + 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.thread != nullptr && !task.completed.load(); + 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 @@ -2499,71 +2600,92 @@ namespace { } int run_host_probe_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature - auto *task = static_cast(context); - if (task == nullptr) { + auto *worker = static_cast(context); + if (worker == nullptr) { return -1; } - network::PairingIdentity clientIdentity {}; - const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; - - task->results.clear(); - task->results.reserve(task->targets.size()); - for (const HostProbeTaskState::TargetHost &target : task->targets) { - HostProbeTaskState::ProbeResult result {}; - result.address = target.address; - result.port = target.port; - result.success = test_tcp_host_connection(target.address, target.port, clientIdentityPointer, nullptr, &result.serverInfo); - task->results.push_back(std::move(result)); - } - - task->completed.store(true); + 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, std::memory_order_release); return 0; } - void finish_host_probe_task_if_ready(logging::Logger &logger, app::ClientState &state, HostProbeTaskState *task) { - if (task == nullptr || task->thread == nullptr || !task->completed.load()) { + void apply_published_host_probe_results(app::ClientState &state, HostProbeTaskState *task) { + if (task == nullptr) { return; } - SDL_Thread *thread = task->thread; - task->thread = nullptr; - int threadResult = 0; - SDL_WaitThread(thread, &threadResult); - (void) threadResult; - - const std::vector results = task->results; - reset_host_probe_task(task); - - std::size_t onlineCount = 0; - std::size_t offlineCount = 0; - bool metadataChanged = false; - for (const HostProbeTaskState::ProbeResult &result : results) { + const std::vector results = ui::drain_host_probe_results(&task->resultQueue); + for (const ui::HostProbeResult &result : results) { if (result.success) { - apply_server_info_to_host(state, result.address, result.port, result.serverInfo); - metadataChanged = metadataChanged || state.hostsDirty; - ++onlineCount; + if (state.activeScreen == app::ScreenId::hosts && state.hostsLoaded) { + apply_server_info_to_host(state, result.address, result.port, result.serverInfo); + task->metadataChanged = task->metadataChanged || state.hostsDirty; + } + ++task->onlineCount; continue; } - for (app::HostRecord &host : state.hosts) { - if (host.address == result.address && app::effective_host_port(host.port) == app::effective_host_port(result.port)) { - host.reachability = app::HostReachability::offline; - host.manualAddress = result.address; - ++offlineCount; - break; - } + if (state.activeScreen == app::ScreenId::hosts && state.hostsLoaded) { + mark_host_offline(state, result.address, result.port); } + ++task->offlineCount; + } + } + + void reap_completed_host_probe_workers(HostProbeTaskState *task) { + if (task == nullptr) { + return; } - logger.log(logging::LogLevel::info, "hosts", "Refreshed " + std::to_string(results.size()) + " saved host(s): " + std::to_string(onlineCount) + " online, " + std::to_string(offlineCount) + " offline"); - if (metadataChanged) { + auto iterator = task->workers.begin(); + while (iterator != task->workers.end()) { + if ((*iterator)->thread == nullptr || !(*iterator)->completed.load(std::memory_order_acquire)) { + ++iterator; + continue; + } + + int threadResult = 0; + SDL_WaitThread((*iterator)->thread, &threadResult); + (void) threadResult; + iterator = task->workers.erase(iterator); + } + } + + void finish_host_probe_task_if_ready(logging::Logger &logger, 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; + } + + logger.log( + logging::LogLevel::info, + "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(logger, state); } + reset_host_probe_task(task); } void start_host_probe_task_if_needed(logging::Logger &logger, const app::ClientState &state, HostProbeTaskState *task, Uint32 now, Uint32 *nextHostProbeTick) { - if (task == nullptr || host_probe_task_is_active(*task) || state.activeScreen != app::ScreenId::hosts || !network::runtime_network_ready()) { + if (task == nullptr || host_probe_task_is_active(*task) || state.activeScreen != app::ScreenId::hosts || !state.hostsLoaded || !network::runtime_network_ready()) { return; } if (nextHostProbeTick != nullptr && *nextHostProbeTick != 0U && now < *nextHostProbeTick) { @@ -2571,16 +2693,24 @@ namespace { } reset_host_probe_task(task); + task->clientIdentityAvailable = try_load_saved_pairing_identity(&task->clientIdentity); + ui::begin_host_probe_result_round(&task->resultQueue, state.hosts.size()); for (const app::HostRecord &host : state.hosts) { - task->targets.push_back({host.address, app::effective_host_port(host.port)}); - } - if (task->targets.empty()) { - return; - } + 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) { + logger.log(logging::LogLevel::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->thread = SDL_CreateThread(run_host_probe_task, "probe-saved-hosts", task); - if (task->thread == nullptr) { - logger.log(logging::LogLevel::error, "hosts", std::string("Failed to start the saved-host refresh task: ") + SDL_GetError()); + task->workers.push_back(std::move(worker)); + } + if (task->workers.empty()) { reset_host_probe_task(task); return; } @@ -2613,6 +2743,10 @@ namespace { break; } } + if (state.activeHostLoaded && app::host_matches_endpoint(state.activeHost, update.pairingAddress, update.pairingPort)) { + state.activeHost.reachability = app::HostReachability::offline; + state.activeHost.manualAddress = update.pairingAddress; + } state.pairingDraft.stage = app::PairingStage::failed; state.pairingDraft.generatedPin.clear(); state.pairingDraft.statusMessage = reachabilityMessage.empty() ? "The host could not be reached for pairing." : reachabilityMessage; @@ -2756,8 +2890,8 @@ namespace { return; } - if (state.selectedHostIndex < state.hosts.size()) { - app::HostRecord &mutableHost = state.hosts[state.selectedHostIndex]; + if (state.activeHostLoaded) { + app::HostRecord &mutableHost = state.activeHost; mutableHost.appListState = app::HostAppListState::loading; mutableHost.appListStatusMessage = (mutableHost.apps.empty() ? "Loading apps for " : "Refreshing apps for ") + mutableHost.displayName + "..."; state.statusMessage.clear(); @@ -2771,17 +2905,17 @@ namespace { if (task->thread == nullptr) { const std::string errorMessage = std::string("Failed to start the app-list fetch task: ") + SDL_GetError(); logger.log(logging::LogLevel::error, "apps", errorMessage); - if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { - state.hosts[state.selectedHostIndex].appListState = app::HostAppListState::failed; - state.hosts[state.selectedHostIndex].appListStatusMessage = errorMessage; + if (state.activeHostLoaded) { + state.activeHost.appListState = app::HostAppListState::failed; + state.activeHost.appListStatusMessage = errorMessage; state.statusMessage = errorMessage; } reset_app_list_task(task); return; } - if (state.selectedHostIndex < state.hosts.size()) { - state.hosts[state.selectedHostIndex].lastAppListRefreshTick = now; + if (state.activeHostLoaded) { + state.activeHost.lastAppListRefreshTick = now; } } @@ -3498,12 +3632,10 @@ namespace ui { keypadRedrawRequested = true; + const app::ScreenId previousScreen = state.activeScreen; const app::AppUpdate update = app::handle_command(state, command); logger.set_minimum_level(state.loggingLevel); log_app_update(logger, state, update); - if (update.screenChanged && !draw_current_shell()) { - return; - } show_log_file_if_requested(logger, state, update); cancel_pairing_if_requested(logger, state, update, &pairingTask); test_host_connection_if_requested(logger, state, update); @@ -3514,9 +3646,21 @@ namespace ui { factory_reset_if_requested(logger, state, update, &coverArtTextureCache); refresh_saved_files_if_needed(logger, state); persist_hosts_if_needed(logger, state, update); + + if (previousScreen != state.activeScreen) { + release_page_resources_for_screen(previousScreen, state.activeScreen, &coverArtTextureCache, &keypadModalLayoutCache); + ensure_hosts_loaded_for_active_screen(logger, state); + } + if ((previousScreen != state.activeScreen || update.screenChanged) && !draw_current_shell()) { + return; + } + if (state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { + clear_keypad_modal_layout_cache(&keypadModalLayoutCache); + } }; while (running && !state.shouldExit) { + ensure_hosts_loaded_for_active_screen(logger, state); finish_pairing_task_if_ready(logger, state, &pairingTask); finish_app_list_task_if_ready(logger, state, &appListTask); finish_app_art_task_if_ready(logger, state, &appArtTask, &coverArtTextureCache); @@ -3735,9 +3879,13 @@ namespace ui { SDL_WaitThread(appArtTask.thread, &threadResult); (void) threadResult; } - if (hostProbeTask.thread != nullptr) { + for (const std::unique_ptr &worker : hostProbeTask.workers) { + if (worker == nullptr || worker->thread == nullptr) { + continue; + } + int threadResult = 0; - SDL_WaitThread(hostProbeTask.thread, &threadResult); + SDL_WaitThread(worker->thread, &threadResult); (void) threadResult; } diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 48d03b4..069f6a5 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -180,6 +180,9 @@ namespace { EXPECT_EQ(update.pairingPort, app::DEFAULT_HOST_PORT); EXPECT_TRUE(app::is_valid_pairing_pin(update.pairingPin)); EXPECT_EQ(state.activeScreen, app::ScreenId::pair_host); + EXPECT_FALSE(state.hostsLoaded); + EXPECT_TRUE(state.hosts.empty()); + EXPECT_TRUE(state.activeHostLoaded); } TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { @@ -285,6 +288,21 @@ namespace { EXPECT_EQ(state.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.activeScreen, app::ScreenId::apps); + EXPECT_FALSE(state.hostsLoaded); + EXPECT_TRUE(state.hosts.empty()); + EXPECT_TRUE(state.activeHostLoaded); + EXPECT_EQ(state.activeHost.address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + } + TEST(ClientStateTest, SelectingAnOfflinePairedHostDoesNotOpenTheAppsScreen) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { @@ -306,10 +324,9 @@ namespace { {"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().runningGameId = 101; - state.hosts.front().apps = { + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.activeHost.runningGameId = 101; + state.activeHost.apps = { {"Steam", 101, false, true, true, "cached-steam", true, false}, }; @@ -321,13 +338,13 @@ namespace { true, "Loaded 2 app(s)"); - ASSERT_EQ(state.hosts.front().apps.size(), 2U); - EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); - EXPECT_TRUE(state.hosts.front().apps[0].hidden); - EXPECT_TRUE(state.hosts.front().apps[0].favorite); - EXPECT_TRUE(state.hosts.front().apps[0].boxArtCached); - EXPECT_TRUE(state.hosts.front().apps[0].running); - EXPECT_TRUE(state.hosts.front().apps[1].boxArtCached); + ASSERT_EQ(state.activeHost.apps.size(), 2U); + EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::ready); + EXPECT_TRUE(state.activeHost.apps[0].hidden); + EXPECT_TRUE(state.activeHost.apps[0].favorite); + EXPECT_TRUE(state.activeHost.apps[0].boxArtCached); + EXPECT_TRUE(state.activeHost.apps[0].running); + EXPECT_TRUE(state.activeHost.apps[1].boxArtCached); EXPECT_TRUE(state.statusMessage.empty()); } @@ -338,8 +355,7 @@ namespace { }); state.hosts.front().resolvedHttpPort = test_support::kTestPorts[test_support::kPortResolvedHttp]; - app::handle_command(state, input::UiCommand::move_down); - app::handle_command(state, input::UiCommand::activate); + 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}, @@ -348,9 +364,9 @@ namespace { true, "Loaded 1 app(s)"); - ASSERT_EQ(state.hosts.front().apps.size(), 1U); - EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::ready); - EXPECT_EQ(state.hosts.front().apps.front().name, "Steam"); + ASSERT_EQ(state.activeHost.apps.size(), 1U); + EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::ready); + EXPECT_EQ(state.activeHost.apps.front().name, "Steam"); EXPECT_TRUE(state.statusMessage.empty()); } @@ -360,15 +376,37 @@ namespace { {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, }); - state.hosts.front().httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; - state.hosts.front().apps = { + ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); + state.activeHost.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; + state.activeHost.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.front().apps.size(), 1U); - EXPECT_TRUE(state.hosts.front().apps.front().boxArtCached); + ASSERT_EQ(state.activeHost.apps.size(), 1U); + EXPECT_TRUE(state.activeHost.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.hostsDirty); + 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.hostsDirty); + ASSERT_EQ(state.activeHost.apps.size(), 1U); + EXPECT_EQ(state.activeHost.appListContentHash, 0xACEDU); } TEST(ClientStateTest, FailedRefreshKeepsCachedAppsAvailable) { @@ -378,19 +416,39 @@ namespace { }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - state.hosts.front().apps = { + state.activeHost.apps = { {"Steam", 101, false, false, false, "cached-steam", true, false}, }; - state.hosts.front().appListContentHash = 0x1234U; + state.activeHost.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.front().appListState, app::HostAppListState::ready); - ASSERT_EQ(state.hosts.front().apps.size(), 1U); - EXPECT_EQ(state.hosts.front().apps.front().name, "Steam"); + EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::ready); + ASSERT_EQ(state.activeHost.apps.size(), 1U); + EXPECT_EQ(state.activeHost.apps.front().name, "Steam"); EXPECT_EQ(state.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.activeHost.apps = { + {"Steam", 101, false, false, false, "cached-steam", true, false}, + }; + state.activeHost.appListState = app::HostAppListState::ready; + + const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); + + EXPECT_TRUE(update.screenChanged); + EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(state.activeHost.apps.empty()); + EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::idle); + } + TEST(ClientStateTest, ExplicitUnpairedAppListFailureMarksTheHostAsNotPaired) { app::ClientState state = app::create_initial_state(); app::replace_hosts(state, { @@ -539,8 +597,8 @@ namespace { EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); EXPECT_TRUE(state.statusMessage.empty()); - EXPECT_EQ(state.hosts.front().appListState, app::HostAppListState::failed); - EXPECT_EQ(state.hosts.front().appListStatusMessage, "The host applist response did not contain any app entries"); + EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::idle); + EXPECT_TRUE(state.activeHost.appListStatusMessage.empty()); } TEST(ClientStateTest, SettingsCanRequestDeletionOfSavedFilesFromTheResetCategory) { diff --git a/tests/unit/app/host_records_test.cpp b/tests/unit/app/host_records_test.cpp index 588df46..8f57d92 100644 --- a/tests/unit/app/host_records_test.cpp +++ b/tests/unit/app/host_records_test.cpp @@ -62,12 +62,46 @@ namespace { 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, ReportsMalformedSerializedLinesWithoutDroppingValidRecords) { const std::string serializedRecords = - "Living Room PC\t192.168.1.20\t\tpaired\n" - "Broken Host\tnot-an-ip\t\tnot_paired\n" + "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\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); diff --git a/tests/unit/startup/host_storage_test.cpp b/tests/unit/startup/host_storage_test.cpp index 869e012..b7e4e71 100644 --- a/tests/unit/startup/host_storage_test.cpp +++ b/tests/unit/startup/host_storage_test.cpp @@ -69,13 +69,44 @@ namespace { 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\n" - "Broken Host\tnot-an-ip\t\tnot_paired\n"; + "\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); 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..2231eb1 --- /dev/null +++ b/tests/unit/ui/host_probe_result_queue_test.cpp @@ -0,0 +1,107 @@ +// 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/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 521d687..8af5921 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -166,7 +166,7 @@ namespace { {"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.front().runningGameId = 101; + state.activeHost.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}, @@ -195,15 +195,20 @@ namespace { TEST(ShellViewTest, HidesCachedAppTilesWhenTheSelectedHostIsNoLongerPaired) { 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::not_paired, app::HostReachability::online}, - }); state.activeScreen = app::ScreenId::apps; - state.hosts.front().apps = { + state.activeHostLoaded = true; + state.activeHost = { + "Office PC", + test_support::kTestIpv4Addresses[test_support::kIpOffice], + test_support::kTestPorts[test_support::kPortDefaultHost], + app::PairingState::not_paired, + app::HostReachability::online, + }; + state.activeHost.apps = { {"Steam", 101, false, false, false, "steam-cover", true, false}, }; - state.hosts.front().appListState = app::HostAppListState::failed; - state.hosts.front().appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again."; + state.activeHost.appListState = app::HostAppListState::failed; + state.activeHost.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, {}); From de886e24200349826892266d4a76134c667df1b9 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:39:53 -0400 Subject: [PATCH 17/35] Add TOML settings & enhance logging Introduce persistent TOML-backed app settings and expand logging features. - Add third-party tomlplusplus submodule and include it in the Xbox build. - Implement settings storage (src/app/settings_storage.*) to load/save moonlight.toml (logging and UI keys), with parsing, validation, warnings and cleanup hints. - Add AppSettings types and load/save APIs (settings_storage.h). - Add storage_paths helper for default storage locations and platform error utilities. - Enhance logging subsystem: - Add global logger proxy (global_logger.*) and startup debug support. - Add source-location support and richer formatting in logger; include optional file/line in entries. - Add runtime file sink type and ability to set file sink, per-sink minimum levels, debugger-console minimum level, and startup debug flag. - Change default in-memory/file logging levels handling and improve sink dispatching. - Update client_state to use new settings, add detailed UI menu item descriptions, track settingsDirty/settingsChanged, expose xemu console logging level, and restructure create_initial_state to explicit member initialization. - Update run-xemu.sh to add environment flags controlling xemu serial stdio and to include serial device args conditionally. - Update CMake includes for tomlplusplus and adjust logging/log_file storage path resolution to use new storage helpers. - Add startup debug logger files and tests for settings/logging components; add third-party/tomlplusplus submodule. These changes add user-configurable TOML settings, improve observability during startup and runtime, and enable mirrored logging to xemu's serial console. --- .gitmodules | 4 + cmake/xbox-build.cmake | 1 + scripts/run-xemu.sh | 25 +- src/app/client_state.cpp | 141 ++++---- src/app/client_state.h | 5 +- src/app/host_records.cpp | 11 +- src/app/settings_storage.cpp | 373 ++++++++++++++++++++ src/app/settings_storage.h | 63 ++++ src/logging/global_logger.cpp | 65 ++++ src/logging/global_logger.h | 118 +++++++ src/logging/log_file.cpp | 25 +- src/logging/log_file.h | 40 +++ src/logging/logger.cpp | 179 +++++++++- src/logging/logger.h | 182 +++++++++- src/logging/startup_debug.cpp | 87 +++++ src/logging/startup_debug.h | 74 ++++ src/main.cpp | 127 +++++-- src/network/host_pairing.cpp | 49 +-- src/network/host_pairing.h | 11 - src/platform/error_utils.h | 24 ++ src/platform/filesystem_utils.cpp | 5 + src/platform/filesystem_utils.h | 53 +++ src/splash/splash_screen.cpp | 29 +- src/splash/splash_screen.h | 21 +- src/startup/client_identity_storage.cpp | 22 +- src/startup/cover_art_cache.cpp | 45 +-- src/startup/host_storage.cpp | 37 +- src/startup/memory_stats.cpp | 6 +- src/startup/memory_stats.h | 8 + src/startup/saved_files.cpp | 42 +-- src/startup/saved_files.h | 9 +- src/startup/storage_paths.cpp | 51 +++ src/startup/storage_paths.h | 31 ++ src/startup/video_mode.cpp | 6 +- src/startup/video_mode.h | 45 +-- src/ui/menu_model.h | 1 + src/ui/shell_screen.cpp | 394 +++++++++++++--------- src/ui/shell_screen.h | 8 +- src/ui/shell_view.cpp | 16 +- src/ui/shell_view.h | 3 + tests/CMakeLists.txt | 1 + tests/unit/app/client_state_test.cpp | 15 +- tests/unit/app/settings_storage_test.cpp | 96 ++++++ tests/unit/logging/log_file_test.cpp | 39 +++ tests/unit/logging/logger_test.cpp | 90 ++++- tests/unit/logging/startup_debug_test.cpp | 29 ++ tests/unit/startup/saved_files_test.cpp | 16 +- tests/unit/ui/menu_model_test.cpp | 30 +- tests/unit/ui/shell_view_test.cpp | 66 +++- third-party/tomlplusplus | 1 + 50 files changed, 2249 insertions(+), 570 deletions(-) create mode 100644 src/app/settings_storage.cpp create mode 100644 src/app/settings_storage.h create mode 100644 src/logging/global_logger.cpp create mode 100644 src/logging/global_logger.h create mode 100644 src/logging/startup_debug.cpp create mode 100644 src/logging/startup_debug.h create mode 100644 src/platform/error_utils.h create mode 100644 src/startup/storage_paths.cpp create mode 100644 src/startup/storage_paths.h create mode 100644 tests/unit/app/settings_storage_test.cpp create mode 100644 tests/unit/logging/startup_debug_test.cpp create mode 160000 third-party/tomlplusplus diff --git a/.gitmodules b/.gitmodules index 219ff13..c06338e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -17,3 +17,7 @@ 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/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 29d7d17..378e072 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -60,6 +60,7 @@ target_sources(${CMAKE_PROJECT_NAME} 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}" diff --git a/scripts/run-xemu.sh b/scripts/run-xemu.sh index fffb265..c184542 100644 --- a/scripts/run-xemu.sh +++ b/scripts/run-xemu.sh @@ -15,6 +15,8 @@ Environment overrides: MOONLIGHT_XEMU_TARGET_PATH MOONLIGHT_XEMU_NETWORK MOONLIGHT_XEMU_TAP_IFNAME + MOONLIGHT_XEMU_ENABLE_SERIAL_STDIO + MOONLIGHT_XEMU_DISABLE_SERIAL_STDIO EOF return 0 } @@ -270,6 +272,11 @@ 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")" @@ -419,7 +426,23 @@ if [[ "$check_only" -eq 1 ]]; then 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_network_args[@]}" +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/src/app/client_state.cpp b/src/app/client_state.cpp index f2ae70f..f41a00b 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -95,6 +95,21 @@ namespace { 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)); } @@ -367,23 +382,23 @@ namespace { switch (state.activeScreen) { case app::ScreenId::settings: return { - {settings_category_menu_id(app::SettingsCategory::logging), "Logging", true}, - {settings_category_menu_id(app::SettingsCategory::display), "Display", true}, - {settings_category_menu_id(app::SettingsCategory::input), "Input", true}, - {settings_category_menu_id(app::SettingsCategory::reset), "Reset", true}, + {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", true}, - {"edit-port", "Host Port", true}, - {"test-connection", "Test Connection", true}, - {"start-pairing", "Start Pairing", true}, - {"save-host", "Save Host", true}, - {"cancel-add-host", "Cancel", true}, + {"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", true}, + {"cancel-pairing", "Cancel", "Stop the current pairing attempt and return to the previous screen.", true}, }; case app::ScreenId::home: case app::ScreenId::hosts: @@ -402,24 +417,25 @@ namespace { switch (state.selectedSettingsCategory) { case app::SettingsCategory::logging: return { - {"view-log-file", "View Log File", true}, - {"cycle-log-level", std::string("Logging Level: ") + logging::to_string(state.loggingLevel), true}, + {"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.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.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", true}, + {"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", true}, + {"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", true}, + {"factory-reset", "Factory Reset", "Delete every Moonlight saved file, including hosts, pairing identity, cached art, and logs.", true}, }; for (const startup::SavedFileEntry &savedFile : state.savedFiles) { - items.push_back({std::string(DELETE_SAVED_FILE_MENU_ID_PREFIX) + savedFile.path, "Delete " + savedFile.displayName, true}); + 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; } @@ -869,18 +885,20 @@ namespace { 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::warning; + 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::error; - case logging::LogLevel::error: return logging::LogLevel::info; + case logging::LogLevel::error: + return logging::LogLevel::warning; } - return logging::LogLevel::info; + return logging::LogLevel::none; } bool handle_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) { // NOSONAR(cpp:S3776) modal command routing stays centralized for predictable UI behavior @@ -899,6 +917,8 @@ namespace { case input::UiCommand::delete_character: case input::UiCommand::open_context_menu: cycle_log_viewer_placement(state); + state.settingsDirty = true; + update->settingsChanged = true; return true; case input::UiCommand::previous_page: scroll_log_viewer(state, true, LOG_VIEWER_SCROLL_STEP); @@ -1094,42 +1114,37 @@ namespace { namespace app { ClientState create_initial_state() { - return { - ScreenId::hosts, - false, - false, - false, - true, - 0U, - HostsFocusArea::toolbar, - DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX, - 0U, - 0U, - 0U, - false, - ui::MenuModel(), - ui::MenuModel(), - {}, - {}, - false, - {}, - 0, - {{}, {}, AddHostField::address, {false, 0U, {}}, ScreenId::hosts, {}, {}, false}, - {{}, DEFAULT_HOST_PORT, {}, PairingStage::idle, {}}, - {}, - SettingsFocusArea::categories, - SettingsCategory::logging, - {}, - {}, - {}, - {}, - 0U, - LogViewerPlacement::full, - logging::LogLevel::info, - {}, - true, - {}, - }; + ClientState state; + state.activeScreen = ScreenId::hosts; + state.overlayVisible = false; + state.shouldExit = false; + state.hostsDirty = false; + state.hostsLoaded = true; + state.overlayScrollOffset = 0U; + state.hostsFocusArea = HostsFocusArea::toolbar; + state.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX; + state.selectedHostIndex = 0U; + state.selectedAppIndex = 0U; + state.appsScrollPage = 0U; + state.showHiddenApps = false; + state.activeHostLoaded = false; + state.selectedHostPort = 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.settingsFocusArea = SettingsFocusArea::categories; + state.pairingDraft.targetPort = DEFAULT_HOST_PORT; + state.pairingDraft.stage = PairingStage::idle; + state.selectedSettingsCategory = SettingsCategory::logging; + state.logViewerScrollOffset = 0U; + state.logViewerPlacement = LogViewerPlacement::full; + state.loggingLevel = logging::LogLevel::none; + state.xemuConsoleLoggingLevel = logging::LogLevel::none; + state.settingsDirty = false; + state.savedFilesDirty = true; + return state; } const char *to_string(ScreenId screen) { @@ -1545,10 +1560,20 @@ namespace app { } if (detailUpdate.activatedItemId == "cycle-log-level") { state.loggingLevel = next_logging_level(state.loggingLevel); + state.settingsDirty = true; + update.settingsChanged = true; state.statusMessage = std::string("Logging level set to ") + logging::to_string(state.loggingLevel); rebuild_menu(state, "cycle-log-level"); return update; } + if (detailUpdate.activatedItemId == "cycle-xemu-console-log-level") { + state.xemuConsoleLoggingLevel = next_logging_level(state.xemuConsoleLoggingLevel); + state.settingsDirty = true; + update.settingsChanged = true; + state.statusMessage = std::string("xemu console logging level set to ") + logging::to_string(state.xemuConsoleLoggingLevel); + rebuild_menu(state, "cycle-xemu-console-log-level"); + return update; + } if (detailUpdate.activatedItemId == "factory-reset") { open_confirmation( state, @@ -1556,7 +1581,7 @@ namespace app { "Factory Reset", { "Delete all Moonlight saved data?", - "This removes hosts, logs, pairing identity, and cached cover art.", + "This removes hosts, settings, logs, pairing identity, and cached cover art.", } ); update.modalOpened = true; diff --git a/src/app/client_state.h b/src/app/client_state.h index 2726c3e..9d6bb5e 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -177,7 +177,9 @@ namespace app { 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::info; ///< Minimum log level selected in settings. + 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 settingsDirty = 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. std::vector pairingResetEndpoints; ///< Endpoints whose pairing material should be cleared during reset. @@ -192,6 +194,7 @@ namespace app { bool overlayVisibilityChanged; ///< True when overlay visibility toggled. bool exitRequested; ///< True when the shell requested application exit. bool hostsChanged; ///< True when the host list changed and should be persisted. + bool settingsChanged; ///< True when persisted TOML-backed settings changed. bool connectionTestRequested; ///< True when a manual host connection test should run. bool pairingRequested; ///< True when manual pairing should begin. bool pairingCancelledRequested; ///< True when an in-progress pairing request should be cancelled. diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index 781632e..db04d4f 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -8,15 +8,12 @@ #include #include -namespace { +// local includes +#include "src/platform/error_utils.h" - bool append_error(std::string *errorMessage, std::string message) { - if (errorMessage != nullptr) { - *errorMessage = std::move(message); - } +namespace { - return false; - } + using platform::append_error; bool parse_ipv4_octet(std::string_view segment, int *value) { if (segment.empty()) { diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp new file mode 100644 index 0000000..4b9f0f0 --- /dev/null +++ b/src/app/settings_storage.cpp @@ -0,0 +1,373 @@ +// 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__) +// needed for toml++ +extern "C" FILE *_wfopen(const wchar_t *path, const wchar_t *mode); +#endif + +#define TOML_EXCEPTIONS 0 +#define TOML_ENABLE_WINDOWS_COMPAT 0 +#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"; + + 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); + } + + 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"]; + if (loggingLevelNode) { + if (const auto loggingLevelText = loggingLevelNode.value(); loggingLevelText) { + if (!try_parse_logging_level(*loggingLevelText, &result.settings.loggingLevel)) { + append_invalid_value_warning(&result.warnings, filePath, "logging.file_minimum_level", *loggingLevelText); + } + } else { + append_invalid_value_warning(&result.warnings, filePath, "logging.file_minimum_level", ""); + } + } + + const auto legacyLoggingLevelNode = settingsTable["logging"]["minimum_level"]; + if (legacyLoggingLevelNode && !loggingLevelNode) { + if (const auto loggingLevelText = legacyLoggingLevelNode.value(); loggingLevelText) { + if (!try_parse_logging_level(*loggingLevelText, &result.settings.loggingLevel)) { + append_invalid_value_warning(&result.warnings, filePath, "logging.minimum_level", *loggingLevelText); + } + } else { + append_invalid_value_warning(&result.warnings, filePath, "logging.minimum_level", ""); + } + } + + const auto xemuConsoleLoggingLevelNode = settingsTable["logging"]["xemu_console_minimum_level"]; + if (xemuConsoleLoggingLevelNode) { + if (const auto xemuConsoleLoggingLevelText = xemuConsoleLoggingLevelNode.value(); xemuConsoleLoggingLevelText) { + if (!try_parse_logging_level(*xemuConsoleLoggingLevelText, &result.settings.xemuConsoleLoggingLevel)) { + append_invalid_value_warning(&result.warnings, filePath, "logging.xemu_console_minimum_level", *xemuConsoleLoggingLevelText); + } + } else { + append_invalid_value_warning(&result.warnings, filePath, "logging.xemu_console_minimum_level", ""); + } + } + + const auto logViewerPlacementNode = settingsTable["ui"]["log_viewer_placement"]; + if (logViewerPlacementNode) { + if (const auto logViewerPlacementText = logViewerPlacementNode.value(); logViewerPlacementText) { + if (!try_parse_log_viewer_placement(*logViewerPlacementText, &result.settings.logViewerPlacement)) { + append_invalid_value_warning(&result.warnings, filePath, "ui.log_viewer_placement", *logViewerPlacementText); + } + } else { + append_invalid_value_warning(&result.warnings, filePath, "ui.log_viewer_placement", ""); + } + } + + return result; + } + + SaveAppSettingsResult save_app_settings(const AppSettings &settings, const std::string &filePath) { + std::string errorMessage; + if (!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..506b4b0 --- /dev/null +++ b/src/app/settings_storage.h @@ -0,0 +1,63 @@ +#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/logging/global_logger.cpp b/src/logging/global_logger.cpp new file mode 100644 index 0000000..3c5d3b2 --- /dev/null +++ b/src/logging/global_logger.cpp @@ -0,0 +1,65 @@ +// class header include +#include "src/logging/global_logger.h" + +// standard includes +#include + +namespace { + + logging::Logger *g_globalLogger = nullptr; + +} // namespace + +namespace logging { + + const GlobalLoggerProxy logger {}; + + void set_global_logger(Logger *logger) { + g_globalLogger = logger; + } + + Logger *global_logger() { + return g_globalLogger; + } + + bool has_global_logger() { + return global_logger() != nullptr; + } + + bool GlobalLoggerProxy::available() const { + return has_global_logger(); + } + + Logger *GlobalLoggerProxy::get() const { + return global_logger(); + } + + bool GlobalLoggerProxy::log(LogLevel level, std::string category, std::string message, LogSourceLocation location) const { + if (Logger *registeredLogger = global_logger(); registeredLogger != nullptr) { + return registeredLogger->log(level, std::move(category), std::move(message), location); + } + + return false; + } + + bool GlobalLoggerProxy::trace(std::string category, std::string message, LogSourceLocation location) const { + return log(LogLevel::trace, std::move(category), std::move(message), location); + } + + bool GlobalLoggerProxy::debug(std::string category, std::string message, LogSourceLocation location) const { + return log(LogLevel::debug, std::move(category), std::move(message), location); + } + + bool GlobalLoggerProxy::info(std::string category, std::string message, LogSourceLocation location) const { + return log(LogLevel::info, std::move(category), std::move(message), location); + } + + bool GlobalLoggerProxy::warn(std::string category, std::string message, LogSourceLocation location) const { + return log(LogLevel::warning, std::move(category), std::move(message), location); + } + + bool GlobalLoggerProxy::error(std::string category, std::string message, LogSourceLocation location) const { + return log(LogLevel::error, std::move(category), std::move(message), location); + } + +} // namespace logging diff --git a/src/logging/global_logger.h b/src/logging/global_logger.h new file mode 100644 index 0000000..9866e67 --- /dev/null +++ b/src/logging/global_logger.h @@ -0,0 +1,118 @@ +#pragma once + +// standard includes +#include + +// local includes +#include "src/logging/logger.h" + +namespace logging { + + /** + * @brief Object-style facade that forwards calls to the registered process-wide logger. + */ + class GlobalLoggerProxy { + public: + /** + * @brief Return whether a process-wide logger is currently registered. + * + * @return true when global logging calls can emit entries. + */ + [[nodiscard]] bool available() const; + + /** + * @brief Return the registered process-wide logger. + * + * @return The registered logger, or nullptr when none is available. + */ + [[nodiscard]] Logger *get() const; + + /** + * @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()) const; + + /** + * @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()) const; + + /** + * @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()) const; + + /** + * @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()) const; + + /** + * @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()) const; + + /** + * @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()) const; + }; + + /** + * @brief Object-style facade used for convenience logging calls such as logging::logger.info(...). + */ + extern const GlobalLoggerProxy logger; + + /** + * @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 the process-wide logger used by convenience logging helpers. + * + * @return The registered logger, or nullptr when none is available. + */ + Logger *global_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(); + +} // namespace logging diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp index b636710..28fe46e 100644 --- a/src/logging/log_file.cpp +++ b/src/logging/log_file.cpp @@ -11,7 +11,7 @@ // local includes #include "src/platform/filesystem_utils.h" -#include "src/startup/host_storage.h" +#include "src/startup/storage_paths.h" namespace { @@ -24,13 +24,7 @@ namespace { namespace logging { std::string default_log_file_path() { - const std::string hostStoragePath = startup::default_host_storage_path(); - const std::string directoryPath = platform::parent_directory(hostStoragePath); - if (directoryPath.empty()) { - return "moonlight.log"; - } - - return platform::join_path(directoryPath, "moonlight.log"); + return startup::default_storage_path("moonlight.log"); } bool reset_log_file(const std::string &filePath, std::string *errorMessage) { @@ -88,6 +82,21 @@ namespace logging { 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; diff --git a/src/logging/log_file.h b/src/logging/log_file.h index 92f62a7..f429f44 100644 --- a/src/logging/log_file.h +++ b/src/logging/log_file.h @@ -46,6 +46,46 @@ namespace logging { */ 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. * diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index 1eba2d9..68a1ad9 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -6,18 +6,65 @@ #include #include #include +#include #include #if defined(_WIN32) #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names #endif +#if defined(NXDK) + #include +#endif + +// local includes +#include "src/logging/startup_debug.h" + namespace { bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { return static_cast(candidateLevel) >= static_cast(minimumLevel); } + 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 {}; @@ -73,6 +120,8 @@ namespace logging { return "WARN"; case LogLevel::error: return "ERROR"; + case LogLevel::none: + return "NONE"; } return "UNKNOWN"; @@ -96,12 +145,36 @@ namespace logging { 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) { - if (entry.category.empty()) { - return std::string("[") + to_string(entry.level) + "] " + entry.message; + 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; } - return std::string("[") + to_string(entry.level) + "] " + entry.category + ": " + entry.message; + if (!entry.category.empty()) { + line += entry.category + ": "; + } + line += entry.message; + return line; } Logger::Logger(std::size_t capacity, TimestampProvider timestampProvider): @@ -120,11 +193,58 @@ namespace logging { 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 { - return is_enabled(level, minimumLevel_); + 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; + } + + for (const RegisteredSink ®isteredSink : sinks_) { + if (registeredSink.sink && is_enabled(level, registeredSink.minimumLevel)) { + return true; + } + } + + return false; } - bool Logger::log(LogLevel level, std::string category, std::string message) { + bool Logger::log(LogLevel level, std::string category, std::string message, LogSourceLocation location) { if (!should_log(level)) { return false; } @@ -135,27 +255,60 @@ namespace logging { std::move(category), std::move(message), timestampProvider_(), + location, }; ++nextSequence_; - if (entries_.size() == capacity_) { - entries_.pop_front(); + if (is_enabled(level, minimumLevel_)) { + if (entries_.size() == capacity_) { + entries_.pop_front(); + } + + entries_.push_back(entry); } - entries_.push_back(entry); + if (startupDebugEnabled_) { + print_startup_log(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 LogSink &sink : sinks_) { - if (sink) { - sink(entries_.back()); + for (const RegisteredSink ®isteredSink : sinks_) { + if (registeredSink.sink && is_enabled(level, registeredSink.minimumLevel)) { + registeredSink.sink(entry); } } return true; } - void Logger::add_sink(LogSink sink) { + 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(std::move(sink)); + sinks_.push_back({minimumLevel, std::move(sink)}); } } diff --git a/src/logging/logger.h b/src/logging/logger.h index 275c0f6..2d225c3 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -19,10 +19,11 @@ namespace logging { info = 2, warning = 3, error = 4, + none = 5, }; /** - * @brief Structured log entry stored by the in-memory logger. + * @brief Local wall-clock timestamp captured for each retained log entry. */ struct LogTimestamp { int year = 0; ///< Full calendar year in local time. @@ -34,15 +35,52 @@ namespace logging { 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; ///< Monotonic sequence number assigned by the logger. - LogLevel level; ///< Severity associated with the entry. + 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. }; /** @@ -71,6 +109,14 @@ namespace logging { */ 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. * @@ -100,24 +146,73 @@ namespace logging { std::size_t capacity() const; /** - * @brief Set the minimum accepted log level. + * @brief Set the minimum retained in-memory log level. * - * @param minimumLevel Entries below this level are ignored. + * @param minimumLevel Entries below this level are not stored in the ring buffer. */ void set_minimum_level(LogLevel minimumLevel); /** - * @brief Return the minimum accepted log level. + * @brief Return the minimum retained in-memory log level. * - * @return Minimum accepted log level. + * @return Minimum retained level. */ LogLevel minimum_level() const; /** - * @brief Return whether a log level would be recorded. + * @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 and dispatched. + * @return true if the entry would be stored or dispatched. */ bool should_log(LogLevel level) const; @@ -127,16 +222,68 @@ namespace logging { * @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 log(LogLevel level, std::string category, std::string message); + 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); + void add_sink(LogSink sink, LogLevel minimumLevel = LogLevel::trace); /** * @brief Return the retained entries. @@ -154,12 +301,21 @@ namespace logging { 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::info; + 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_; + std::vector sinks_; }; } // namespace logging diff --git a/src/logging/startup_debug.cpp b/src/logging/startup_debug.cpp new file mode 100644 index 0000000..6d2ac9d --- /dev/null +++ b/src/logging/startup_debug.cpp @@ -0,0 +1,87 @@ +// class header include +#include "src/logging/startup_debug.h" + +// standard includes +#include + +// nxdk includes +#include + +namespace { + + bool g_startupConsoleEnabled = true; + + logging::StartupConsoleStyle startup_console_style_for_level(logging::LogLevel level) { + switch (level) { + case logging::LogLevel::trace: + return logging::StartupConsoleStyle::trace; + case logging::LogLevel::debug: + return logging::StartupConsoleStyle::debug; + case logging::LogLevel::info: + case logging::LogLevel::none: + return logging::StartupConsoleStyle::info; + case logging::LogLevel::warning: + return logging::StartupConsoleStyle::warning; + case logging::LogLevel::error: + return logging::StartupConsoleStyle::error; + } + + return logging::StartupConsoleStyle::info; + } + +} // namespace + +namespace logging { + + const char *startup_status_block(StartupConsoleStyle style) { + switch (style) { + case StartupConsoleStyle::pending: + return "[START ]"; + case StartupConsoleStyle::trace: + return "[TRACE ]"; + case StartupConsoleStyle::debug: + return "[DEBUG ]"; + case StartupConsoleStyle::info: + return "[ INFO ]"; + case StartupConsoleStyle::warning: + return "[ WARN ]"; + case StartupConsoleStyle::error: + return "[ERROR ]"; + } + + return "[ INFO ]"; + } + + std::string format_startup_console_line(StartupConsoleStyle style, std::string_view category, std::string_view message) { + std::string line = startup_status_block(style); + line.push_back(' '); + 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) { + g_startupConsoleEnabled = enabled; + } + + bool startup_console_enabled() { + return g_startupConsoleEnabled; + } + + void print_startup_console_line(StartupConsoleStyle style, std::string_view category, std::string_view message) { + if (!startup_console_enabled()) { + return; + } + + const std::string line = format_startup_console_line(style, category, message); + debugPrint("%s\n", line.c_str()); + } + + void print_startup_log(LogLevel level, std::string_view category, std::string_view message) { + print_startup_console_line(startup_console_style_for_level(level), category, message); + } + +} // namespace logging diff --git a/src/logging/startup_debug.h b/src/logging/startup_debug.h new file mode 100644 index 0000000..a4015bd --- /dev/null +++ b/src/logging/startup_debug.h @@ -0,0 +1,74 @@ +#pragma once + +// standard includes +#include +#include + +// local includes +#include "src/logging/logger.h" + +namespace logging { + + /** + * @brief Fixed-width startup console prefixes used by the pre-splash debug wrapper. + */ + enum class StartupConsoleStyle { + pending, + trace, + debug, + info, + warning, + error, + }; + + /** + * @brief Return the fixed-width status block shown at the start of startup console lines. + * + * @param style Startup console style to stringify. + * @return Fixed-width status block text. + */ + [[nodiscard]] const char *startup_status_block(StartupConsoleStyle style); + + /** + * @brief Format one startup console line without writing it to the debug console. + * + * @param style Debian-style token shown for the line. + * @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(StartupConsoleStyle style, 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 Debian-style startup console line when output is enabled. + * + * @param style Debian-style token shown for the line. + * @param category Short subsystem category such as startup or sdl. + * @param message Human-readable console text. + */ + void print_startup_console_line(StartupConsoleStyle style, std::string_view category, std::string_view message); + + /** + * @brief Print one structured log-level startup console line when output is enabled. + * + * @param level Structured log level to map into a console token. + * @param category Short subsystem category such as startup or sdl. + * @param message Human-readable console text. + */ + void print_startup_log(LogLevel level, std::string_view category, std::string_view message); + +} // namespace logging diff --git a/src/main.cpp b/src/main.cpp index e927b3c..5f48f36 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,13 +4,19 @@ #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/global_logger.h" #include "src/logging/log_file.h" #include "src/logging/logger.h" +#include "src/logging/startup_debug.h" #include "src/network/runtime_network.h" #include "src/splash/splash_screen.h" #include "src/startup/host_storage.h" @@ -27,10 +33,43 @@ namespace { network::RuntimeNetworkStatus runtimeNetworkStatus; }; - int report_startup_failure(logging::Logger &logger, const char *category, const std::string &message) { - logger.log(logging::LogLevel::error, category, message); - debugPrint("%s\n", message.c_str()); - debugPrint("Holding failure screen for 5 seconds before exit.\n"); + void apply_persisted_settings(app::ClientState &state, const app::AppSettings &settings) { + state.loggingLevel = settings.loggingLevel; + state.xemuConsoleLoggingLevel = settings.xemuConsoleLoggingLevel; + state.logViewerPlacement = settings.logViewerPlacement; + state.settingsDirty = 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::logger.warn("settings", warning); + } + + if (!loadResult.fileFound) { + logging::logger.info("settings", "No persisted settings file found. Using defaults."); + return; + } + + logging::logger.info("settings", "Loaded persisted Moonlight settings"); + if (!loadResult.cleanupRequired) { + return; + } + + const app::SaveAppSettingsResult saveResult = app::save_app_settings(loadResult.settings); + if (saveResult.success) { + logging::logger.info("settings", "Removed obsolete settings keys from the persisted configuration"); + return; + } + + logging::logger.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::logger.error(category, message); + logging::print_startup_console_line(logging::StartupConsoleStyle::warning, category, "Holding failure screen for 5 seconds before exit."); Sleep(5000); return 1; } @@ -40,25 +79,33 @@ namespace { return; } - debugPrint("[startup] %s\n", message); + logging::print_startup_console_line(logging::StartupConsoleStyle::info, "startup", message); } void debug_print_video_mode_selection(const startup::VideoModeSelection &selection) { - debugPrint("[startup] Detected %u video mode(s)\n", static_cast(selection.availableVideoModes.size())); + logging::print_startup_console_line( + logging::StartupConsoleStyle::info, + "video", + "Detected " + std::to_string(selection.availableVideoModes.size()) + " video mode(s)" + ); for (const std::string &line : startup::format_video_mode_lines(selection)) { - debugPrint("[startup] %s\n", line.c_str()); + logging::print_startup_console_line(logging::StartupConsoleStyle::info, "video", line); } } void debug_print_encoder_settings(DWORD encoderSettings) { - debugPrint( - "[startup] Encoder settings: 0x%08lX (widescreen=%s, 480p=%s, 720p=%s, 1080i=%s)\n", + 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::StartupConsoleStyle::info, "video", messageBuffer.data()); } int run_startup_task(void *context) { @@ -73,7 +120,7 @@ namespace { return 0; } - void finish_startup_task(logging::Logger &logger, app::ClientState &clientState, StartupTaskState *task) { + void finish_startup_task(app::ClientState &clientState, StartupTaskState *task) { if (task == nullptr) { return; } @@ -86,15 +133,15 @@ namespace { } for (const std::string &warning : task->loadedHosts.warnings) { - logger.log(logging::LogLevel::warning, "hosts", warning); + logging::logger.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)"); - logger.log(logging::LogLevel::info, "hosts", "Loaded " + std::to_string(task->loadedHosts.hosts.size()) + " saved host record(s)"); + logging::logger.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)) { - logger.log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); + logging::logger.log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); } if (!task->runtimeNetworkStatus.ready) { clientState.statusMessage = task->runtimeNetworkStatus.summary; @@ -105,25 +152,39 @@ namespace { int main() { logging::Logger logger; + logging::set_global_logger(&logger); + logger.set_minimum_level(logging::LogLevel::trace); + app::ClientState clientState = app::create_initial_state(); + load_persisted_settings(clientState); + logger.set_file_minimum_level(clientState.loggingLevel); + logger.set_debugger_console_minimum_level(clientState.xemuConsoleLoggingLevel); + const std::string logFilePath = logging::default_log_file_path(); + logging::RuntimeLogFileSink runtimeLogFile(logFilePath); app::set_log_file_path(clientState, logFilePath); - if (std::string logResetError; !logging::reset_log_file(logFilePath, &logResetError)) { - debugPrint("Failed to reset runtime log file %s: %s\n", logFilePath.c_str(), logResetError.c_str()); + + std::string logFileResetError; + if (!runtimeLogFile.reset(&logFileResetError)) { + logging::print_startup_console_line( + logging::StartupConsoleStyle::warning, + "logging", + logFileResetError.empty() ? "Failed to reset the runtime log file." : logFileResetError + ); } - logger.add_sink([logFilePath](const logging::LogEntry &entry) { + logger.set_file_sink([&runtimeLogFile](const logging::LogEntry &entry) { std::string ignoredError; - logging::append_log_file_entry(entry, logFilePath, &ignoredError); + runtimeLogFile.consume(entry, &ignoredError); }); - logger.set_minimum_level(clientState.loggingLevel); - logger.log(logging::LogLevel::info, "app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); - logger.log(logging::LogLevel::info, "logging", "Writing runtime logs to " + logFilePath); + + logging::logger.info("app", std::string("Initial screen: ") + app::to_string(clientState.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() @@ -134,7 +195,7 @@ int main() { debug_print_startup_checkpoint("About to call SDL_Init"); if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMECONTROLLER) != 0) { - return report_startup_failure(logger, "sdl", std::string("SDL_Init failed: ") + SDL_GetError()); + return report_startup_failure("sdl", std::string("SDL_Init failed: ") + SDL_GetError()); } debug_print_startup_checkpoint("SDL_Init succeeded"); @@ -148,7 +209,7 @@ int main() { SDL_WINDOW_SHOWN ); if (window == nullptr) { - const int exitCode = report_startup_failure(logger, "sdl", std::string("SDL_CreateWindow failed: ") + SDL_GetError()); + const int exitCode = report_startup_failure("sdl", std::string("SDL_CreateWindow failed: ") + SDL_GetError()); SDL_Quit(); return exitCode; } @@ -164,31 +225,27 @@ int main() { debug_print_startup_checkpoint("Background startup task created"); } - logger.log(logging::LogLevel::info, "app", "Showing splash screen"); + logging::logger.info("app", "Showing splash screen"); debug_print_startup_checkpoint("About to show splash screen"); + logger.set_startup_debug_enabled(false); + logging::set_startup_console_enabled(false); splash::show_splash_screen(window, bestVideoMode, [&startupTask]() { return !startupTask.completed.load(); }); - debug_print_startup_checkpoint("Returned from splash screen"); - finish_startup_task(logger, clientState, &startupTask); - for (const std::string &line : startup::format_video_mode_lines(videoModeSelection)) { - logger.log(logging::LogLevel::info, "video", line); - } - for (const std::string &line : startup::format_memory_statistics_lines()) { - logger.log(logging::LogLevel::info, "memory", line); - } + finish_startup_task(clientState, &startupTask); + startup::log_video_modes(videoModeSelection); - logger.log(logging::LogLevel::info, "app", "Starting interactive shell"); - const int exitCode = ui::run_shell(window, bestVideoMode, clientState, logger); + logging::logger.info("app", "Starting interactive shell"); + const int exitCode = ui::run_shell(window, bestVideoMode, clientState); if (clientState.hostsDirty) { const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(clientState.hosts); if (saveResult.success) { - logger.log(logging::LogLevel::info, "hosts", "Saved host records before exit"); + logging::logger.info("hosts", "Saved host records before exit"); clientState.hostsDirty = false; } else { - logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); + logging::logger.error("hosts", saveResult.errorMessage); } } diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 715fc9f..5f47874 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -27,6 +27,7 @@ // local includes #include "src/network/runtime_network.h" +#include "src/platform/error_utils.h" // platform includes #ifdef NXDK @@ -74,6 +75,8 @@ extern "C" int rand_s(unsigned int *randomValue); namespace { + using platform::append_error; + void trace_pairing_phase(const char *message) { (void) message; } @@ -133,8 +136,6 @@ namespace { SOCKET handle = INVALID_SOCKET; }; - bool append_error(std::string *errorMessage, std::string message); - bool pairing_cancel_requested(const std::atomic *cancelRequested) { return cancelRequested != nullptr && cancelRequested->load(std::memory_order_acquire); } @@ -302,7 +303,6 @@ namespace { std::string body; }; - bool append_error(std::string *errorMessage, std::string message); bool hex_value(char character, unsigned char *value); bool http_get( const std::string &address, @@ -571,14 +571,6 @@ namespace { return false; } - bool append_error(std::string *errorMessage, std::string message) { - if (errorMessage != nullptr) { - *errorMessage = std::move(message); - } - - return false; - } - std::string take_openssl_error_queue() { std::string details; unsigned long errorCode = 0; @@ -1152,41 +1144,6 @@ namespace { return rootStatusCode == 401U || rootStatusCode == 403U || network::error_indicates_unpaired_client(rootStatusMessage); } - std::vector extract_xml_element_blocks(std::string_view xml, std::string_view tagName) { - std::vector blocks; - 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] != '>' && !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::size_t closeIndex = xml.find(closeTag, openEnd + 1); - if (closeIndex == std::string_view::npos) { - break; - } - - blocks.push_back(xml.substr(openEnd + 1, closeIndex - openEnd - 1)); - cursor = closeIndex + closeTag.size(); - } - - return blocks; - } - 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")) { diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index 7381b07..0b60e4a 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -157,17 +157,6 @@ namespace network { std::string *errorMessage = nullptr ); - /** - * @brief Query live host status without client authentication. - * - * @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 = nullptr); - /** * @brief Query the app list exported by a host. * diff --git a/src/platform/error_utils.h b/src/platform/error_utils.h new file mode 100644 index 0000000..6a137fd --- /dev/null +++ b/src/platform/error_utils.h @@ -0,0 +1,24 @@ +#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 index 6f17e18..ec6d9d3 100644 --- a/src/platform/filesystem_utils.cpp +++ b/src/platform/filesystem_utils.cpp @@ -100,6 +100,11 @@ namespace platform { 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; diff --git a/src/platform/filesystem_utils.h b/src/platform/filesystem_utils.h index eeda309..668d0e4 100644 --- a/src/platform/filesystem_utils.h +++ b/src/platform/filesystem_utils.h @@ -7,18 +7,71 @@ 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_screen.cpp b/src/splash/splash_screen.cpp index b795ff1..fa27ca5 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -8,13 +8,13 @@ #include // nxdk includes -#include #include #include #include #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // local includes +#include "src/logging/global_logger.h" #include "src/os.h" #include "src/splash/splash_layout.h" @@ -25,15 +25,15 @@ namespace { constexpr Uint8 SPLASH_BACKGROUND_BLUE = 0x64; void printSDLErrorAndReboot() { - debugPrint("SDL_Error: %s\n", SDL_GetError()); - debugPrint("Rebooting in 5 seconds.\n"); + logging::logger.error("splash", std::string("SDL error: ") + SDL_GetError()); + logging::logger.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::logger.error("splash", std::string("SDL_image error: ") + IMG_GetError()); + logging::logger.warn("splash", "Rebooting in 5 seconds."); Sleep(5000); XReboot(); } @@ -139,20 +139,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::logger.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::logger.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::logger.error("splash", std::string("Failed to lock scaled splash asset surface: ") + SDL_GetError()); SDL_UnlockSurface(sourceSurface); SDL_FreeSurface(sourceSurface); SDL_FreeSurface(scaledSurface); @@ -172,7 +172,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::logger.error("splash", std::string("Failed to enable alpha blending for scaled splash asset: ") + SDL_GetError()); SDL_FreeSurface(scaledSurface); return nullptr; } @@ -187,7 +187,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::logger.error( + "splash", + std::string("Failed to normalize splash asset surface format ") + SDL_GetPixelFormatName(surface->format->format) + ": " + SDL_GetError() + ); SDL_FreeSurface(surface); return nullptr; } @@ -195,7 +198,7 @@ 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::logger.error("splash", std::string("Failed to enable alpha blending for splash asset: ") + SDL_GetError()); SDL_FreeSurface(normalizedSurface); return nullptr; } @@ -210,10 +213,10 @@ namespace { return normalizedSurface; } - debugPrint("Failed to prepare splash asset %s for rendering.\n", assetPath.c_str()); + logging::logger.error("splash", "Failed to prepare splash asset " + assetPath + " for rendering."); } - debugPrint("Failed to load splash asset %s: %s\n", assetPath.c_str(), IMG_GetError()); + logging::logger.error("splash", "Failed to load splash asset " + assetPath + ": " + IMG_GetError()); return nullptr; } diff --git a/src/splash/splash_screen.h b/src/splash/splash_screen.h index 16299ad..08ea95e 100644 --- a/src/splash/splash_screen.h +++ b/src/splash/splash_screen.h @@ -1,17 +1,30 @@ #pragma once -// nxdk includes -#include - // standard includes #include -struct SDL_Window; +// nxdk includes +#include +#include namespace splash { + /** + * @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 index 720a6ed..bcc0b43 100644 --- a/src/startup/client_identity_storage.cpp +++ b/src/startup/client_identity_storage.cpp @@ -10,17 +10,14 @@ #include // local includes +#include "src/platform/error_utils.h" #include "src/platform/filesystem_utils.h" -#include "src/startup/host_storage.h" +#include "src/startup/storage_paths.h" namespace { - bool append_error(std::string *errorMessage, std::string message) { - if (errorMessage != nullptr) { - *errorMessage = std::move(message); - } - return false; - } + 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"; @@ -31,10 +28,6 @@ namespace { int errorCode; }; - std::string join_path(const std::string &left, const std::string &right) { - return platform::join_path(left, right); - } - ReadFileTextResult read_file_text(const std::string &filePath, std::string *errorMessage) { FILE *file = std::fopen(filePath.c_str(), "rb"); if (file == nullptr) { @@ -102,12 +95,7 @@ namespace { namespace startup { std::string default_client_identity_directory() { - const std::string hostStorageDirectory = platform::parent_directory(default_host_storage_path()); - if (hostStorageDirectory.empty()) { - return "pairing"; - } - - return join_path(hostStorageDirectory, "pairing"); + return default_storage_path("pairing"); } LoadClientIdentityResult load_client_identity(const std::string &directoryPath) { diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp index 95c6364..159095c 100644 --- a/src/startup/cover_art_cache.cpp +++ b/src/startup/cover_art_cache.cpp @@ -2,7 +2,6 @@ #include "src/startup/cover_art_cache.h" // standard includes -#include #include #include #include @@ -10,46 +9,14 @@ #include #include -// nxdk includes -#if defined(__has_include) - #if __has_include() - #include - #include - #define MOONLIGHT_HAS_NXDK_XBE 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use - #endif - #if __has_include() - #include - #define MOONLIGHT_HAS_NXDK_MOUNT 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use - #endif -#endif - // local includes +#include "src/platform/error_utils.h" #include "src/platform/filesystem_utils.h" +#include "src/startup/storage_paths.h" namespace { - bool append_error(std::string *errorMessage, std::string message) { - if (errorMessage != nullptr) { - *errorMessage = std::move(message); - } - return false; - } - - std::string title_scoped_storage_root() { -#ifdef MOONLIGHT_HAS_NXDK_XBE - #ifdef MOONLIGHT_HAS_NXDK_MOUNT - 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 - } + 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"); @@ -98,11 +65,7 @@ namespace { namespace startup { std::string default_cover_art_cache_root() { - if (const std::string titleScopedRoot = title_scoped_storage_root(); !titleScopedRoot.empty()) { - return titleScopedRoot + "cover-art-cache"; - } - - return {"moonlight-cover-art-cache"}; + return default_storage_path("cover-art-cache"); } std::string build_cover_art_cache_key(std::string_view hostUuid, std::string_view hostAddress, int appId) { diff --git a/src/startup/host_storage.cpp b/src/startup/host_storage.cpp index 78affc9..1af5fe2 100644 --- a/src/startup/host_storage.cpp +++ b/src/startup/host_storage.cpp @@ -2,28 +2,15 @@ #include "src/startup/host_storage.h" // standard includes -#include #include #include #include #include #include -// nxdk includes -#if defined(__has_include) - #if __has_include() - #include - #include - #define MOONLIGHT_HAS_NXDK_XBE 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use - #endif - #if __has_include() - #include - #define MOONLIGHT_HAS_NXDK_MOUNT 1 // NOSONAR(cpp:S5028) must be a preprocessor macro for #ifdef use - #endif -#endif - // local includes #include "src/platform/filesystem_utils.h" +#include "src/startup/storage_paths.h" namespace { @@ -45,32 +32,12 @@ namespace { return content; } - std::string title_scoped_storage_root() { -#ifdef MOONLIGHT_HAS_NXDK_XBE - #ifdef MOONLIGHT_HAS_NXDK_MOUNT - 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 - } - } // namespace namespace startup { std::string default_host_storage_path() { - if (const std::string titleScopedRoot = title_scoped_storage_root(); !titleScopedRoot.empty()) { - return titleScopedRoot + "moonlight-hosts.tsv"; - } - - return {"moonlight-hosts.tsv"}; + return default_storage_path("moonlight-hosts.tsv"); } LoadSavedHostsResult load_saved_hosts(const std::string &filePath) { diff --git a/src/startup/memory_stats.cpp b/src/startup/memory_stats.cpp index 47d6cd3..3b0f18a 100644 --- a/src/startup/memory_stats.cpp +++ b/src/startup/memory_stats.cpp @@ -1,8 +1,10 @@ // class header include #include "src/startup/memory_stats.h" +// local includes +#include "src/logging/global_logger.h" + // nxdk includes -#include #include namespace startup { @@ -36,7 +38,7 @@ namespace startup { void log_memory_statistics() { for (const std::string &line : format_memory_statistics_lines()) { - debugPrint("%s\n", line.c_str()); + logging::logger.info("memory", line); } } diff --git a/src/startup/memory_stats.h b/src/startup/memory_stats.h index 59eb32c..ac48e09 100644 --- a/src/startup/memory_stats.h +++ b/src/startup/memory_stats.h @@ -6,8 +6,16 @@ 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 index b9435a0..306c869 100644 --- a/src/startup/saved_files.cpp +++ b/src/startup/saved_files.cpp @@ -3,7 +3,6 @@ // standard includes #include -#include #include #include #include @@ -22,7 +21,9 @@ #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" @@ -36,30 +37,17 @@ namespace { struct ResolvedSavedFileCatalogConfig { std::string hostStoragePath; + std::string settingsFilePath; std::string logFilePath; std::string pairingDirectory; std::string coverArtCacheRoot; }; - bool append_error(std::string *errorMessage, std::string message) { - if (errorMessage != nullptr) { - *errorMessage = std::move(message); - } - return false; - } - - std::string join_path(const std::string &left, const std::string &right) { - return platform::join_path(left, right); - } - - std::string file_name_from_path(const std::string &path) { - const std::size_t separatorIndex = path.find_last_of("\\/"); - return separatorIndex == std::string::npos ? path : path.substr(separatorIndex + 1U); - } - - bool path_has_prefix(const std::string &path, const std::string &prefix) { - return platform::path_has_prefix(path, prefix); - } + 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)) { @@ -73,13 +61,10 @@ namespace { return offset >= path.size() ? file_name_from_path(path) : path.substr(offset); } - bool try_get_file_size(const std::string &path, std::uint64_t *sizeBytes) { - return platform::try_get_file_size(path, sizeBytes); - } - 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, @@ -92,7 +77,11 @@ namespace { const std::string &path, const std::string &displayName ) { - if (files == nullptr || seenPaths == nullptr || path.empty() || seenPaths->find(path) != seenPaths->end()) { + if (files == nullptr || seenPaths == nullptr || path.empty()) { + return; + } + + if (seenPaths->find(path) != seenPaths->end()) { return; } @@ -199,7 +188,7 @@ namespace { } bool path_is_managed_saved_file(const std::string &path, const ResolvedSavedFileCatalogConfig &config) { - if (path == config.hostStoragePath || path == config.logFilePath) { + if (path == config.hostStoragePath || path == config.settingsFilePath || path == config.logFilePath) { return true; } @@ -233,6 +222,7 @@ namespace startup { 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)); diff --git a/src/startup/saved_files.h b/src/startup/saved_files.h index a2f7da9..f014be7 100644 --- a/src/startup/saved_files.h +++ b/src/startup/saved_files.h @@ -20,10 +20,11 @@ namespace startup { * @brief Optional path overrides used to inspect Moonlight-managed files. */ struct SavedFileCatalogConfig { - std::string hostStoragePath; ///< Path to the saved-host storage file - std::string logFilePath; ///< Path to the persisted log file - std::string pairingDirectory; ///< Directory containing saved client pairing identity files - std::string coverArtCacheRoot; ///< Root directory containing cached cover art artifacts + 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. }; /** diff --git a/src/startup/storage_paths.cpp b/src/startup/storage_paths.cpp new file mode 100644 index 0000000..c16eb78 --- /dev/null +++ b/src/startup/storage_paths.cpp @@ -0,0 +1,51 @@ +// class header include +#include "src/startup/storage_paths.h" + +// standard includes +#include + +// nxdk includes +#if defined(__has_include) + #if __has_include() + #include + #include + #define MOONLIGHT_HAS_NXDK_XBE 1 + #endif + #if __has_include() + #include + #define MOONLIGHT_HAS_NXDK_MOUNT 1 + #endif +#endif + +#ifdef MOONLIGHT_HAS_NXDK_XBE + #include + #include +#endif + +namespace startup { + + std::string title_scoped_storage_root() { +#ifdef MOONLIGHT_HAS_NXDK_XBE + #ifdef MOONLIGHT_HAS_NXDK_MOUNT + 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..660c5b7 --- /dev/null +++ b/src/startup/storage_paths.h @@ -0,0 +1,31 @@ +#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 a93b9e4..0f581f3 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -1,8 +1,8 @@ // class header include #include "src/startup/video_mode.h" -// nxdk includes -#include +// local includes +#include "src/logging/global_logger.h" namespace startup { @@ -85,7 +85,7 @@ namespace startup { void log_video_modes(const VideoModeSelection &selection) { for (const std::string &line : format_video_mode_lines(selection)) { - debugPrint("%s\n", line.c_str()); + logging::logger.info("video", line); } } diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index d3226ea..7a43a41 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -11,37 +11,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. @@ -53,29 +33,30 @@ 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 Return human-readable lines describing the detected and selected video modes. + * + * @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. * - * This function emits diagnostic information (e.g., to console or platform - * log) about the contents of the provided VideoModeSelection. - * * @param selection The selection object to log. */ void log_video_modes(const VideoModeSelection &selection); diff --git a/src/ui/menu_model.h b/src/ui/menu_model.h index 13ba420..844b8ce 100644 --- a/src/ui/menu_model.h +++ b/src/ui/menu_model.h @@ -18,6 +18,7 @@ namespace ui { 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. }; diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index cfda77f..3fcae7d 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -21,11 +21,15 @@ #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/global_logger.h" #include "src/logging/log_file.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" @@ -129,21 +133,13 @@ namespace { return texture; } - int report_shell_failure(logging::Logger &logger, const char *category, const std::string &message) { - logger.log(logging::LogLevel::error, category, message); - logger.log(logging::LogLevel::warning, category, "Holding the failure screen for 5 seconds before exit."); + int report_shell_failure(const char *category, const std::string &message) { + logging::logger.error(category, message); + logging::logger.warn(category, "Holding the failure screen for 5 seconds before exit."); Sleep(5000); return 1; } - bool append_error(std::string *errorMessage, std::string message) { - if (errorMessage != nullptr) { - *errorMessage = std::move(message); - } - - return false; - } - void destroy_texture(SDL_Texture *texture) { if (texture != nullptr) { SDL_DestroyTexture(texture); @@ -1850,18 +1846,18 @@ namespace { 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(logging::Logger &logger, const app::ClientState &state, const app::AppUpdate &update) { + void log_app_update(const app::ClientState &state, const app::AppUpdate &update) { if (!update.activatedItemId.empty()) { - logger.log(logging::LogLevel::info, "ui", "Activated menu item: " + update.activatedItemId); + logging::logger.info("ui", "Activated menu item: " + update.activatedItemId); } if (update.screenChanged) { - logger.log(logging::LogLevel::info, "ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); + logging::logger.info("ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); } if (update.overlayVisibilityChanged) { - logger.log(logging::LogLevel::info, "overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + logging::logger.info("overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); } if (update.exitRequested) { - logger.log(logging::LogLevel::info, "app", "Exit requested from shell"); + logging::logger.info("app", "Exit requested from shell"); } } @@ -1899,27 +1895,27 @@ namespace { targetHost->lastAppListRefreshTick = sourceHost.lastAppListRefreshTick; } - bool ensure_hosts_loaded_for_active_screen(logging::Logger &logger, app::ClientState &state) { + bool ensure_hosts_loaded_for_active_screen(app::ClientState &state) { if (state.activeScreen != app::ScreenId::hosts || state.hostsLoaded) { return true; } const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); for (const std::string &warning : loadedHosts.warnings) { - logger.log(logging::LogLevel::warning, "hosts", warning); + logging::logger.warn("hosts", warning); } app::replace_hosts(state, loadedHosts.hosts, state.statusMessage); return true; } - bool persist_hosts(logging::Logger &logger, app::ClientState &state) { + bool persist_hosts(app::ClientState &state) { std::vector hostsToSave; if (state.hostsLoaded) { hostsToSave = state.hosts; } else if (state.activeHostLoaded) { const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); for (const std::string &warning : loadedHosts.warnings) { - logger.log(logging::LogLevel::warning, "hosts", warning); + logging::logger.warn("hosts", warning); } hostsToSave = loadedHosts.hosts; if (app::HostRecord *host = find_persisted_host_record(hostsToSave, state.activeHost.address, state.activeHost.port); host != nullptr) { @@ -1934,20 +1930,43 @@ namespace { const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hostsToSave); if (saveResult.success) { state.hostsDirty = false; - logger.log(logging::LogLevel::info, "hosts", "Saved host records"); + logging::logger.info("hosts", "Saved host records"); return true; } - logger.log(logging::LogLevel::error, "hosts", saveResult.errorMessage); + logging::logger.error("hosts", saveResult.errorMessage); return false; } - void persist_hosts_if_needed(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + void persist_hosts_if_needed(app::ClientState &state, const app::AppUpdate &update) { if (!update.hostsChanged) { return; } - persist_hosts(logger, state); + persist_hosts(state); + } + + app::AppSettings persistent_settings_from_state(const app::ClientState &state) { + return { + state.loggingLevel, + state.xemuConsoleLoggingLevel, + state.logViewerPlacement, + }; + } + + void persist_settings_if_needed(app::ClientState &state, const app::AppUpdate &update) { + if (!update.settingsChanged || !state.settingsDirty) { + return; + } + + const app::SaveAppSettingsResult saveResult = app::save_app_settings(persistent_settings_from_state(state)); + if (saveResult.success) { + state.settingsDirty = false; + logging::logger.info("settings", "Saved Moonlight settings"); + return; + } + + logging::logger.error("settings", saveResult.errorMessage); } void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { // NOSONAR(cpp:S3776) host metadata updates intentionally stay grouped with pairing-state transitions @@ -2016,12 +2035,11 @@ namespace { return {}; } - const std::size_t fileNameStart = path.find_last_of("\\/"); - if (fileNameStart == std::string::npos || fileNameStart + 1U >= path.size()) { + const std::string fileName = platform::file_name_from_path(path); + if (fileName.empty()) { return {}; } - const std::string fileName = path.substr(fileNameStart + 1U); if (fileName.size() <= 4U || fileName.substr(fileName.size() - 4U) != ".bin") { return {}; } @@ -2042,14 +2060,14 @@ namespace { } } - void refresh_saved_files_if_needed(logging::Logger &logger, app::ClientState &state) { + void refresh_saved_files_if_needed(app::ClientState &state) { if (state.activeScreen != app::ScreenId::settings || !state.savedFilesDirty) { return; } const startup::ListSavedFilesResult savedFiles = startup::list_saved_files(); for (const std::string &warning : savedFiles.warnings) { - logger.log(logging::LogLevel::warning, "storage", warning); + logging::logger.warn("storage", warning); } app::replace_saved_files(state, savedFiles.files); } @@ -2063,14 +2081,14 @@ namespace { } } - void delete_saved_file_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + void delete_saved_file_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { if (!update.savedFileDeleteRequested) { return; } if (std::string errorMessage; !startup::delete_saved_file(update.savedFileDeletePath, &errorMessage)) { state.statusMessage = errorMessage; - logger.log(logging::LogLevel::warning, "storage", errorMessage); + logging::logger.warn("storage", errorMessage); return; } @@ -2080,10 +2098,10 @@ namespace { clear_cover_art_texture(coverArtTextureCache, deletedCoverArtCacheKey); state.savedFilesDirty = true; state.statusMessage = "Deleted saved file " + deletedDisplayName; - logger.log(logging::LogLevel::info, "storage", state.statusMessage); + logging::logger.info("storage", state.statusMessage); } - void delete_host_data_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + void delete_host_data_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { if (!update.hostDeleteCleanupRequested) { return; } @@ -2091,7 +2109,7 @@ namespace { std::size_t deletedCoverArtCount = 0U; for (const std::string &cacheKey : update.deletedHostCoverArtCacheKeys) { if (std::string errorMessage; !startup::delete_cover_art(cacheKey, &errorMessage)) { - logger.log(logging::LogLevel::warning, "storage", errorMessage); + logging::logger.warn("storage", errorMessage); } else { ++deletedCoverArtCount; } @@ -2106,12 +2124,12 @@ namespace { if (!pairedHostsRemain) { std::string errorMessage; if (!startup::delete_client_identity(&errorMessage)) { - logger.log(logging::LogLevel::warning, "storage", errorMessage); + logging::logger.warn("storage", errorMessage); } else { deletedClientIdentity = true; } } else { - logger.log(logging::LogLevel::info, "storage", "Retained the shared pairing identity because other paired hosts still exist"); + logging::logger.info("storage", "Retained the shared pairing identity because other paired hosts still exist"); } } @@ -2122,17 +2140,17 @@ namespace { if (deletedClientIdentity) { state.statusMessage += " and reset local pairing identity"; } - logger.log(logging::LogLevel::info, "storage", state.statusMessage); + logging::logger.info("storage", state.statusMessage); } - void factory_reset_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { + void factory_reset_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { if (!update.factoryResetRequested) { return; } if (std::string errorMessage; !startup::delete_all_saved_files(&errorMessage)) { state.statusMessage = errorMessage; - logger.log(logging::LogLevel::warning, "storage", errorMessage); + logging::logger.warn("storage", errorMessage); return; } @@ -2143,7 +2161,7 @@ namespace { state.statusMessage = "Factory reset completed"; clear_cover_art_texture_cache(coverArtTextureCache); app::set_log_file_path(state, logging::default_log_file_path()); - logger.log(logging::LogLevel::info, "storage", state.statusMessage); + logging::logger.info("storage", state.statusMessage); } bool try_load_saved_pairing_identity(network::PairingIdentity *identity) { @@ -2161,7 +2179,7 @@ namespace { 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 append_error(errorMessage, "No valid paired client identity is available. Pair the host again before browsing apps."); + return platform::append_error(errorMessage, "No valid paired client identity is available. Pair the host again before browsing apps."); } if (identity != nullptr) { @@ -2304,7 +2322,7 @@ namespace { return attempt != nullptr && attempt->thread != nullptr && attempt->completed.load(); } - void finalize_pairing_attempt(logging::Logger &logger, app::ClientState *state, std::unique_ptr attempt) { + void finalize_pairing_attempt(app::ClientState *state, std::unique_ptr attempt) { if (attempt == nullptr || attempt->thread == nullptr) { return; } @@ -2320,11 +2338,11 @@ namespace { reset_pairing_attempt(attempt.get()); for (const PairingAttemptState::DeferredLogEntry &entry : deferredLogs) { - logger.log(entry.level, "pairing", entry.message); + logging::logger.log(entry.level, "pairing", entry.message); } if (discardResult || state == nullptr) { - logger.log(logging::LogLevel::info, "pairing", "Ignored a completed pairing result after leaving the pairing screen or starting a new attempt"); + logging::logger.info("pairing", "Ignored a completed pairing result after leaving the pairing screen or starting a new attempt"); return; } @@ -2336,9 +2354,9 @@ namespace { result.message ); - logger.log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); + logging::logger.log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); if (hostsChanged) { - persist_hosts(logger, *state); + persist_hosts(*state); } } @@ -2354,7 +2372,7 @@ namespace { task->retiredAttempts.push_back(std::move(task->activeAttempt)); } - void reap_retired_pairing_attempts(logging::Logger &logger, PairingTaskState *task) { + void reap_retired_pairing_attempts(PairingTaskState *task) { if (task == nullptr) { return; } @@ -2366,7 +2384,7 @@ namespace { continue; } - finalize_pairing_attempt(logger, nullptr, std::move(*iterator)); + finalize_pairing_attempt(nullptr, std::move(*iterator)); iterator = task->retiredAttempts.erase(iterator); } } @@ -2486,20 +2504,20 @@ namespace { return 0; } - void finish_pairing_task_if_ready(logging::Logger &logger, app::ClientState &state, PairingTaskState *task) { + void finish_pairing_task_if_ready(app::ClientState &state, PairingTaskState *task) { if (task == nullptr) { return; } - reap_retired_pairing_attempts(logger, task); + reap_retired_pairing_attempts(task); if (!pairing_attempt_is_ready(task->activeAttempt.get())) { return; } - finalize_pairing_attempt(logger, &state, std::move(task->activeAttempt)); + finalize_pairing_attempt(&state, std::move(task->activeAttempt)); } - void cancel_pairing_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + void cancel_pairing_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { if (task == nullptr || !update.pairingCancelledRequested || task->activeAttempt == nullptr || task->activeAttempt->thread == nullptr) { return; } @@ -2508,10 +2526,10 @@ namespace { task->activeAttempt->cancelRequested.store(true); retire_active_pairing_attempt(task, true); state.statusMessage.clear(); - logger.log(logging::LogLevel::info, "pairing", "Cancelled the in-flight pairing attempt after leaving the pairing screen"); + logging::logger.info("pairing", "Cancelled the in-flight pairing attempt after leaving the pairing screen"); } - void test_host_connection_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + void test_host_connection_if_requested(app::ClientState &state, const app::AppUpdate &update) { if (!update.connectionTestRequested) { return; } @@ -2521,7 +2539,7 @@ namespace { if (address.empty()) { app::apply_connection_test_result(state, false, "Connection test failed because the host address is invalid"); - logger.log(logging::LogLevel::warning, "hosts", state.statusMessage); + logging::logger.warn("hosts", state.statusMessage); return; } @@ -2543,13 +2561,13 @@ namespace { } } app::apply_connection_test_result(state, success, resultMessage); - logger.log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); + logging::logger.log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); if (state.hostsDirty) { - persist_hosts(logger, state); + persist_hosts(state); } } - void browse_host_apps_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + void browse_host_apps_if_requested(app::ClientState &state, const app::AppUpdate &update) { if (!update.appsBrowseRequested) { return; } @@ -2575,28 +2593,28 @@ namespace { } } state.statusMessage = resultMessage; - logger.log(logging::LogLevel::warning, "apps", resultMessage); + logging::logger.warn("apps", resultMessage); return; } apply_server_info_to_host(state, address, port, serverInfo); if (state.hostsDirty) { - persist_hosts(logger, state); + persist_hosts(state); } host = app::selected_host(state); if (host == nullptr || host->pairingState != app::PairingState::paired) { state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again before opening apps."; - logger.log(logging::LogLevel::warning, "apps", state.statusMessage); + logging::logger.warn("apps", state.statusMessage); return; } if (app::begin_selected_host_app_browse(state, update.appsBrowseShowHidden)) { - logger.log(logging::LogLevel::info, "apps", "Authorized host browse for " + host->displayName); + logging::logger.info("apps", "Authorized host browse for " + host->displayName); return; } - logger.log(logging::LogLevel::warning, "apps", state.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); + logging::logger.warn("apps", state.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); } int run_host_probe_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature @@ -2661,7 +2679,7 @@ namespace { } } - void finish_host_probe_task_if_ready(logging::Logger &logger, app::ClientState &state, HostProbeTaskState *task) { + void finish_host_probe_task_if_ready(app::ClientState &state, HostProbeTaskState *task) { if (task == nullptr) { return; } @@ -2673,18 +2691,17 @@ namespace { return; } - logger.log( - logging::LogLevel::info, + logging::logger.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(logger, state); + persist_hosts(state); } reset_host_probe_task(task); } - void start_host_probe_task_if_needed(logging::Logger &logger, const app::ClientState &state, HostProbeTaskState *task, Uint32 now, Uint32 *nextHostProbeTick) { + 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.activeScreen != app::ScreenId::hosts || !state.hostsLoaded || !network::runtime_network_ready()) { return; } @@ -2703,7 +2720,7 @@ namespace { worker->resultQueue = &task->resultQueue; worker->thread = SDL_CreateThread(run_host_probe_task, "probe-saved-host", worker.get()); if (worker->thread == nullptr) { - logger.log(logging::LogLevel::error, "hosts", "Failed to start the saved-host refresh worker for " + host.address + ": " + SDL_GetError()); + logging::logger.error("hosts", "Failed to start the saved-host refresh worker for " + host.address + ": " + SDL_GetError()); ui::skip_host_probe_result_target(&task->resultQueue); continue; } @@ -2720,16 +2737,16 @@ namespace { } } - void pair_host_if_requested(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { + void pair_host_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { if (!update.pairingRequested || task == nullptr) { return; } - finish_pairing_task_if_ready(logger, state, task); + finish_pairing_task_if_ready(state, task); if (pairing_task_is_active(*task)) { retire_active_pairing_attempt(task, true); - logger.log(logging::LogLevel::info, "pairing", "Discarded the previous background pairing attempt and started a fresh one"); + logging::logger.info("pairing", "Discarded the previous background pairing attempt and started a fresh one"); } std::string reachabilityMessage; @@ -2751,13 +2768,13 @@ namespace { state.pairingDraft.generatedPin.clear(); state.pairingDraft.statusMessage = reachabilityMessage.empty() ? "The host could not be reached for pairing." : reachabilityMessage; state.statusMessage = state.pairingDraft.statusMessage; - logger.log(logging::LogLevel::warning, "pairing", state.pairingDraft.statusMessage); + logging::logger.warn("pairing", state.pairingDraft.statusMessage); return; } apply_server_info_to_host(state, update.pairingAddress, update.pairingPort, serverInfo); if (state.hostsDirty) { - persist_hosts(logger, state); + persist_hosts(state); } auto attempt = std::make_unique(); @@ -2776,7 +2793,7 @@ namespace { const std::string createThreadError = std::string("Failed to start the background pairing task: ") + SDL_GetError(); app::apply_pairing_result(state, update.pairingAddress, update.pairingPort, false, createThreadError); state.pairingDraft.generatedPin.clear(); - logger.log(logging::LogLevel::error, "pairing", createThreadError); + logging::logger.error("pairing", createThreadError); return; } @@ -2785,7 +2802,7 @@ namespace { state.pairingDraft.stage = app::PairingStage::in_progress; state.pairingDraft.statusMessage = "The host is reachable. Enter the code shown below on the host and keep this screen open for the result."; state.statusMessage.clear(); - logger.log(logging::LogLevel::info, "pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); + logging::logger.info("pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); } int run_app_list_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature @@ -2837,7 +2854,7 @@ namespace { return 0; } - void finish_app_list_task_if_ready(logging::Logger &logger, app::ClientState &state, AppListTaskState *task) { + void finish_app_list_task_if_ready(app::ClientState &state, AppListTaskState *task) { if (task == nullptr || task->thread == nullptr || !task->completed.load()) { return; } @@ -2864,18 +2881,18 @@ namespace { if (success) { app::apply_app_list_result(state, address, port, std::move(apps), appListContentHash, true, message); - logger.log(logging::LogLevel::info, "apps", "Fetched app list from " + address + ":" + std::to_string(serverInfo.httpPort)); + logging::logger.info("apps", "Fetched app list from " + address + ":" + std::to_string(serverInfo.httpPort)); if (state.hostsDirty) { - persist_hosts(logger, state); + persist_hosts(state); } return; } app::apply_app_list_result(state, address, port, {}, 0, false, message); - logger.log(logging::LogLevel::warning, "apps", message); + logging::logger.warn("apps", message); } - void start_app_list_task_if_needed(logging::Logger &logger, app::ClientState &state, AppListTaskState *task, Uint32 now) { + void start_app_list_task_if_needed(app::ClientState &state, AppListTaskState *task, Uint32 now) { if (task == nullptr || app_list_task_is_active(*task) || state.activeScreen != app::ScreenId::apps) { return; } @@ -2904,7 +2921,7 @@ namespace { 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(); - logger.log(logging::LogLevel::error, "apps", errorMessage); + logging::logger.error("apps", errorMessage); if (state.activeHostLoaded) { state.activeHost.appListState = app::HostAppListState::failed; state.activeHost.appListStatusMessage = errorMessage; @@ -2955,7 +2972,7 @@ namespace { return 0; } - void finish_app_art_task_if_ready(logging::Logger &logger, app::ClientState &state, AppArtTaskState *task, CoverArtTextureCache *textureCache) { + void finish_app_art_task_if_ready(app::ClientState &state, AppArtTaskState *task, CoverArtTextureCache *textureCache) { if (task == nullptr || task->thread == nullptr || !task->completed.load()) { return; } @@ -2981,14 +2998,14 @@ namespace { } if (!cachedAppIds.empty()) { - logger.log(logging::LogLevel::info, "apps", "Cached cover art for " + std::to_string(cachedAppIds.size()) + " app(s)"); + logging::logger.info("apps", "Cached cover art for " + std::to_string(cachedAppIds.size()) + " app(s)"); } if (failureCount > 0U) { - logger.log(logging::LogLevel::warning, "apps", std::to_string(failureCount) + " app artwork request(s) fell back to placeholders"); + logging::logger.warn("apps", std::to_string(failureCount) + " app artwork request(s) fell back to placeholders"); } } - void start_app_art_task_if_needed(logging::Logger &logger, const app::ClientState &state, AppArtTaskState *task) { + void start_app_art_task_if_needed(const app::ClientState &state, AppArtTaskState *task) { if (task == nullptr || app_art_task_is_active(*task) || state.activeScreen != app::ScreenId::apps) { return; } @@ -3011,12 +3028,12 @@ namespace { task->apps = host->apps; task->thread = SDL_CreateThread(run_app_art_task, "fetch-app-art", task); if (task->thread == nullptr) { - logger.log(logging::LogLevel::error, "apps", std::string("Failed to start the cover-art fetch task: ") + SDL_GetError()); + logging::logger.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(logging::Logger &logger, app::ClientState &state, const app::AppUpdate &update) { + void show_log_file_if_requested(app::ClientState &state, const app::AppUpdate &update) { if (!update.logViewRequested) { return; } @@ -3026,7 +3043,7 @@ namespace { app::set_log_file_path(state, loadedLog.filePath); if (!loadedLog.errorMessage.empty()) { app::apply_log_viewer_contents(state, {loadedLog.errorMessage}, loadedLog.errorMessage); - logger.log(logging::LogLevel::warning, "logging", loadedLog.errorMessage); + logging::logger.warn("logging", loadedLog.errorMessage); return; } @@ -3039,7 +3056,7 @@ namespace { 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); - logger.log(logging::LogLevel::info, "logging", statusMessage + ": " + loadedLog.filePath); + logging::logger.info("logging", statusMessage + ": " + loadedLog.filePath); } bool draw_shell( // NOSONAR(cpp:S3776,cpp:S107) one-frame shell rendering is intentionally centralized to keep layout and failure handling consistent @@ -3274,15 +3291,37 @@ namespace { } else { const bool settingsScreen = viewModel.screen == app::ScreenId::settings; const bool hasDetailMenu = settingsScreen && !viewModel.detailMenuRows.empty(); - const int menuPanelWidth = std::max(232, (contentRect.w * 31) / 100); - const SDL_Rect menuPanel {contentRect.x, contentRect.y, menuPanelWidth, contentRect.h}; - const SDL_Rect bodyPanel {contentRect.x + menuPanelWidth + panelGap, contentRect.y, contentRect.w - menuPanelWidth - panelGap, contentRect.h}; + 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, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD8); - draw_rect(renderer, bodyPanel, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xB8); + draw_rect( + renderer, + menuPanel, + viewModel.leftPanelActive ? ACCENT_RED : TEXT_RED, + viewModel.leftPanelActive ? ACCENT_GREEN : TEXT_GREEN, + viewModel.leftPanelActive ? ACCENT_BLUE : TEXT_BLUE, + viewModel.leftPanelActive ? 0xD8 : 0x48 + ); + draw_rect( + renderer, + bodyPanel, + viewModel.rightPanelActive ? ACCENT_RED : TEXT_RED, + viewModel.rightPanelActive ? ACCENT_GREEN : TEXT_GREEN, + viewModel.rightPanelActive ? ACCENT_BLUE : TEXT_BLUE, + viewModel.rightPanelActive ? 0xD8 : 0x48 + ); - const SDL_Rect menuHeaderRect {menuPanel.x + 14, menuPanel.y + 14, menuPanel.w - 28, std::max(34, TTF_FontLineSkip(smallFont) + 10)}; + 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; @@ -3292,43 +3331,68 @@ namespace { renderer, bodyFont, viewModel.menuRows, - {menuPanel.x + 14, menuHeaderRect.y + menuHeaderRect.h + 12, menuPanel.w - 28, menuPanel.h - (menuHeaderRect.h + 40)}, + {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; } - const int bodyCardPadding = 16; if (hasDetailMenu) { - const SDL_Rect optionsCard {bodyPanel.x + 16, bodyPanel.y + 16, bodyPanel.w - 32, std::max(1, bodyPanel.h - 32)}; - fill_rect(renderer, optionsCard, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xCC); - draw_rect(renderer, optionsCard, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xC0); - if (!render_text_line_simple(renderer, smallFont, "Options", {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, optionsCard.x + bodyCardPadding, optionsCard.y + 12, optionsCard.w - (bodyCardPadding * 2))) { + 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.detailMenuRows, - {optionsCard.x + 12, optionsCard.y + 40, optionsCard.w - 24, optionsCard.h - 52}, + optionsRect, std::max(34, TTF_FontLineSkip(bodyFont) + 12) )) { return false; } - } else { - const SDL_Rect contentCard { - bodyPanel.x + 16, - bodyPanel.y + 16, - bodyPanel.w - 32, - std::max(1, bodyPanel.h - 32), - }; - fill_rect(renderer, contentCard, PANEL_ALT_RED, PANEL_ALT_GREEN, PANEL_ALT_BLUE, 0xC2); - draw_rect(renderer, contentCard, TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0x50); - int bodyY = contentCard.y + bodyCardPadding; + 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.selectedMenuRowLabel.empty()) { + int drawnHeight = 0; + if (!render_text_line(renderer, bodyFont, viewModel.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.selectedMenuRowDescription.empty() ? std::string("No description is available for the selected setting.") : viewModel.selectedMenuRowDescription; + if (!render_text_line(renderer, smallFont, descriptionText, {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, descriptionRect.x + 10, descriptionY, descriptionRect.w - 20)) { + return false; + } + } else { + int bodyY = bodyPanel.y + panelPadding; for (const std::string &line : viewModel.bodyLines) { int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, contentCard.x + bodyCardPadding, bodyY, contentCard.w - (bodyCardPadding * 2), &drawnHeight)) { // NOSONAR(cpp:S134) settings-body rendering keeps layout failure handling local + if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyY, bodyPanel.w - (panelPadding * 2), &drawnHeight)) { // NOSONAR(cpp:S134) settings-body rendering keeps layout failure handling local return false; } bodyY += drawnHeight + 8; @@ -3519,13 +3583,18 @@ namespace { namespace ui { - int run_shell(SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state, logging::Logger &logger) { // NOSONAR(cpp:S3776) shell loop owns all frame/update orchestration in one place by design + int run_shell( // NOSONAR(cpp:S3776) shell loop owns all frame/update orchestration in one place by design + SDL_Window *window, + const VIDEO_MODE &videoMode, + app::ClientState &state + ) { + logging::Logger *logger = logging::global_logger(); if (window == nullptr) { - return report_shell_failure(logger, "sdl", "Shell requires a valid SDL window"); + return report_shell_failure("sdl", "Shell requires a valid SDL window"); } if (TTF_Init() != 0) { - return report_shell_failure(logger, "ttf", std::string("TTF_Init failed: ") + TTF_GetError()); + return report_shell_failure("ttf", std::string("TTF_Init failed: ") + TTF_GetError()); } IMG_Init(IMG_INIT_PNG | IMG_INIT_JPG); @@ -3536,7 +3605,7 @@ namespace ui { if (renderer == nullptr) { IMG_Quit(); TTF_Quit(); - return report_shell_failure(logger, "sdl", std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); + return report_shell_failure("sdl", std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); } SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND); SDL_SetRenderDrawColor(renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); @@ -3560,7 +3629,7 @@ namespace ui { SDL_DestroyRenderer(renderer); IMG_Quit(); TTF_Quit(); - return report_shell_failure(logger, "ttf", std::string("Failed to load shell font from ") + fontPath + ": " + TTF_GetError()); + return report_shell_failure("ttf", std::string("Failed to load shell font from ") + fontPath + ": " + TTF_GetError()); } SDL_Texture *titleLogoTexture = load_texture_from_asset(renderer, "moonlight-logo.svg"); @@ -3570,7 +3639,7 @@ namespace ui { if (SDL_IsGameController(joystickIndex)) { controller = SDL_GameControllerOpen(joystickIndex); if (controller != nullptr) { - logger.log(logging::LogLevel::info, "input", "Opened primary controller"); + logging::logger.info("input", "Opened primary controller"); break; } } @@ -3609,17 +3678,22 @@ namespace ui { reset_app_list_task(&appListTask); reset_app_art_task(&appArtTask); reset_host_probe_task(&hostProbeTask); - logger.set_minimum_level(state.loggingLevel); - logger.log(logging::LogLevel::info, "app", "Entered interactive shell"); + if (logger != nullptr) { + logger->set_minimum_level(logging::LogLevel::trace); + logger->set_file_minimum_level(state.loggingLevel); + logger->set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); + } + logging::logger.info("app", "Entered interactive shell"); bool keypadRedrawRequested = true; const auto draw_current_shell = [&]() { - if (const auto viewModel = build_shell_view_model(state, logger.snapshot(logging::LogLevel::info)); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache, &keypadModalLayoutCache)) { + const std::vector retainedEntries = logger == nullptr ? std::vector {} : logger->snapshot(logging::LogLevel::info); + if (const auto viewModel = build_shell_view_model(state, retainedEntries); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache, &keypadModalLayoutCache)) { keypadRedrawRequested = false; return true; } - report_shell_failure(logger, "render", std::string("Shell render failed: ") + SDL_GetError()); + report_shell_failure("render", std::string("Shell render failed: ") + SDL_GetError()); running = false; state.shouldExit = true; return false; @@ -3634,22 +3708,26 @@ namespace ui { const app::ScreenId previousScreen = state.activeScreen; const app::AppUpdate update = app::handle_command(state, command); - logger.set_minimum_level(state.loggingLevel); - log_app_update(logger, state, update); - show_log_file_if_requested(logger, state, update); - cancel_pairing_if_requested(logger, state, update, &pairingTask); - test_host_connection_if_requested(logger, state, update); - browse_host_apps_if_requested(logger, state, update); - pair_host_if_requested(logger, state, update, &pairingTask); - delete_host_data_if_requested(logger, state, update, &coverArtTextureCache); - delete_saved_file_if_requested(logger, state, update, &coverArtTextureCache); - factory_reset_if_requested(logger, state, update, &coverArtTextureCache); - refresh_saved_files_if_needed(logger, state); - persist_hosts_if_needed(logger, state, update); + if (logger != nullptr) { + logger->set_file_minimum_level(state.loggingLevel); + logger->set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); + } + log_app_update(state, update); + show_log_file_if_requested(state, update); + cancel_pairing_if_requested(state, update, &pairingTask); + test_host_connection_if_requested(state, update); + browse_host_apps_if_requested(state, update); + pair_host_if_requested(state, update, &pairingTask); + delete_host_data_if_requested(state, update, &coverArtTextureCache); + delete_saved_file_if_requested(state, update, &coverArtTextureCache); + factory_reset_if_requested(state, update, &coverArtTextureCache); + refresh_saved_files_if_needed(state); + persist_settings_if_needed(state, update); + persist_hosts_if_needed(state, update); if (previousScreen != state.activeScreen) { release_page_resources_for_screen(previousScreen, state.activeScreen, &coverArtTextureCache, &keypadModalLayoutCache); - ensure_hosts_loaded_for_active_screen(logger, state); + ensure_hosts_loaded_for_active_screen(state); } if ((previousScreen != state.activeScreen || update.screenChanged) && !draw_current_shell()) { return; @@ -3660,15 +3738,15 @@ namespace ui { }; while (running && !state.shouldExit) { - ensure_hosts_loaded_for_active_screen(logger, state); - finish_pairing_task_if_ready(logger, state, &pairingTask); - finish_app_list_task_if_ready(logger, state, &appListTask); - finish_app_art_task_if_ready(logger, state, &appArtTask, &coverArtTextureCache); - finish_host_probe_task_if_ready(logger, state, &hostProbeTask); - refresh_saved_files_if_needed(logger, state); - start_host_probe_task_if_needed(logger, state, &hostProbeTask, SDL_GetTicks(), &nextHostProbeTick); - start_app_list_task_if_needed(logger, state, &appListTask, SDL_GetTicks()); - start_app_art_task_if_needed(logger, state, &appArtTask); + ensure_hosts_loaded_for_active_screen(state); + finish_pairing_task_if_ready(state, &pairingTask); + finish_app_list_task_if_ready(state, &appListTask); + finish_app_art_task_if_ready(state, &appArtTask, &coverArtTextureCache); + finish_host_probe_task_if_ready(state, &hostProbeTask); + refresh_saved_files_if_needed(state); + start_host_probe_task_if_needed(state, &hostProbeTask, SDL_GetTicks(), &nextHostProbeTick); + start_app_list_task_if_needed(state, &appListTask, SDL_GetTicks()); + start_app_art_task_if_needed(state, &appArtTask); if ( !controllerExitComboTriggered && controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) @@ -3678,7 +3756,7 @@ namespace ui { if (SDL_GetTicks() - comboStartTick >= EXIT_COMBO_HOLD_MILLISECONDS) { controllerExitComboTriggered = true; state.shouldExit = true; - logger.log(logging::LogLevel::info, "app", "Exit requested from held Start+Back on the hosts screen"); + logging::logger.info("app", "Exit requested from held Start+Back on the hosts screen"); } } @@ -3716,7 +3794,7 @@ namespace ui { if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing controller = SDL_GameControllerOpen(event.cdevice.which); if (controller != nullptr) { - logger.log(logging::LogLevel::info, "input", "Controller connected"); + logging::logger.info("input", "Controller connected"); } } break; @@ -3734,7 +3812,7 @@ namespace ui { controllerExitComboTriggered = false; controllerNavigationNeutralRequired = false; reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); - logger.log(logging::LogLevel::warning, "input", "Controller disconnected"); + logging::logger.warn("input", "Controller disconnected"); } break; case SDL_CONTROLLERBUTTONDOWN: @@ -3843,14 +3921,14 @@ namespace ui { process_command(poll_controller_navigation(controller, SDL_GetTicks(), &moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState)); } - finish_pairing_task_if_ready(logger, state, &pairingTask); - finish_app_list_task_if_ready(logger, state, &appListTask); - finish_app_art_task_if_ready(logger, state, &appArtTask, &coverArtTextureCache); - finish_host_probe_task_if_ready(logger, state, &hostProbeTask); + finish_pairing_task_if_ready(state, &pairingTask); + finish_app_list_task_if_ready(state, &appListTask); + finish_app_art_task_if_ready(state, &appArtTask, &coverArtTextureCache); + finish_host_probe_task_if_ready(state, &hostProbeTask); const Uint32 backgroundTaskTick = SDL_GetTicks(); - start_host_probe_task_if_needed(logger, state, &hostProbeTask, backgroundTaskTick, &nextHostProbeTick); - start_app_list_task_if_needed(logger, state, &appListTask, backgroundTaskTick); - start_app_art_task_if_needed(logger, state, &appArtTask); + start_host_probe_task_if_needed(state, &hostProbeTask, backgroundTaskTick, &nextHostProbeTick); + start_app_list_task_if_needed(state, &appListTask, backgroundTaskTick); + start_app_art_task_if_needed(state, &appArtTask); if ((state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || keypadRedrawRequested) && !draw_current_shell()) { break; @@ -3859,7 +3937,7 @@ namespace ui { if (pairingTask.activeAttempt != nullptr) { pairingTask.activeAttempt->discardResult.store(true); - finalize_pairing_attempt(logger, nullptr, std::move(pairingTask.activeAttempt)); + finalize_pairing_attempt(nullptr, std::move(pairingTask.activeAttempt)); } while (!pairingTask.retiredAttempts.empty()) { std::unique_ptr attempt = std::move(pairingTask.retiredAttempts.back()); @@ -3867,7 +3945,7 @@ namespace ui { if (attempt != nullptr) { attempt->discardResult.store(true); } - finalize_pairing_attempt(logger, nullptr, std::move(attempt)); + finalize_pairing_attempt(nullptr, std::move(attempt)); } if (appListTask.thread != nullptr) { int threadResult = 0; diff --git a/src/ui/shell_screen.h b/src/ui/shell_screen.h index e138f72..d215ebc 100644 --- a/src/ui/shell_screen.h +++ b/src/ui/shell_screen.h @@ -5,7 +5,6 @@ // local includes #include "src/app/client_state.h" -#include "src/logging/logger.h" struct SDL_Window; @@ -17,9 +16,12 @@ namespace ui { * @param window Shared SDL window created during startup. * @param videoMode Active output mode for the shell window. * @param state Mutable application state. - * @param logger Structured logger used for diagnostics and overlay content. * @return 0 on normal exit, non-zero if initialization failed. */ - int run_shell(SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state, logging::Logger &logger); + 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 index 02fd91c..80ad497 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -303,9 +303,13 @@ namespace { std::string("Category: ") + settings_category_label(state.selectedSettingsCategory), }; if (state.selectedSettingsCategory == app::SettingsCategory::logging) { - lines.push_back(std::string("Log file: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); - lines.push_back(std::string("Current logging level: ") + logging::to_string(state.loggingLevel)); - lines.emplace_back("Use View Log File to inspect persisted startup and applist diagnostics."); + lines.emplace_back("Runtime log file: reset on every startup"); + lines.push_back(std::string("Log file path: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); + lines.push_back(std::string("File logging level: ") + logging::to_string(state.loggingLevel)); + lines.push_back(std::string("xemu console logging level: ") + logging::to_string(state.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."); } else if (state.selectedSettingsCategory == app::SettingsCategory::reset) { if (state.savedFiles.empty()) { lines.emplace_back("Saved files: none found."); @@ -555,11 +559,17 @@ namespace ui { viewModel.bodyLines = body_lines(state); viewModel.menuRows = menu_rows(state); viewModel.detailMenuRows = detail_menu_rows(state); + if (state.activeScreen == app::ScreenId::settings || state.activeScreen == app::ScreenId::add_host || state.activeScreen == app::ScreenId::pair_host) { + viewModel.leftPanelActive = state.activeScreen != app::ScreenId::settings || state.settingsFocusArea == app::SettingsFocusArea::categories; + viewModel.rightPanelActive = state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options; + } if (state.activeScreen == app::ScreenId::settings || state.activeScreen == app::ScreenId::add_host || state.activeScreen == app::ScreenId::pair_host) { if (state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { viewModel.selectedMenuRowLabel = state.detailMenu.selected_item()->label; + viewModel.selectedMenuRowDescription = state.detailMenu.selected_item()->description; } else if (state.menu.selected_item() != nullptr) { viewModel.selectedMenuRowLabel = state.menu.selected_item()->label; + viewModel.selectedMenuRowDescription = state.menu.selected_item()->description; } } viewModel.footerActions = footer_actions(state); diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h index 036535f..68be6d7 100644 --- a/src/ui/shell_view.h +++ b/src/ui/shell_view.h @@ -110,6 +110,9 @@ namespace ui { 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. std::vector footerActions; ///< Footer actions shown for the current screen. bool overlayVisible = false; ///< True when the diagnostics overlay should be rendered. std::string overlayTitle; ///< Diagnostics overlay title. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d6dcd8a..10c0b37 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -45,6 +45,7 @@ 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} diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 069f6a5..0262e5a 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -19,7 +19,7 @@ namespace { EXPECT_FALSE(state.overlayVisible); EXPECT_FALSE(state.shouldExit); EXPECT_FALSE(state.hostsDirty); - EXPECT_TRUE(state.menu.items().empty()); + EXPECT_EQ(state.loggingLevel, logging::LogLevel::none); } TEST(ClientStateTest, ReplacesHostsFromPersistenceWithoutMarkingThemDirty) { @@ -59,7 +59,7 @@ namespace { EXPECT_EQ(state.activeScreen, app::ScreenId::add_host); } - TEST(ClientStateTest, SettingsCanRequestLogViewingAndCycleLoggingLevel) { + TEST(ClientStateTest, SettingsCanRequestLogViewingAndCycleBothLoggingLevelsFromNone) { app::ClientState state = app::create_initial_state(); app::handle_command(state, input::UiCommand::move_left); @@ -79,8 +79,15 @@ namespace { app::handle_command(state, input::UiCommand::move_down); update = app::handle_command(state, input::UiCommand::activate); EXPECT_FALSE(update.logViewRequested); - EXPECT_EQ(state.loggingLevel, logging::LogLevel::debug); - EXPECT_EQ(state.statusMessage, "Logging level set to DEBUG"); + EXPECT_TRUE(update.settingsChanged); + EXPECT_EQ(state.loggingLevel, logging::LogLevel::error); + EXPECT_EQ(state.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.settingsChanged); + EXPECT_EQ(state.xemuConsoleLoggingLevel, logging::LogLevel::error); + EXPECT_EQ(state.statusMessage, "xemu console logging level set to ERROR"); } TEST(ClientStateTest, TogglingAndScrollingTheOverlayUpdatesTheVisibleState) { diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp new file mode 100644 index 0000000..bedc912 --- /dev/null +++ b/tests/unit/app/settings_storage_test.cpp @@ -0,0 +1,96 @@ +// test header include +#include "src/app/settings_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, const std::string &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, 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); + } + +} // namespace diff --git a/tests/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp index 86240b0..75ccabc 100644 --- a/tests/unit/logging/log_file_test.cpp +++ b/tests/unit/logging/log_file_test.cpp @@ -71,4 +71,43 @@ namespace { 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"); + } + } // namespace diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp index efdf88e..812f2ec 100644 --- a/tests/unit/logging/logger_test.cpp +++ b/tests/unit/logging/logger_test.cpp @@ -1,4 +1,5 @@ -// class header include +// test header include +#include "src/logging/global_logger.h" #include "src/logging/logger.h" // standard includes @@ -15,11 +16,21 @@ namespace { 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, 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")); @@ -41,6 +52,8 @@ namespace { 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")); @@ -55,18 +68,81 @@ namespace { 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] ui: opened"); + 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, GlobalLoggerProxyCapturesTheCallsiteLocation) { + 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::logger.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, GlobalLoggerProxyReturnsFalseWhenNoLoggerIsRegistered) { + logging::set_global_logger(nullptr); + + EXPECT_FALSE(logging::logger.info("ui", "ignored")); + } + + 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, 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")); @@ -79,4 +155,14 @@ namespace { 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()); + } + } // 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..f94a080 --- /dev/null +++ b/tests/unit/logging/startup_debug_test.cpp @@ -0,0 +1,29 @@ +// test header include +#include "src/logging/startup_debug.h" + +// lib includes +#include + +namespace { + + TEST(StartupDebugTest, FormatsStructuredStartupConsoleLines) { + EXPECT_STREQ(logging::startup_status_block(logging::StartupConsoleStyle::pending), "[START ]"); + EXPECT_STREQ(logging::startup_status_block(logging::StartupConsoleStyle::trace), "[TRACE ]"); + EXPECT_STREQ(logging::startup_status_block(logging::StartupConsoleStyle::error), "[ERROR ]"); + EXPECT_EQ( + logging::format_startup_console_line(logging::StartupConsoleStyle::warning, "network", "Runtime networking is unavailable"), + "[ WARN ] network: Runtime networking is unavailable" + ); + } + + 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/startup/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp index 9c0d6e5..4decb83 100644 --- a/tests/unit/startup/saved_files_test.cpp +++ b/tests/unit/startup/saved_files_test.cpp @@ -23,9 +23,10 @@ namespace { 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 pairingDirectory = test_support::join_path(testDirectory, "pairing"); 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"); @@ -33,6 +34,7 @@ namespace { std::string coverArtFilePath = test_support::join_path(coverArtDirectory, "cover-101.bin"); startup::SavedFileCatalogConfig config { hostStoragePath, + settingsFilePath, logFilePath, pairingDirectory, coverArtDirectory, @@ -50,6 +52,7 @@ namespace { 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(coverArtDirectory); test_support::remove_directory_if_present(pairingDirectory); @@ -59,6 +62,7 @@ namespace { 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'}); @@ -68,13 +72,14 @@ namespace { const startup::ListSavedFilesResult result = startup::list_saved_files(config); EXPECT_TRUE(result.warnings.empty()); - ASSERT_EQ(result.files.size(), 6U); + 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, test_support::join_path("pairing", "client.pem")); - EXPECT_EQ(result.files[4].displayName, test_support::join_path("pairing", "key.pem")); - EXPECT_EQ(result.files[5].displayName, test_support::join_path("pairing", "uniqueid.dat")); + 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) { @@ -100,6 +105,7 @@ namespace { 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'}); diff --git a/tests/unit/ui/menu_model_test.cpp b/tests/unit/ui/menu_model_test.cpp index 6b06333..a3d091b 100644 --- a/tests/unit/ui/menu_model_test.cpp +++ b/tests/unit/ui/menu_model_test.cpp @@ -6,9 +6,9 @@ namespace { TEST(MenuModelTest, SelectsTheFirstEnabledItemWhenConstructed) { const ui::MenuModel menu({ - {"disabled", "Disabled", false}, - {"hosts", "Hosts", true}, - {"settings", "Settings", true}, + {"disabled", "Disabled", {}, false}, + {"hosts", "Hosts", {}, true}, + {"settings", "Settings", {}, true}, }); ASSERT_NE(menu.selected_item(), nullptr); @@ -18,9 +18,9 @@ namespace { TEST(MenuModelTest, MovesSelectionAndSkipsDisabledItems) { ui::MenuModel menu({ - {"hosts", "Hosts", true}, - {"disabled", "Disabled", false}, - {"settings", "Settings", true}, + {"hosts", "Hosts", {}, true}, + {"disabled", "Disabled", {}, false}, + {"settings", "Settings", {}, true}, }); const ui::MenuUpdate update = menu.handle_command(input::UiCommand::move_down); @@ -32,8 +32,8 @@ namespace { TEST(MenuModelTest, WrapsAroundWhenMovingPastTheLastItem) { ui::MenuModel menu({ - {"hosts", "Hosts", true}, - {"settings", "Settings", true}, + {"hosts", "Hosts", {}, true}, + {"settings", "Settings", {}, true}, }); EXPECT_TRUE(menu.handle_command(input::UiCommand::move_up).selectionChanged); @@ -43,8 +43,8 @@ namespace { TEST(MenuModelTest, ActivatesTheSelectedItem) { ui::MenuModel menu({ - {"hosts", "Hosts", true}, - {"settings", "Settings", true}, + {"hosts", "Hosts", {}, true}, + {"settings", "Settings", {}, true}, }); const ui::MenuUpdate update = menu.handle_command(input::UiCommand::activate); @@ -55,9 +55,9 @@ namespace { TEST(MenuModelTest, CanSelectAnEnabledItemById) { ui::MenuModel menu({ - {"hosts", "Hosts", true}, - {"disabled", "Disabled", false}, - {"settings", "Settings", true}, + {"hosts", "Hosts", {}, true}, + {"disabled", "Disabled", {}, false}, + {"settings", "Settings", {}, true}, }); EXPECT_TRUE(menu.select_item_by_id("settings")); @@ -69,7 +69,7 @@ namespace { TEST(MenuModelTest, SurfacesBackAndOverlayActionsWithoutChangingSelection) { ui::MenuModel menu({ - {"hosts", "Hosts", true}, + {"hosts", "Hosts", {}, true}, }); const ui::MenuUpdate backUpdate = menu.handle_command(input::UiCommand::back); @@ -83,7 +83,7 @@ namespace { TEST(MenuModelTest, SurfacesFastPageActionsWithoutChangingSelection) { ui::MenuModel menu({ - {"hosts", "Hosts", true}, + {"hosts", "Hosts", {}, true}, }); const ui::MenuUpdate previousUpdate = menu.handle_command(input::UiCommand::fast_previous_page); diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 8af5921..6d60909 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -99,26 +99,29 @@ namespace { TEST(ShellViewTest, ShowsLoggingDetailsOnTheSettingsScreen) { app::ClientState state = app::create_initial_state(); + state.loggingLevel = logging::LogLevel::none; + state.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.activeScreen, app::ScreenId::settings); app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); - state.loggingLevel = logging::LogLevel::debug; - app::replace_saved_files(state, { - {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 2048U}, - {"E:\\UDATA\\12345678\\pairing\\client.pem", "pairing\\client.pem", 1536U}, - }); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - ASSERT_GE(viewModel.bodyLines.size(), 4U); + ASSERT_GE(viewModel.bodyLines.size(), 7U); EXPECT_EQ(viewModel.bodyLines[0], "Category: Logging"); - EXPECT_EQ(viewModel.bodyLines[1], "Log file: E:\\UDATA\\12345678\\moonlight.log"); - EXPECT_EQ(viewModel.bodyLines[2], "Current logging level: DEBUG"); - EXPECT_EQ(viewModel.bodyLines[3], "Use View Log File to inspect persisted startup and applist diagnostics."); - ASSERT_EQ(viewModel.detailMenuRows.size(), 2U); + EXPECT_EQ(viewModel.bodyLines[1], "Runtime log file: reset on every startup"); + EXPECT_EQ(viewModel.bodyLines[2], "Log file path: E:\\UDATA\\12345678\\moonlight.log"); + EXPECT_EQ(viewModel.bodyLines[3], "File logging level: NONE"); + EXPECT_EQ(viewModel.bodyLines[4], "xemu console logging level: WARN"); + ASSERT_EQ(viewModel.detailMenuRows.size(), 3U); EXPECT_EQ(viewModel.detailMenuRows[0].label, "View Log File"); + EXPECT_EQ(viewModel.detailMenuRows[1].label, "File Logging Level: NONE"); + EXPECT_EQ(viewModel.detailMenuRows[2].label, "xemu Console Level: WARN"); + EXPECT_EQ(viewModel.selectedMenuRowDescription, "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."); + EXPECT_TRUE(viewModel.leftPanelActive); + EXPECT_FALSE(viewModel.rightPanelActive); } TEST(ShellViewTest, ExposesTheFullSelectedSettingsLabelBesideTheMenu) { @@ -138,6 +141,49 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); EXPECT_EQ(viewModel.selectedMenuRowLabel, "Delete pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin"); + EXPECT_EQ(viewModel.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.activeScreen, app::ScreenId::settings); + + state.settingsFocusArea = 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.selectedMenuRowLabel, "xemu Console Level: NONE"); + EXPECT_EQ(viewModel.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.activeScreen, app::ScreenId::settings); + + state.settingsFocusArea = app::SettingsFocusArea::options; + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_FALSE(viewModel.leftPanelActive); + EXPECT_TRUE(viewModel.rightPanelActive); + } + + TEST(ShellViewTest, KeepsTheLeftPanelActiveOnTheAddHostScreen) { + app::ClientState state = app::create_initial_state(); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); + + const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); + + EXPECT_TRUE(viewModel.leftPanelActive); + EXPECT_FALSE(viewModel.rightPanelActive); } TEST(ShellViewTest, BuildsTheAddHostKeypadModalAsANumberPad) { 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 From e31a886bc1b34fa98edf2892a5b2254453ad7f64 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 12 Apr 2026 10:46:01 -0400 Subject: [PATCH 18/35] Consolidate global logger and startup debug Remove separate global_logger and startup_debug modules and move their functionality into logger.{h,cpp}. Introduces process-wide logger management and convenience logging APIs (set_global_logger, log/trace/debug/info/warn/error, set_minimum_level, set_file_sink, set_file_minimum_level, set_debugger_console_minimum_level, set_startup_debug_enabled, snapshot, format_startup_console_line, startup console enable/print helpers). Replace uses of logging::logger proxy and StartupConsoleStyle with the new logging::* free functions and LogLevel-based startup console functions across main, splash, startup, ui and tests. Add internal g_globalLogger and startup-console state, and NXDK-aware startup console printing. Update includes to use logger.h and tidy up related tests. --- src/logging/global_logger.cpp | 65 --------- src/logging/global_logger.h | 118 ----------------- src/logging/logger.cpp | 124 ++++++++++++++++- src/logging/logger.h | 154 ++++++++++++++++++++++ src/logging/startup_debug.cpp | 87 ------------ src/logging/startup_debug.h | 74 ----------- src/main.cpp | 52 ++++---- src/splash/splash_screen.cpp | 26 ++-- src/startup/memory_stats.cpp | 4 +- src/startup/video_mode.cpp | 4 +- src/ui/shell_screen.cpp | 119 ++++++++--------- tests/unit/logging/logger_test.cpp | 9 +- tests/unit/logging/startup_debug_test.cpp | 13 +- 13 files changed, 384 insertions(+), 465 deletions(-) delete mode 100644 src/logging/global_logger.cpp delete mode 100644 src/logging/global_logger.h delete mode 100644 src/logging/startup_debug.cpp delete mode 100644 src/logging/startup_debug.h diff --git a/src/logging/global_logger.cpp b/src/logging/global_logger.cpp deleted file mode 100644 index 3c5d3b2..0000000 --- a/src/logging/global_logger.cpp +++ /dev/null @@ -1,65 +0,0 @@ -// class header include -#include "src/logging/global_logger.h" - -// standard includes -#include - -namespace { - - logging::Logger *g_globalLogger = nullptr; - -} // namespace - -namespace logging { - - const GlobalLoggerProxy logger {}; - - void set_global_logger(Logger *logger) { - g_globalLogger = logger; - } - - Logger *global_logger() { - return g_globalLogger; - } - - bool has_global_logger() { - return global_logger() != nullptr; - } - - bool GlobalLoggerProxy::available() const { - return has_global_logger(); - } - - Logger *GlobalLoggerProxy::get() const { - return global_logger(); - } - - bool GlobalLoggerProxy::log(LogLevel level, std::string category, std::string message, LogSourceLocation location) const { - if (Logger *registeredLogger = global_logger(); registeredLogger != nullptr) { - return registeredLogger->log(level, std::move(category), std::move(message), location); - } - - return false; - } - - bool GlobalLoggerProxy::trace(std::string category, std::string message, LogSourceLocation location) const { - return log(LogLevel::trace, std::move(category), std::move(message), location); - } - - bool GlobalLoggerProxy::debug(std::string category, std::string message, LogSourceLocation location) const { - return log(LogLevel::debug, std::move(category), std::move(message), location); - } - - bool GlobalLoggerProxy::info(std::string category, std::string message, LogSourceLocation location) const { - return log(LogLevel::info, std::move(category), std::move(message), location); - } - - bool GlobalLoggerProxy::warn(std::string category, std::string message, LogSourceLocation location) const { - return log(LogLevel::warning, std::move(category), std::move(message), location); - } - - bool GlobalLoggerProxy::error(std::string category, std::string message, LogSourceLocation location) const { - return log(LogLevel::error, std::move(category), std::move(message), location); - } - -} // namespace logging diff --git a/src/logging/global_logger.h b/src/logging/global_logger.h deleted file mode 100644 index 9866e67..0000000 --- a/src/logging/global_logger.h +++ /dev/null @@ -1,118 +0,0 @@ -#pragma once - -// standard includes -#include - -// local includes -#include "src/logging/logger.h" - -namespace logging { - - /** - * @brief Object-style facade that forwards calls to the registered process-wide logger. - */ - class GlobalLoggerProxy { - public: - /** - * @brief Return whether a process-wide logger is currently registered. - * - * @return true when global logging calls can emit entries. - */ - [[nodiscard]] bool available() const; - - /** - * @brief Return the registered process-wide logger. - * - * @return The registered logger, or nullptr when none is available. - */ - [[nodiscard]] Logger *get() const; - - /** - * @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()) const; - - /** - * @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()) const; - - /** - * @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()) const; - - /** - * @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()) const; - - /** - * @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()) const; - - /** - * @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()) const; - }; - - /** - * @brief Object-style facade used for convenience logging calls such as logging::logger.info(...). - */ - extern const GlobalLoggerProxy logger; - - /** - * @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 the process-wide logger used by convenience logging helpers. - * - * @return The registered logger, or nullptr when none is available. - */ - Logger *global_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(); - -} // namespace logging diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index 68a1ad9..52b1afe 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -14,18 +14,27 @@ #endif #if defined(NXDK) + #include #include #endif -// local includes -#include "src/logging/startup_debug.h" - namespace { + logging::Logger *g_globalLogger = nullptr; + bool g_startupConsoleEnabled = true; + bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { return static_cast(candidateLevel) >= static_cast(minimumLevel); } + logging::Logger *registered_logger() { + return g_globalLogger; + } + + 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') { @@ -177,6 +186,113 @@ namespace logging { return line; } + void set_global_logger(Logger *logger) { + g_globalLogger = 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 (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) { + g_startupConsoleEnabled = enabled; + } + + bool startup_console_enabled() { + return g_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)) {} @@ -268,7 +384,7 @@ namespace logging { } if (startupDebugEnabled_) { - print_startup_log(entry.level, entry.category, entry.message); + print_startup_console_line(entry.level, entry.category, entry.message); } if (fileSink_ && is_enabled(level, fileMinimumLevel_)) { fileSink_(entry); diff --git a/src/logging/logger.h b/src/logging/logger.h index 2d225c3..46754f7 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace logging { @@ -93,6 +94,8 @@ namespace logging { */ using TimestampProvider = std::function; + class Logger; + /** * @brief Return the display label for a log level. * @@ -125,6 +128,157 @@ namespace logging { */ 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. */ diff --git a/src/logging/startup_debug.cpp b/src/logging/startup_debug.cpp deleted file mode 100644 index 6d2ac9d..0000000 --- a/src/logging/startup_debug.cpp +++ /dev/null @@ -1,87 +0,0 @@ -// class header include -#include "src/logging/startup_debug.h" - -// standard includes -#include - -// nxdk includes -#include - -namespace { - - bool g_startupConsoleEnabled = true; - - logging::StartupConsoleStyle startup_console_style_for_level(logging::LogLevel level) { - switch (level) { - case logging::LogLevel::trace: - return logging::StartupConsoleStyle::trace; - case logging::LogLevel::debug: - return logging::StartupConsoleStyle::debug; - case logging::LogLevel::info: - case logging::LogLevel::none: - return logging::StartupConsoleStyle::info; - case logging::LogLevel::warning: - return logging::StartupConsoleStyle::warning; - case logging::LogLevel::error: - return logging::StartupConsoleStyle::error; - } - - return logging::StartupConsoleStyle::info; - } - -} // namespace - -namespace logging { - - const char *startup_status_block(StartupConsoleStyle style) { - switch (style) { - case StartupConsoleStyle::pending: - return "[START ]"; - case StartupConsoleStyle::trace: - return "[TRACE ]"; - case StartupConsoleStyle::debug: - return "[DEBUG ]"; - case StartupConsoleStyle::info: - return "[ INFO ]"; - case StartupConsoleStyle::warning: - return "[ WARN ]"; - case StartupConsoleStyle::error: - return "[ERROR ]"; - } - - return "[ INFO ]"; - } - - std::string format_startup_console_line(StartupConsoleStyle style, std::string_view category, std::string_view message) { - std::string line = startup_status_block(style); - line.push_back(' '); - 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) { - g_startupConsoleEnabled = enabled; - } - - bool startup_console_enabled() { - return g_startupConsoleEnabled; - } - - void print_startup_console_line(StartupConsoleStyle style, std::string_view category, std::string_view message) { - if (!startup_console_enabled()) { - return; - } - - const std::string line = format_startup_console_line(style, category, message); - debugPrint("%s\n", line.c_str()); - } - - void print_startup_log(LogLevel level, std::string_view category, std::string_view message) { - print_startup_console_line(startup_console_style_for_level(level), category, message); - } - -} // namespace logging diff --git a/src/logging/startup_debug.h b/src/logging/startup_debug.h deleted file mode 100644 index a4015bd..0000000 --- a/src/logging/startup_debug.h +++ /dev/null @@ -1,74 +0,0 @@ -#pragma once - -// standard includes -#include -#include - -// local includes -#include "src/logging/logger.h" - -namespace logging { - - /** - * @brief Fixed-width startup console prefixes used by the pre-splash debug wrapper. - */ - enum class StartupConsoleStyle { - pending, - trace, - debug, - info, - warning, - error, - }; - - /** - * @brief Return the fixed-width status block shown at the start of startup console lines. - * - * @param style Startup console style to stringify. - * @return Fixed-width status block text. - */ - [[nodiscard]] const char *startup_status_block(StartupConsoleStyle style); - - /** - * @brief Format one startup console line without writing it to the debug console. - * - * @param style Debian-style token shown for the line. - * @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(StartupConsoleStyle style, 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 Debian-style startup console line when output is enabled. - * - * @param style Debian-style token shown for the line. - * @param category Short subsystem category such as startup or sdl. - * @param message Human-readable console text. - */ - void print_startup_console_line(StartupConsoleStyle style, std::string_view category, std::string_view message); - - /** - * @brief Print one structured log-level startup console line when output is enabled. - * - * @param level Structured log level to map into a console token. - * @param category Short subsystem category such as startup or sdl. - * @param message Human-readable console text. - */ - void print_startup_log(LogLevel level, std::string_view category, std::string_view message); - -} // namespace logging diff --git a/src/main.cpp b/src/main.cpp index 5f48f36..6a28a2b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -13,10 +13,8 @@ // local includes #include "src/app/client_state.h" #include "src/app/settings_storage.h" -#include "src/logging/global_logger.h" #include "src/logging/log_file.h" #include "src/logging/logger.h" -#include "src/logging/startup_debug.h" #include "src/network/runtime_network.h" #include "src/splash/splash_screen.h" #include "src/startup/host_storage.h" @@ -45,31 +43,31 @@ namespace { apply_persisted_settings(state, loadResult.settings); for (const std::string &warning : loadResult.warnings) { - logging::logger.warn("settings", warning); + logging::warn("settings", warning); } if (!loadResult.fileFound) { - logging::logger.info("settings", "No persisted settings file found. Using defaults."); + logging::info("settings", "No persisted settings file found. Using defaults."); return; } - logging::logger.info("settings", "Loaded persisted Moonlight settings"); + 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::logger.info("settings", "Removed obsolete settings keys from the persisted configuration"); + logging::info("settings", "Removed obsolete settings keys from the persisted configuration"); return; } - logging::logger.warn("settings", saveResult.errorMessage.empty() ? "Failed to rewrite the settings file after cleaning obsolete keys" : saveResult.errorMessage); + 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::logger.error(category, message); - logging::print_startup_console_line(logging::StartupConsoleStyle::warning, category, "Holding failure screen for 5 seconds before exit."); + 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; } @@ -79,17 +77,17 @@ namespace { return; } - logging::print_startup_console_line(logging::StartupConsoleStyle::info, "startup", message); + 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::StartupConsoleStyle::info, + 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::StartupConsoleStyle::info, "video", line); + logging::print_startup_console_line(logging::LogLevel::info, "video", line); } } @@ -105,7 +103,7 @@ namespace { (encoderSettings & VIDEO_MODE_720P) != 0 ? "on" : "off", (encoderSettings & VIDEO_MODE_1080I) != 0 ? "on" : "off" ); - logging::print_startup_console_line(logging::StartupConsoleStyle::info, "video", messageBuffer.data()); + logging::print_startup_console_line(logging::LogLevel::info, "video", messageBuffer.data()); } int run_startup_task(void *context) { @@ -133,15 +131,15 @@ namespace { } for (const std::string &warning : task->loadedHosts.warnings) { - logging::logger.warn("hosts", warning); + 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::logger.info("hosts", "Loaded " + std::to_string(task->loadedHosts.hosts.size()) + " saved host record(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::logger.log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); + logging::log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); } if (!task->runtimeNetworkStatus.ready) { clientState.statusMessage = task->runtimeNetworkStatus.summary; @@ -153,12 +151,12 @@ namespace { int main() { logging::Logger logger; logging::set_global_logger(&logger); - logger.set_minimum_level(logging::LogLevel::trace); + logging::set_minimum_level(logging::LogLevel::trace); app::ClientState clientState = app::create_initial_state(); load_persisted_settings(clientState); - logger.set_file_minimum_level(clientState.loggingLevel); - logger.set_debugger_console_minimum_level(clientState.xemuConsoleLoggingLevel); + logging::set_file_minimum_level(clientState.loggingLevel); + logging::set_debugger_console_minimum_level(clientState.xemuConsoleLoggingLevel); const std::string logFilePath = logging::default_log_file_path(); logging::RuntimeLogFileSink runtimeLogFile(logFilePath); @@ -167,17 +165,17 @@ int main() { std::string logFileResetError; if (!runtimeLogFile.reset(&logFileResetError)) { logging::print_startup_console_line( - logging::StartupConsoleStyle::warning, + logging::LogLevel::warning, "logging", logFileResetError.empty() ? "Failed to reset the runtime log file." : logFileResetError ); } - logger.set_file_sink([&runtimeLogFile](const logging::LogEntry &entry) { + logging::set_file_sink([&runtimeLogFile](const logging::LogEntry &entry) { std::string ignoredError; runtimeLogFile.consume(entry, &ignoredError); }); - logging::logger.info("app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); + logging::info("app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); debug_print_startup_checkpoint("Runtime logging initialized"); debug_print_encoder_settings(XVideoGetEncoderSettings()); @@ -225,9 +223,9 @@ int main() { debug_print_startup_checkpoint("Background startup task created"); } - logging::logger.info("app", "Showing splash screen"); + logging::info("app", "Showing splash screen"); debug_print_startup_checkpoint("About to show splash screen"); - logger.set_startup_debug_enabled(false); + logging::set_startup_debug_enabled(false); logging::set_startup_console_enabled(false); splash::show_splash_screen(window, bestVideoMode, [&startupTask]() { return !startupTask.completed.load(); @@ -236,16 +234,16 @@ int main() { finish_startup_task(clientState, &startupTask); startup::log_video_modes(videoModeSelection); - logging::logger.info("app", "Starting interactive shell"); + logging::info("app", "Starting interactive shell"); const int exitCode = ui::run_shell(window, bestVideoMode, clientState); if (clientState.hostsDirty) { const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(clientState.hosts); if (saveResult.success) { - logging::logger.info("hosts", "Saved host records before exit"); + logging::info("hosts", "Saved host records before exit"); clientState.hostsDirty = false; } else { - logging::logger.error("hosts", saveResult.errorMessage); + logging::error("hosts", saveResult.errorMessage); } } diff --git a/src/splash/splash_screen.cpp b/src/splash/splash_screen.cpp index fa27ca5..4196a03 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -14,7 +14,7 @@ #include // NOSONAR(cpp:S3806) nxdk requires lowercase header names // local includes -#include "src/logging/global_logger.h" +#include "src/logging/logger.h" #include "src/os.h" #include "src/splash/splash_layout.h" @@ -25,15 +25,15 @@ namespace { constexpr Uint8 SPLASH_BACKGROUND_BLUE = 0x64; void printSDLErrorAndReboot() { - logging::logger.error("splash", std::string("SDL error: ") + SDL_GetError()); - logging::logger.warn("splash", "Rebooting in 5 seconds."); + logging::error("splash", std::string("SDL error: ") + SDL_GetError()); + logging::warn("splash", "Rebooting in 5 seconds."); Sleep(5000); XReboot(); } void printIMGErrorAndReboot() { - logging::logger.error("splash", std::string("SDL_image error: ") + IMG_GetError()); - logging::logger.warn("splash", "Rebooting in 5 seconds."); + logging::error("splash", std::string("SDL_image error: ") + IMG_GetError()); + logging::warn("splash", "Rebooting in 5 seconds."); Sleep(5000); XReboot(); } @@ -139,20 +139,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) { - logging::logger.error("splash", std::string("Failed to create scaled splash asset surface: ") + 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) { - logging::logger.error("splash", std::string("Failed to lock source splash asset surface: ") + 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) { - logging::logger.error("splash", std::string("Failed to lock scaled splash asset surface: ") + 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); @@ -172,7 +172,7 @@ namespace { SDL_FreeSurface(sourceSurface); if (SDL_SetSurfaceBlendMode(scaledSurface, SDL_BLENDMODE_BLEND) < 0) { - logging::logger.error("splash", std::string("Failed to enable alpha blending for scaled splash asset: ") + SDL_GetError()); + logging::error("splash", std::string("Failed to enable alpha blending for scaled splash asset: ") + SDL_GetError()); SDL_FreeSurface(scaledSurface); return nullptr; } @@ -187,7 +187,7 @@ namespace { SDL_Surface *normalizedSurface = SDL_ConvertSurfaceFormat(surface, SDL_PIXELFORMAT_ARGB8888, 0); if (normalizedSurface == nullptr) { - logging::logger.error( + logging::error( "splash", std::string("Failed to normalize splash asset surface format ") + SDL_GetPixelFormatName(surface->format->format) + ": " + SDL_GetError() ); @@ -198,7 +198,7 @@ namespace { SDL_FreeSurface(surface); if (SDL_SetSurfaceBlendMode(normalizedSurface, SDL_BLENDMODE_BLEND) < 0) { - logging::logger.error("splash", std::string("Failed to enable alpha blending for splash asset: ") + SDL_GetError()); + logging::error("splash", std::string("Failed to enable alpha blending for splash asset: ") + SDL_GetError()); SDL_FreeSurface(normalizedSurface); return nullptr; } @@ -213,10 +213,10 @@ namespace { return normalizedSurface; } - logging::logger.error("splash", "Failed to prepare splash asset " + assetPath + " for rendering."); + logging::error("splash", "Failed to prepare splash asset " + assetPath + " for rendering."); } - logging::logger.error("splash", "Failed to load splash asset " + assetPath + ": " + IMG_GetError()); + logging::error("splash", "Failed to load splash asset " + assetPath + ": " + IMG_GetError()); return nullptr; } diff --git a/src/startup/memory_stats.cpp b/src/startup/memory_stats.cpp index 3b0f18a..fbe4d3d 100644 --- a/src/startup/memory_stats.cpp +++ b/src/startup/memory_stats.cpp @@ -2,7 +2,7 @@ #include "src/startup/memory_stats.h" // local includes -#include "src/logging/global_logger.h" +#include "src/logging/logger.h" // nxdk includes #include @@ -38,7 +38,7 @@ namespace startup { void log_memory_statistics() { for (const std::string &line : format_memory_statistics_lines()) { - logging::logger.info("memory", line); + logging::info("memory", line); } } diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index 0f581f3..f086324 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -2,7 +2,7 @@ #include "src/startup/video_mode.h" // local includes -#include "src/logging/global_logger.h" +#include "src/logging/logger.h" namespace startup { @@ -85,7 +85,7 @@ namespace startup { void log_video_modes(const VideoModeSelection &selection) { for (const std::string &line : format_video_mode_lines(selection)) { - logging::logger.info("video", line); + logging::info("video", line); } } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 3fcae7d..d607792 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -23,8 +23,8 @@ // local includes #include "src/app/settings_storage.h" #include "src/input/navigation_input.h" -#include "src/logging/global_logger.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" @@ -134,8 +134,8 @@ namespace { } int report_shell_failure(const char *category, const std::string &message) { - logging::logger.error(category, message); - logging::logger.warn(category, "Holding the failure screen for 5 seconds before exit."); + logging::error(category, message); + logging::warn(category, "Holding the failure screen for 5 seconds before exit."); Sleep(5000); return 1; } @@ -1848,16 +1848,16 @@ namespace { void log_app_update(const app::ClientState &state, const app::AppUpdate &update) { if (!update.activatedItemId.empty()) { - logging::logger.info("ui", "Activated menu item: " + update.activatedItemId); + logging::info("ui", "Activated menu item: " + update.activatedItemId); } if (update.screenChanged) { - logging::logger.info("ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); + logging::info("ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); } if (update.overlayVisibilityChanged) { - logging::logger.info("overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + logging::info("overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); } if (update.exitRequested) { - logging::logger.info("app", "Exit requested from shell"); + logging::info("app", "Exit requested from shell"); } } @@ -1902,7 +1902,7 @@ namespace { const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); for (const std::string &warning : loadedHosts.warnings) { - logging::logger.warn("hosts", warning); + logging::warn("hosts", warning); } app::replace_hosts(state, loadedHosts.hosts, state.statusMessage); return true; @@ -1915,7 +1915,7 @@ namespace { } else if (state.activeHostLoaded) { const startup::LoadSavedHostsResult loadedHosts = startup::load_saved_hosts(); for (const std::string &warning : loadedHosts.warnings) { - logging::logger.warn("hosts", warning); + logging::warn("hosts", warning); } hostsToSave = loadedHosts.hosts; if (app::HostRecord *host = find_persisted_host_record(hostsToSave, state.activeHost.address, state.activeHost.port); host != nullptr) { @@ -1930,11 +1930,11 @@ namespace { const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hostsToSave); if (saveResult.success) { state.hostsDirty = false; - logging::logger.info("hosts", "Saved host records"); + logging::info("hosts", "Saved host records"); return true; } - logging::logger.error("hosts", saveResult.errorMessage); + logging::error("hosts", saveResult.errorMessage); return false; } @@ -1962,11 +1962,11 @@ namespace { const app::SaveAppSettingsResult saveResult = app::save_app_settings(persistent_settings_from_state(state)); if (saveResult.success) { state.settingsDirty = false; - logging::logger.info("settings", "Saved Moonlight settings"); + logging::info("settings", "Saved Moonlight settings"); return; } - logging::logger.error("settings", saveResult.errorMessage); + logging::error("settings", saveResult.errorMessage); } void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { // NOSONAR(cpp:S3776) host metadata updates intentionally stay grouped with pairing-state transitions @@ -2067,7 +2067,7 @@ namespace { const startup::ListSavedFilesResult savedFiles = startup::list_saved_files(); for (const std::string &warning : savedFiles.warnings) { - logging::logger.warn("storage", warning); + logging::warn("storage", warning); } app::replace_saved_files(state, savedFiles.files); } @@ -2088,7 +2088,7 @@ namespace { if (std::string errorMessage; !startup::delete_saved_file(update.savedFileDeletePath, &errorMessage)) { state.statusMessage = errorMessage; - logging::logger.warn("storage", errorMessage); + logging::warn("storage", errorMessage); return; } @@ -2098,7 +2098,7 @@ namespace { clear_cover_art_texture(coverArtTextureCache, deletedCoverArtCacheKey); state.savedFilesDirty = true; state.statusMessage = "Deleted saved file " + deletedDisplayName; - logging::logger.info("storage", state.statusMessage); + logging::info("storage", state.statusMessage); } void delete_host_data_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { @@ -2109,7 +2109,7 @@ namespace { std::size_t deletedCoverArtCount = 0U; for (const std::string &cacheKey : update.deletedHostCoverArtCacheKeys) { if (std::string errorMessage; !startup::delete_cover_art(cacheKey, &errorMessage)) { - logging::logger.warn("storage", errorMessage); + logging::warn("storage", errorMessage); } else { ++deletedCoverArtCount; } @@ -2124,12 +2124,12 @@ namespace { if (!pairedHostsRemain) { std::string errorMessage; if (!startup::delete_client_identity(&errorMessage)) { - logging::logger.warn("storage", errorMessage); + logging::warn("storage", errorMessage); } else { deletedClientIdentity = true; } } else { - logging::logger.info("storage", "Retained the shared pairing identity because other paired hosts still exist"); + logging::info("storage", "Retained the shared pairing identity because other paired hosts still exist"); } } @@ -2140,7 +2140,7 @@ namespace { if (deletedClientIdentity) { state.statusMessage += " and reset local pairing identity"; } - logging::logger.info("storage", state.statusMessage); + logging::info("storage", state.statusMessage); } void factory_reset_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { @@ -2150,7 +2150,7 @@ namespace { if (std::string errorMessage; !startup::delete_all_saved_files(&errorMessage)) { state.statusMessage = errorMessage; - logging::logger.warn("storage", errorMessage); + logging::warn("storage", errorMessage); return; } @@ -2161,7 +2161,7 @@ namespace { state.statusMessage = "Factory reset completed"; clear_cover_art_texture_cache(coverArtTextureCache); app::set_log_file_path(state, logging::default_log_file_path()); - logging::logger.info("storage", state.statusMessage); + logging::info("storage", state.statusMessage); } bool try_load_saved_pairing_identity(network::PairingIdentity *identity) { @@ -2338,11 +2338,11 @@ namespace { reset_pairing_attempt(attempt.get()); for (const PairingAttemptState::DeferredLogEntry &entry : deferredLogs) { - logging::logger.log(entry.level, "pairing", entry.message); + logging::log(entry.level, "pairing", entry.message); } if (discardResult || state == nullptr) { - logging::logger.info("pairing", "Ignored a completed pairing result after leaving the pairing screen or starting a new attempt"); + logging::info("pairing", "Ignored a completed pairing result after leaving the pairing screen or starting a new attempt"); return; } @@ -2354,7 +2354,7 @@ namespace { result.message ); - logging::logger.log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); + logging::log(result.success || result.alreadyPaired ? logging::LogLevel::info : logging::LogLevel::warning, "pairing", result.message); if (hostsChanged) { persist_hosts(*state); } @@ -2526,7 +2526,7 @@ namespace { task->activeAttempt->cancelRequested.store(true); retire_active_pairing_attempt(task, true); state.statusMessage.clear(); - logging::logger.info("pairing", "Cancelled the in-flight pairing attempt after leaving the pairing screen"); + 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) { @@ -2539,7 +2539,7 @@ namespace { if (address.empty()) { app::apply_connection_test_result(state, false, "Connection test failed because the host address is invalid"); - logging::logger.warn("hosts", state.statusMessage); + logging::warn("hosts", state.statusMessage); return; } @@ -2561,7 +2561,7 @@ namespace { } } app::apply_connection_test_result(state, success, resultMessage); - logging::logger.log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); + logging::log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); if (state.hostsDirty) { persist_hosts(state); } @@ -2593,7 +2593,7 @@ namespace { } } state.statusMessage = resultMessage; - logging::logger.warn("apps", resultMessage); + logging::warn("apps", resultMessage); return; } @@ -2605,16 +2605,16 @@ namespace { host = app::selected_host(state); if (host == nullptr || host->pairingState != app::PairingState::paired) { state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again before opening apps."; - logging::logger.warn("apps", state.statusMessage); + logging::warn("apps", state.statusMessage); return; } if (app::begin_selected_host_app_browse(state, update.appsBrowseShowHidden)) { - logging::logger.info("apps", "Authorized host browse for " + host->displayName); + logging::info("apps", "Authorized host browse for " + host->displayName); return; } - logging::logger.warn("apps", state.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); + logging::warn("apps", state.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); } int run_host_probe_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature @@ -2691,7 +2691,7 @@ namespace { return; } - logging::logger.debug( + 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" ); @@ -2720,7 +2720,7 @@ namespace { worker->resultQueue = &task->resultQueue; worker->thread = SDL_CreateThread(run_host_probe_task, "probe-saved-host", worker.get()); if (worker->thread == nullptr) { - logging::logger.error("hosts", "Failed to start the saved-host refresh worker for " + host.address + ": " + SDL_GetError()); + 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; } @@ -2746,7 +2746,7 @@ namespace { if (pairing_task_is_active(*task)) { retire_active_pairing_attempt(task, true); - logging::logger.info("pairing", "Discarded the previous background pairing attempt and started a fresh one"); + logging::info("pairing", "Discarded the previous background pairing attempt and started a fresh one"); } std::string reachabilityMessage; @@ -2768,7 +2768,7 @@ namespace { state.pairingDraft.generatedPin.clear(); state.pairingDraft.statusMessage = reachabilityMessage.empty() ? "The host could not be reached for pairing." : reachabilityMessage; state.statusMessage = state.pairingDraft.statusMessage; - logging::logger.warn("pairing", state.pairingDraft.statusMessage); + logging::warn("pairing", state.pairingDraft.statusMessage); return; } @@ -2793,7 +2793,7 @@ namespace { const std::string createThreadError = std::string("Failed to start the background pairing task: ") + SDL_GetError(); app::apply_pairing_result(state, update.pairingAddress, update.pairingPort, false, createThreadError); state.pairingDraft.generatedPin.clear(); - logging::logger.error("pairing", createThreadError); + logging::error("pairing", createThreadError); return; } @@ -2802,7 +2802,7 @@ namespace { state.pairingDraft.stage = app::PairingStage::in_progress; state.pairingDraft.statusMessage = "The host is reachable. Enter the code shown below on the host and keep this screen open for the result."; state.statusMessage.clear(); - logging::logger.info("pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); + logging::info("pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); } int run_app_list_task(void *context) { // NOSONAR(cpp:S5008) SDL_CreateThread requires void* callback signature @@ -2881,7 +2881,7 @@ namespace { if (success) { app::apply_app_list_result(state, address, port, std::move(apps), appListContentHash, true, message); - logging::logger.info("apps", "Fetched app list from " + address + ":" + std::to_string(serverInfo.httpPort)); + logging::info("apps", "Fetched app list from " + address + ":" + std::to_string(serverInfo.httpPort)); if (state.hostsDirty) { persist_hosts(state); } @@ -2889,7 +2889,7 @@ namespace { } app::apply_app_list_result(state, address, port, {}, 0, false, message); - logging::logger.warn("apps", message); + logging::warn("apps", message); } void start_app_list_task_if_needed(app::ClientState &state, AppListTaskState *task, Uint32 now) { @@ -2921,7 +2921,7 @@ namespace { 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::logger.error("apps", errorMessage); + logging::error("apps", errorMessage); if (state.activeHostLoaded) { state.activeHost.appListState = app::HostAppListState::failed; state.activeHost.appListStatusMessage = errorMessage; @@ -2998,10 +2998,10 @@ namespace { } if (!cachedAppIds.empty()) { - logging::logger.info("apps", "Cached cover art for " + std::to_string(cachedAppIds.size()) + " app(s)"); + logging::info("apps", "Cached cover art for " + std::to_string(cachedAppIds.size()) + " app(s)"); } if (failureCount > 0U) { - logging::logger.warn("apps", std::to_string(failureCount) + " app artwork request(s) fell back to placeholders"); + logging::warn("apps", std::to_string(failureCount) + " app artwork request(s) fell back to placeholders"); } } @@ -3028,7 +3028,7 @@ namespace { task->apps = host->apps; task->thread = SDL_CreateThread(run_app_art_task, "fetch-app-art", task); if (task->thread == nullptr) { - logging::logger.error("apps", std::string("Failed to start the cover-art fetch task: ") + SDL_GetError()); + logging::error("apps", std::string("Failed to start the cover-art fetch task: ") + SDL_GetError()); reset_app_art_task(task); } } @@ -3043,7 +3043,7 @@ namespace { app::set_log_file_path(state, loadedLog.filePath); if (!loadedLog.errorMessage.empty()) { app::apply_log_viewer_contents(state, {loadedLog.errorMessage}, loadedLog.errorMessage); - logging::logger.warn("logging", loadedLog.errorMessage); + logging::warn("logging", loadedLog.errorMessage); return; } @@ -3056,7 +3056,7 @@ namespace { 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::logger.info("logging", statusMessage + ": " + loadedLog.filePath); + logging::info("logging", statusMessage + ": " + loadedLog.filePath); } bool draw_shell( // NOSONAR(cpp:S3776,cpp:S107) one-frame shell rendering is intentionally centralized to keep layout and failure handling consistent @@ -3588,7 +3588,6 @@ namespace ui { const VIDEO_MODE &videoMode, app::ClientState &state ) { - logging::Logger *logger = logging::global_logger(); if (window == nullptr) { return report_shell_failure("sdl", "Shell requires a valid SDL window"); } @@ -3639,7 +3638,7 @@ namespace ui { if (SDL_IsGameController(joystickIndex)) { controller = SDL_GameControllerOpen(joystickIndex); if (controller != nullptr) { - logging::logger.info("input", "Opened primary controller"); + logging::info("input", "Opened primary controller"); break; } } @@ -3678,16 +3677,14 @@ namespace ui { reset_app_list_task(&appListTask); reset_app_art_task(&appArtTask); reset_host_probe_task(&hostProbeTask); - if (logger != nullptr) { - logger->set_minimum_level(logging::LogLevel::trace); - logger->set_file_minimum_level(state.loggingLevel); - logger->set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); - } - logging::logger.info("app", "Entered interactive shell"); + logging::set_minimum_level(logging::LogLevel::trace); + logging::set_file_minimum_level(state.loggingLevel); + logging::set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); + logging::info("app", "Entered interactive shell"); bool keypadRedrawRequested = true; const auto draw_current_shell = [&]() { - const std::vector retainedEntries = logger == nullptr ? std::vector {} : logger->snapshot(logging::LogLevel::info); + const std::vector retainedEntries = logging::snapshot(logging::LogLevel::info); if (const auto viewModel = build_shell_view_model(state, retainedEntries); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache, &keypadModalLayoutCache)) { keypadRedrawRequested = false; return true; @@ -3708,10 +3705,8 @@ namespace ui { const app::ScreenId previousScreen = state.activeScreen; const app::AppUpdate update = app::handle_command(state, command); - if (logger != nullptr) { - logger->set_file_minimum_level(state.loggingLevel); - logger->set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); - } + logging::set_file_minimum_level(state.loggingLevel); + logging::set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); log_app_update(state, update); show_log_file_if_requested(state, update); cancel_pairing_if_requested(state, update, &pairingTask); @@ -3756,7 +3751,7 @@ namespace ui { if (SDL_GetTicks() - comboStartTick >= EXIT_COMBO_HOLD_MILLISECONDS) { controllerExitComboTriggered = true; state.shouldExit = true; - logging::logger.info("app", "Exit requested from held Start+Back on the hosts screen"); + logging::info("app", "Exit requested from held Start+Back on the hosts screen"); } } @@ -3794,7 +3789,7 @@ namespace ui { if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing controller = SDL_GameControllerOpen(event.cdevice.which); if (controller != nullptr) { - logging::logger.info("input", "Controller connected"); + logging::info("input", "Controller connected"); } } break; @@ -3812,7 +3807,7 @@ namespace ui { controllerExitComboTriggered = false; controllerNavigationNeutralRequired = false; reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); - logging::logger.warn("input", "Controller disconnected"); + logging::warn("input", "Controller disconnected"); } break; case SDL_CONTROLLERBUTTONDOWN: diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp index 812f2ec..7b9af87 100644 --- a/tests/unit/logging/logger_test.cpp +++ b/tests/unit/logging/logger_test.cpp @@ -1,5 +1,4 @@ // test header include -#include "src/logging/global_logger.h" #include "src/logging/logger.h" // standard includes @@ -97,14 +96,14 @@ namespace { EXPECT_EQ(logging::format_entry(localLogger.entries().front()), "[INFO] [tests/unit/logging/logger_test.cpp:" + std::to_string(expectedLine) + "] ui: opened"); } - TEST(LoggerTest, GlobalLoggerProxyCapturesTheCallsiteLocation) { + 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::logger.info("ui", "opened globally")); + EXPECT_TRUE(logging::info("ui", "opened globally")); ASSERT_EQ(localLogger.entries().size(), 1U); EXPECT_EQ(localLogger.entries().front().sourceLocation.line, expectedLine); @@ -113,10 +112,10 @@ namespace { logging::set_global_logger(nullptr); } - TEST(LoggerTest, GlobalLoggerProxyReturnsFalseWhenNoLoggerIsRegistered) { + TEST(LoggerTest, NamespaceLevelGlobalLoggingReturnsFalseWhenNoLoggerIsRegistered) { logging::set_global_logger(nullptr); - EXPECT_FALSE(logging::logger.info("ui", "ignored")); + EXPECT_FALSE(logging::info("ui", "ignored")); } TEST(LoggerTest, DispatchesTheDedicatedRuntimeFileSinkIndependentlyFromTheRetainedBufferLevel) { diff --git a/tests/unit/logging/startup_debug_test.cpp b/tests/unit/logging/startup_debug_test.cpp index f94a080..ad4b8a8 100644 --- a/tests/unit/logging/startup_debug_test.cpp +++ b/tests/unit/logging/startup_debug_test.cpp @@ -1,5 +1,5 @@ // test header include -#include "src/logging/startup_debug.h" +#include "src/logging/logger.h" // lib includes #include @@ -7,12 +7,13 @@ namespace { TEST(StartupDebugTest, FormatsStructuredStartupConsoleLines) { - EXPECT_STREQ(logging::startup_status_block(logging::StartupConsoleStyle::pending), "[START ]"); - EXPECT_STREQ(logging::startup_status_block(logging::StartupConsoleStyle::trace), "[TRACE ]"); - EXPECT_STREQ(logging::startup_status_block(logging::StartupConsoleStyle::error), "[ERROR ]"); EXPECT_EQ( - logging::format_startup_console_line(logging::StartupConsoleStyle::warning, "network", "Runtime networking is unavailable"), - "[ WARN ] network: Runtime networking is unavailable" + 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)" ); } From 2671e26f05a96901697587b7c8b63d526302f2e9 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 12 Apr 2026 11:44:03 -0400 Subject: [PATCH 19/35] Refactor modal and app-list handling into helpers Break up large command and app-list functions into focused helper routines. Extracted modal helpers (close_modal_and_mark_closed, per-modal handlers, handle_modal_activation) and host/app action helpers (request_host_pairing, delete_selected_host, collect_deleted_host_cover_art_keys). Reworked apply_app_list_result into smaller pieces (find_app_list_result_host, merge_host_app_records, apply_unpaired_app_list_failure, apply_cached_app_list_failure, restore_selected_app_after_refresh) and tightened null/update checks. Also modularized UI command handling (overlay, add-host keypad, settings, hosts, apps) to improve readability and maintainability with no intended behavioral changes. --- src/app/client_state.cpp | 1580 +++++++++++++++++++++------------- src/network/host_pairing.cpp | 1029 +++++++++++++--------- src/ui/shell_screen.cpp | 509 ++++++----- src/ui/shell_view.cpp | 498 ++++++----- 4 files changed, 2190 insertions(+), 1426 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index f41a00b..a7a74ad 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -901,207 +901,302 @@ namespace { return logging::LogLevel::none; } - bool handle_modal_command(app::ClientState &state, input::UiCommand command, app::AppUpdate *update) { // NOSONAR(cpp:S3776) modal command routing stays centralized for predictable UI behavior - if (!state.modal.active()) { + /** + * @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->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->screenChanged = true; + update->pairingRequested = true; + update->pairingAddress = state.pairingDraft.targetAddress; + update->pairingPort = state.pairingDraft.targetPort; + update->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->deletedHostCoverArtCacheKeys.begin(), update->deletedHostCoverArtCacheKeys.end(), appRecord.boxArtCacheKey) != update->deletedHostCoverArtCacheKeys.end()) { + continue; + } + update->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.selectedHostIndex >= state.hosts.size()) { + return; + } + + const app::HostRecord deletedHost = state.hosts[state.selectedHostIndex]; + remember_deleted_host_pairing(state, deletedHost); + update->hostDeleteCleanupRequested = true; + update->deletedHostAddress = deletedHost.address; + update->deletedHostPort = deletedHost.port; + update->deletedHostWasPaired = deletedHost.pairingState == app::PairingState::paired; + collect_deleted_host_cover_art_keys(deletedHost, update); + state.hosts.erase(state.hosts.begin() + static_cast(state.selectedHostIndex)); + state.hostsDirty = true; + update->hostsChanged = true; + clamp_selected_host_index(state); + close_modal_and_mark_closed(state, update); + state.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; } - if (state.modal.id == app::ModalId::log_viewer) { - switch (command) { - case input::UiCommand::back: - case input::UiCommand::activate: - case input::UiCommand::confirm: - close_modal(state); - update->modalClosed = true; - return true; - case input::UiCommand::delete_character: - case input::UiCommand::open_context_menu: - cycle_log_viewer_placement(state); - state.settingsDirty = true; + 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.settingsDirty = true; + if (update != nullptr) { update->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; + 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; } - if (command == input::UiCommand::back) { - close_modal(state); - update->modalClosed = 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.statusMessage = "Cancelled the pending reset action"; + return true; + } + if (update == nullptr) { + return true; + } + if (action == app::ConfirmationAction::delete_saved_file) { + update->savedFileDeleteRequested = true; + update->savedFileDeletePath = targetPath; return true; } + if (action == app::ConfirmationAction::factory_reset) { + update->factoryResetRequested = true; + } + return true; + } - if (command == input::UiCommand::move_up || command == input::UiCommand::move_left) { - move_modal_selection(state, -1); + /** + * @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; } - if (command == input::UiCommand::move_down || command == input::UiCommand::move_right) { - move_modal_selection(state, 1); + + switch (state.modal.selectedActionIndex % 4U) { + case 0: + close_modal_and_mark_closed(state, update); + if (host->pairingState == app::PairingState::paired) { + if (update != nullptr) { + update->appsBrowseRequested = true; + update->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->connectionTestRequested = true; + update->connectionTestAddress = host->address; + update->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); + const app::HostAppRecord *selectedApp = app::selected_app(state); + if (host == nullptr || selectedApp == nullptr) { + close_modal_and_mark_closed(state, update); return true; } - if (command != input::UiCommand::activate && command != input::UiCommand::confirm) { + app::HostRecord *mutableHost = state.activeHostLoaded ? &state.activeHost : nullptr; + if (mutableHost == nullptr) { + close_modal_and_mark_closed(state, update); + return true; + } + + const std::vector indices = visible_app_indices(*mutableHost, state.showHiddenApps); + if (indices.empty()) { + close_modal_and_mark_closed(state, update); return true; } + app::HostAppRecord &appRecord = mutableHost->apps[indices[state.selectedAppIndex]]; + switch (state.modal.selectedActionIndex % 3U) { + case 0: + appRecord.hidden = !appRecord.hidden; + state.hostsDirty = true; + close_modal_and_mark_closed(state, update); + if (update != nullptr) { + update->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.hostsDirty = true; + close_modal_and_mark_closed(state, update); + if (update != nullptr) { + update->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(state); - update->modalClosed = true; + close_modal_and_mark_closed(state, update); return true; case app::ModalId::log_viewer: return true; case app::ModalId::confirmation: - { - const bool confirmed = state.modal.selectedActionIndex % 2U == 0U; - const app::ConfirmationAction action = state.confirmation.action; - const std::string targetPath = state.confirmation.targetPath; - close_modal(state); - update->modalClosed = true; - if (!confirmed) { - state.statusMessage = "Cancelled the pending reset action"; - return true; - } - if (action == app::ConfirmationAction::delete_saved_file) { - update->savedFileDeleteRequested = true; - update->savedFileDeletePath = targetPath; - return true; - } - if (action == app::ConfirmationAction::factory_reset) { - update->factoryResetRequested = true; - return true; - } - return true; - } + return handle_confirmation_modal_activation(state, update); case app::ModalId::host_actions: - { - const app::HostRecord *host = app::selected_host(state); - if (host == nullptr) { - close_modal(state); - update->modalClosed = true; - return true; - } - - switch (state.modal.selectedActionIndex % 4U) { - case 0: - close_modal(state); - update->modalClosed = true; - if (host->pairingState == app::PairingState::paired) { - update->appsBrowseRequested = true; - update->appsBrowseShowHidden = true; - } else { - if (enter_pair_host_screen(state, host->address, host->port)) { // NOSONAR(cpp:S134) host action flow is intentionally kept inline with its UI side effects - update->screenChanged = true; - update->pairingRequested = true; - update->pairingAddress = state.pairingDraft.targetAddress; - update->pairingPort = state.pairingDraft.targetPort; - update->pairingPin = state.pairingDraft.generatedPin; - } - } - return true; - case 1: - close_modal(state); - update->modalClosed = true; - update->connectionTestRequested = true; - update->connectionTestAddress = host->address; - update->connectionTestPort = app::effective_host_port(host->port); - return true; - case 2: - if (state.selectedHostIndex < state.hosts.size()) { - const app::HostRecord deletedHost = state.hosts[state.selectedHostIndex]; - remember_deleted_host_pairing(state, deletedHost); - update->hostDeleteCleanupRequested = true; - update->deletedHostAddress = deletedHost.address; - update->deletedHostPort = deletedHost.port; - update->deletedHostWasPaired = deletedHost.pairingState == app::PairingState::paired; - for (const app::HostAppRecord &appRecord : deletedHost.apps) { - if (!appRecord.boxArtCacheKey.empty() && std::find(update->deletedHostCoverArtCacheKeys.begin(), update->deletedHostCoverArtCacheKeys.end(), appRecord.boxArtCacheKey) == update->deletedHostCoverArtCacheKeys.end()) { - update->deletedHostCoverArtCacheKeys.push_back(appRecord.boxArtCacheKey); - } - } - state.hosts.erase(state.hosts.begin() + static_cast(state.selectedHostIndex)); - state.hostsDirty = true; - update->hostsChanged = true; - clamp_selected_host_index(state); - close_modal(state); - update->modalClosed = true; - state.statusMessage = "Deleted saved host"; - } - return true; - case 3: - open_modal(state, app::ModalId::host_details); - return true; - default: - return true; - } - return true; - } + return handle_host_actions_modal_activation(state, update); case app::ModalId::app_actions: - { - const app::HostRecord *host = app::apps_host(state); - if (const app::HostAppRecord *selectedApp = app::selected_app(state); host == nullptr || selectedApp == nullptr) { - close_modal(state); - update->modalClosed = true; - return true; - } - - app::HostRecord *mutableHost = state.activeHostLoaded ? &state.activeHost : nullptr; - if (mutableHost == nullptr) { - close_modal(state); - update->modalClosed = true; - return true; - } - const std::vector indices = visible_app_indices(*mutableHost, state.showHiddenApps); - if (indices.empty()) { - close_modal(state); - update->modalClosed = true; - return true; - } - app::HostAppRecord &appRecord = mutableHost->apps[indices[state.selectedAppIndex]]; - - switch (state.modal.selectedActionIndex % 3U) { - case 0: - appRecord.hidden = !appRecord.hidden; - state.hostsDirty = true; - close_modal(state); - update->modalClosed = true; - update->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.hostsDirty = true; - close_modal(state); - update->modalClosed = true; - update->hostsChanged = true; - return true; - default: - return true; - } - return true; - } + return handle_app_actions_modal_activation(state, update); case app::ModalId::none: return false; } @@ -1109,6 +1204,36 @@ namespace { 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 { @@ -1266,117 +1391,210 @@ namespace app { return false; } - void apply_app_list_result( // NOSONAR(cpp:S3776) app-list merge logic is intentionally centralized to preserve host selection state - ClientState &state, - const std::string &address, - uint16_t port, - std::vector apps, - uint64_t appListContentHash, - bool success, - std::string message - ) { + /** + * @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) { HostRecord *host = find_host_by_endpoint(state.hosts, address, port); - if (host == nullptr && state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { - host = &state.activeHost; + if (host != nullptr) { + return host; } - if (host == nullptr) { - return; + if (state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { + return &state.activeHost; } + return nullptr; + } - bool persistedAppCacheChanged = false; + /** + * @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.activeScreen == ScreenId::apps && state.activeHostLoaded && host == &state.activeHost; + } - const bool hostIsActiveAppsScreenSelection = state.activeScreen == ScreenId::apps && state.activeHostLoaded && host == &state.activeHost; + /** + * @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); - const int selectedAppId = currentSelection == nullptr ? 0 : currentSelection->id; + return currentSelection == nullptr ? 0 : currentSelection->id; + } - if (!success) { - if (const bool hostIsUnpaired = network::error_indicates_unpaired_client(message); hostIsUnpaired) { - host->pairingState = PairingState::not_paired; - persistedAppCacheChanged = !host->apps.empty() || host->appListContentHash != 0U; - host->apps.clear(); - host->appListContentHash = 0; - host->lastAppListRefreshTick = 0U; - host->appListState = HostAppListState::failed; - host->appListStatusMessage = message; - if (hostIsActiveAppsScreenSelection) { - state.statusMessage = std::move(message); - } - refresh_running_flags(host); - clamp_selected_app_index(state); - 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.statusMessage = std::move(message); - } - refresh_running_flags(host); - clamp_selected_app_index(state); + /** + * @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; } - if (const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; appListChanged) { - 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()) { // NOSONAR(cpp:S134) merge path keeps persisted app metadata updates together - appRecord.boxArtCacheKey = savedApp->boxArtCacheKey; - } - } - appRecord.running = static_cast(appRecord.id) == host->runningGameId; - mergedApps.push_back(std::move(appRecord)); - } - host->apps = std::move(mergedApps); - persistedAppCacheChanged = true; - } else { - refresh_running_flags(host); - } - - persistedAppCacheChanged = persistedAppCacheChanged || host->appListContentHash != appListContentHash; - host->appListContentHash = appListContentHash; - host->appListState = HostAppListState::ready; + 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.hostsDirty = state.hostsDirty || persistedAppCacheChanged; if (hostIsActiveAppsScreenSelection) { - state.statusMessage.clear(); - } - - if (selectedAppId != 0) { - const std::size_t restoredIndex = visible_app_index_for_id(*host, state.showHiddenApps, selectedAppId); - if (restoredIndex != static_cast(-1)) { - state.selectedAppIndex = restoredIndex; - } + state.statusMessage = std::move(message); } + refresh_running_flags(host); clamp_selected_app_index(state); } - void mark_cover_art_cached(ClientState &state, const std::string &address, uint16_t port, int appId) { - HostRecord *host = find_host_by_endpoint(state.hosts, address, port); - if (host == nullptr && state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { - host = &state.activeHost; - } + /** + * @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; } - for (HostAppRecord &appRecord : host->apps) { - if (appRecord.id == appId) { - if (appRecord.boxArtCached) { - 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.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.boxArtCached = true; - state.hostsDirty = true; - return; } + appRecord.running = static_cast(appRecord.id) == host.runningGameId; + mergedApps.push_back(std::move(appRecord)); } + return mergedApps; } - void set_log_file_path(ClientState &state, std::string logFilePath) { - state.logFilePath = std::move(logFilePath); + /** + * @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.showHiddenApps, selectedAppId); + if (restoredIndex != static_cast(-1)) { + state.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; + const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; + if (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.hostsDirty = state.hostsDirty || persistedAppCacheChanged; + if (hostIsActiveAppsScreenSelection) { + state.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, address, port); + if (host == nullptr && state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { + host = &state.activeHost; + } + if (host == nullptr) { + return; + } + + for (HostAppRecord &appRecord : host->apps) { + if (appRecord.id == appId) { + if (appRecord.boxArtCached) { + return; + } + appRecord.boxArtCached = true; + state.hostsDirty = true; + return; + } + } + } + + void set_log_file_path(ClientState &state, std::string logFilePath) { + state.logFilePath = std::move(logFilePath); } void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage) { @@ -1421,417 +1639,501 @@ namespace app { return &state.activeHost; } - AppUpdate handle_command(ClientState &state, input::UiCommand command) { // NOSONAR(cpp:S3776) top-level UI command routing intentionally remains in one place - AppUpdate update {}; - + /** + * @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.overlayVisible = !state.overlayVisible; if (!state.overlayVisible) { state.overlayScrollOffset = 0U; } - update.overlayChanged = true; - update.overlayVisibilityChanged = true; - return update; + if (update != nullptr) { + update->overlayChanged = true; + update->overlayVisibilityChanged = true; + } + return true; } - if (state.overlayVisible) { - if (command == input::UiCommand::previous_page) { + if (!state.overlayVisible || update == nullptr) { + return false; + } + + switch (command) { + case input::UiCommand::previous_page: state.overlayScrollOffset += OVERLAY_SCROLL_STEP; - update.overlayChanged = true; - return update; - } - if (command == input::UiCommand::next_page) { + update->overlayChanged = true; + return true; + case input::UiCommand::next_page: state.overlayScrollOffset = state.overlayScrollOffset > OVERLAY_SCROLL_STEP ? state.overlayScrollOffset - OVERLAY_SCROLL_STEP : 0U; - update.overlayChanged = true; - return update; - } - if (command == input::UiCommand::fast_previous_page) { + update->overlayChanged = true; + return true; + case input::UiCommand::fast_previous_page: state.overlayScrollOffset += OVERLAY_SCROLL_STEP * 3U; - update.overlayChanged = true; - return update; - } - if (command == input::UiCommand::fast_next_page) { - const std::size_t fastStep = OVERLAY_SCROLL_STEP * 3U; - state.overlayScrollOffset = state.overlayScrollOffset > fastStep ? state.overlayScrollOffset - fastStep : 0U; - update.overlayChanged = true; - return update; - } + update->overlayChanged = true; + return true; + case input::UiCommand::fast_next_page: + { + const std::size_t fastStep = OVERLAY_SCROLL_STEP * 3U; + state.overlayScrollOffset = state.overlayScrollOffset > fastStep ? state.overlayScrollOffset - fastStep : 0U; + update->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; } - if (state.activeScreen == ScreenId::add_host && state.addHostDraft.keypad.visible) { - switch (command) { - case input::UiCommand::move_up: - move_add_host_keypad_selection(state, -1, 0); - return update; - case input::UiCommand::move_down: - move_add_host_keypad_selection(state, 1, 0); - return update; - case input::UiCommand::move_left: - move_add_host_keypad_selection(state, 0, -1); - return update; - case input::UiCommand::move_right: - move_add_host_keypad_selection(state, 0, 1); - return update; - case input::UiCommand::back: - cancel_add_host_keypad(state); - return update; - case input::UiCommand::delete_character: - backspace_active_add_host_field(state); - return update; - case input::UiCommand::confirm: - accept_add_host_keypad(state); - return update; - case input::UiCommand::activate: - { - char character = '\0'; - if (selected_add_host_keypad_character(state, &character)) { - append_to_active_add_host_field(state, character); - } - return update; - } - 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 update; - } - } + return false; + } - if (handle_modal_command(state, command, &update)) { - return update; + /** + * @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.activeScreen != ScreenId::add_host || !state.addHostDraft.keypad.visible) { + return false; } - if (command == input::UiCommand::delete_character && !state.statusMessage.empty()) { - state.statusMessage.clear(); - return update; + 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: + { + char character = '\0'; + if (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; } - if (state.activeScreen == ScreenId::settings) { - if (command == input::UiCommand::move_left && state.settingsFocusArea == SettingsFocusArea::options) { - state.settingsFocusArea = SettingsFocusArea::categories; - return update; - } - if (command == input::UiCommand::move_right && state.settingsFocusArea == SettingsFocusArea::categories && !state.detailMenu.items().empty()) { - state.settingsFocusArea = SettingsFocusArea::options; - return update; - } + return true; + } - if (state.settingsFocusArea == SettingsFocusArea::categories) { - const ui::MenuUpdate categoryUpdate = state.menu.handle_command(command); - if (categoryUpdate.backRequested) { - set_screen(state, ScreenId::hosts); - update.screenChanged = true; - return update; - } - if (categoryUpdate.selectionChanged) { - sync_selected_settings_category_from_menu(state); - rebuild_settings_detail_menu(state, {}, false); - return update; - } - if (!categoryUpdate.activationRequested) { - return update; - } + /** + * @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.activatedItemId = categoryUpdate.activatedItemId; - sync_selected_settings_category_from_menu(state); - rebuild_menu(state, categoryUpdate.activatedItemId); - if (!state.detailMenu.items().empty()) { - state.settingsFocusArea = SettingsFocusArea::options; + update->activatedItemId = detailUpdate.activatedItemId; + if (detailUpdate.activatedItemId == "view-log-file") { + update->logViewRequested = true; + return; + } + if (detailUpdate.activatedItemId == "cycle-log-level") { + state.loggingLevel = next_logging_level(state.loggingLevel); + state.settingsDirty = true; + update->settingsChanged = true; + state.statusMessage = std::string("Logging level set to ") + logging::to_string(state.loggingLevel); + rebuild_menu(state, "cycle-log-level"); + return; + } + if (detailUpdate.activatedItemId == "cycle-xemu-console-log-level") { + state.xemuConsoleLoggingLevel = next_logging_level(state.xemuConsoleLoggingLevel); + state.settingsDirty = true; + update->settingsChanged = true; + state.statusMessage = std::string("xemu console logging level set to ") + logging::to_string(state.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.", } - return update; - } + ); + update->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->modalOpened = true; + return; + } + state.statusMessage = detailUpdate.activatedItemId + " is not implemented yet"; + } - const ui::MenuUpdate detailUpdate = state.detailMenu.handle_command(command); - if (detailUpdate.backRequested) { - state.settingsFocusArea = SettingsFocusArea::categories; - return update; - } - if (!detailUpdate.activationRequested) { - return update; - } + /** + * @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.activeScreen != ScreenId::settings || update == nullptr) { + return false; + } - update.activatedItemId = detailUpdate.activatedItemId; - if (detailUpdate.activatedItemId == "view-log-file") { - update.logViewRequested = true; - return update; - } - if (detailUpdate.activatedItemId == "cycle-log-level") { - state.loggingLevel = next_logging_level(state.loggingLevel); - state.settingsDirty = true; - update.settingsChanged = true; - state.statusMessage = std::string("Logging level set to ") + logging::to_string(state.loggingLevel); - rebuild_menu(state, "cycle-log-level"); - return update; + if (command == input::UiCommand::move_left && state.settingsFocusArea == SettingsFocusArea::options) { + state.settingsFocusArea = SettingsFocusArea::categories; + return true; + } + if (command == input::UiCommand::move_right && state.settingsFocusArea == SettingsFocusArea::categories && !state.detailMenu.items().empty()) { + state.settingsFocusArea = SettingsFocusArea::options; + return true; + } + + if (state.settingsFocusArea == SettingsFocusArea::categories) { + const ui::MenuUpdate categoryUpdate = state.menu.handle_command(command); + if (categoryUpdate.backRequested) { + set_screen(state, ScreenId::hosts); + update->screenChanged = true; + return true; } - if (detailUpdate.activatedItemId == "cycle-xemu-console-log-level") { - state.xemuConsoleLoggingLevel = next_logging_level(state.xemuConsoleLoggingLevel); - state.settingsDirty = true; - update.settingsChanged = true; - state.statusMessage = std::string("xemu console logging level set to ") + logging::to_string(state.xemuConsoleLoggingLevel); - rebuild_menu(state, "cycle-xemu-console-log-level"); - return update; + if (categoryUpdate.selectionChanged) { + sync_selected_settings_category_from_menu(state); + rebuild_settings_detail_menu(state, {}, false); + return true; } - 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.modalOpened = true; - return update; + if (!categoryUpdate.activationRequested) { + return true; } - 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.modalOpened = true; - return update; + + update->activatedItemId = categoryUpdate.activatedItemId; + sync_selected_settings_category_from_menu(state); + rebuild_menu(state, categoryUpdate.activatedItemId); + if (!state.detailMenu.items().empty()) { + state.settingsFocusArea = SettingsFocusArea::options; } - state.statusMessage = detailUpdate.activatedItemId + " is not implemented yet"; - return update; + return true; } - if (state.activeScreen == ScreenId::hosts) { - switch (command) { - case input::UiCommand::move_left: - if (state.hostsFocusArea == HostsFocusArea::toolbar) { - move_toolbar_selection(state, -1); - } else { - move_host_grid_selection(state, 0, -1); - } - return update; - case input::UiCommand::move_right: - if (state.hostsFocusArea == HostsFocusArea::toolbar) { - move_toolbar_selection(state, 1); - } else { - move_host_grid_selection(state, 0, 1); - } - return update; - case input::UiCommand::move_down: - if (state.hostsFocusArea == HostsFocusArea::toolbar) { - if (!state.hosts.empty()) { // NOSONAR(cpp:S134) hosts-screen focus transition stays inline with navigation handling - state.hostsFocusArea = HostsFocusArea::grid; - } - } else { - move_host_grid_selection(state, 1, 0); - } - return update; - case input::UiCommand::move_up: - if (state.hostsFocusArea == HostsFocusArea::grid) { - move_host_grid_selection(state, -1, 0); - } - return update; - case input::UiCommand::open_context_menu: - if (state.hostsFocusArea == HostsFocusArea::grid && selected_host(state) != nullptr) { - open_modal(state, ModalId::host_actions); - update.modalOpened = true; - } - return update; - case input::UiCommand::activate: - case input::UiCommand::confirm: - if (state.hostsFocusArea == HostsFocusArea::toolbar) { - if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 0U) { // NOSONAR(cpp:S134) toolbar actions stay explicit for controller navigation parity - set_screen(state, ScreenId::settings, settings_category_menu_id(SettingsCategory::logging)); - update.screenChanged = true; - update.activatedItemId = "settings-button"; - return update; - } - if (state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT == 1U) { // NOSONAR(cpp:S134) toolbar actions stay explicit for controller navigation parity - open_modal(state, ModalId::support); - update.modalOpened = true; - update.activatedItemId = "support-button"; - return update; - } - enter_add_host_screen(state); - update.screenChanged = true; - update.activatedItemId = "add-host-button"; - return update; - } + const ui::MenuUpdate detailUpdate = state.detailMenu.handle_command(command); + if (detailUpdate.backRequested) { + state.settingsFocusArea = SettingsFocusArea::categories; + return true; + } + if (!detailUpdate.activationRequested) { + return true; + } - if (const HostRecord *host = selected_host(state); host != nullptr) { - update.activatedItemId = "select-host"; - if (host->pairingState == PairingState::paired) { // NOSONAR(cpp:S134) host activation keeps browse-vs-pair branching with its update flags - update.appsBrowseRequested = true; - update.appsBrowseShowHidden = false; - } else { - if (enter_pair_host_screen(state, host->address, host->port)) { - update.screenChanged = true; - update.pairingRequested = true; - update.pairingAddress = state.pairingDraft.targetAddress; - update.pairingPort = state.pairingDraft.targetPort; - update.pairingPin = state.pairingDraft.generatedPin; - } - } - } - return update; - 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 update; - } + 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; } - if (state.activeScreen == ScreenId::apps) { - switch (command) { - case input::UiCommand::move_left: - move_app_grid_selection(state, 0, -1); - return update; - case input::UiCommand::move_right: - move_app_grid_selection(state, 0, 1); - return update; - case input::UiCommand::move_up: - move_app_grid_selection(state, -1, 0); - return update; - case input::UiCommand::move_down: - move_app_grid_selection(state, 1, 0); - return update; - case input::UiCommand::open_context_menu: - if (selected_app(state) != nullptr) { - open_modal(state, ModalId::app_actions); - update.modalOpened = true; - } - return update; - case input::UiCommand::activate: - case input::UiCommand::confirm: - if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) { - state.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; - update.activatedItemId = "launch-app"; - } - return update; - case input::UiCommand::back: - state.statusMessage.clear(); - set_screen(state, ScreenId::hosts); - update.screenChanged = true; - return update; - 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 update; - } + update->activatedItemId = "select-host"; + if (host->pairingState == PairingState::paired) { + update->appsBrowseRequested = true; + update->appsBrowseShowHidden = false; + return; } - const ui::MenuUpdate menuUpdate = state.menu.handle_command(command); - if (menuUpdate.overlayToggleRequested) { - state.overlayVisible = !state.overlayVisible; - update.overlayChanged = true; - update.overlayVisibilityChanged = true; - return update; + 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; } - if (menuUpdate.backRequested) { - if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { - if (state.activeScreen == ScreenId::pair_host) { - update.pairingCancelledRequested = true; - } - state.statusMessage = state.activeScreen == ScreenId::apps ? std::string {} : state.statusMessage; - set_screen(state, ScreenId::hosts); - update.screenChanged = true; - } - return update; + const std::size_t toolbarIndex = state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT; + if (toolbarIndex == 0U) { + set_screen(state, ScreenId::settings, settings_category_menu_id(SettingsCategory::logging)); + update->screenChanged = true; + update->activatedItemId = "settings-button"; + return; + } + if (toolbarIndex == 1U) { + open_modal(state, ModalId::support); + update->modalOpened = true; + update->activatedItemId = "support-button"; + return; } - if (!menuUpdate.activationRequested) { - return update; + enter_add_host_screen(state); + update->screenChanged = true; + update->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.activeScreen != ScreenId::hosts || update == nullptr) { + return false; } - update.activatedItemId = menuUpdate.activatedItemId; + switch (command) { + case input::UiCommand::move_left: + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + move_toolbar_selection(state, -1); + } else { + move_host_grid_selection(state, 0, -1); + } + return true; + case input::UiCommand::move_right: + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + move_toolbar_selection(state, 1); + } else { + move_host_grid_selection(state, 0, 1); + } + return true; + case input::UiCommand::move_down: + if (state.hostsFocusArea == HostsFocusArea::toolbar) { + if (!state.hosts.empty()) { + state.hostsFocusArea = HostsFocusArea::grid; + } + } else { + move_host_grid_selection(state, 1, 0); + } + return true; + case input::UiCommand::move_up: + if (state.hostsFocusArea == HostsFocusArea::grid) { + move_host_grid_selection(state, -1, 0); + } + return true; + case input::UiCommand::open_context_menu: + if (state.hostsFocusArea == HostsFocusArea::grid && selected_host(state) != nullptr) { + open_modal(state, ModalId::host_actions); + update->modalOpened = true; + } + return true; + case input::UiCommand::activate: + case input::UiCommand::confirm: + if (state.hostsFocusArea == 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; + } - if (state.activeScreen == ScreenId::pair_host) { - if (menuUpdate.activatedItemId == "cancel-pairing") { - update.pairingCancelledRequested = 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.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->modalOpened = true; + } + return true; + case input::UiCommand::activate: + case input::UiCommand::confirm: + if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) { + state.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; + update->activatedItemId = "launch-app"; + } + return true; + case input::UiCommand::back: + state.statusMessage.clear(); set_screen(state, ScreenId::hosts); - update.screenChanged = true; - } - return update; + update->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; } - if (state.activeScreen != ScreenId::add_host) { - return update; + 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, const std::string &validationError) { + state.addHostDraft.validationMessage = validationError; + state.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, const std::string &activatedItemId, AppUpdate *update) { + if (update == nullptr) { + return; } - if (menuUpdate.activatedItemId == "edit-address") { + if (activatedItemId == "edit-address") { open_add_host_keypad(state, AddHostField::address); - return update; + return; } - if (menuUpdate.activatedItemId == "edit-port") { + if (activatedItemId == "edit-port") { open_add_host_keypad(state, AddHostField::port); - return update; + return; } std::string normalizedAddress; uint16_t parsedPort = 0; std::string validationError; const bool draftIsValid = normalize_add_host_inputs(state, &normalizedAddress, &parsedPort, &validationError); - - if (menuUpdate.activatedItemId == "test-connection") { + if (activatedItemId == "test-connection") { if (!draftIsValid) { - state.addHostDraft.validationMessage = validationError; - state.statusMessage = validationError; - return update; + 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.statusMessage = state.addHostDraft.connectionMessage; - update.connectionTestRequested = true; - update.connectionTestAddress = normalizedAddress; - update.connectionTestPort = effective_host_port(parsedPort); - return update; + update->connectionTestRequested = true; + update->connectionTestAddress = normalizedAddress; + update->connectionTestPort = effective_host_port(parsedPort); + return; } - - if (menuUpdate.activatedItemId == "save-host") { + if (activatedItemId == "save-host") { if (!draftIsValid) { - state.addHostDraft.validationMessage = validationError; - state.statusMessage = validationError; - return update; + apply_add_host_validation_error(state, validationError); + return; } if (find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort) != nullptr) { - state.addHostDraft.validationMessage = "That host is already saved"; - state.statusMessage = state.addHostDraft.validationMessage; - return update; + apply_add_host_validation_error(state, "That host is already saved"); + return; } state.hosts.push_back(make_host_record(normalizedAddress, parsedPort)); state.selectedHostIndex = state.hosts.size() - 1U; state.hostsFocusArea = HostsFocusArea::grid; state.hostsDirty = true; - update.hostsChanged = true; + update->hostsChanged = true; state.addHostDraft.validationMessage.clear(); state.addHostDraft.connectionMessage.clear(); state.statusMessage = "Saved host " + normalizedAddress; set_screen(state, ScreenId::hosts); - update.screenChanged = true; - return update; + update->screenChanged = true; + return; } - - if (menuUpdate.activatedItemId == "start-pairing") { + if (activatedItemId == "start-pairing") { if (!draftIsValid) { - state.addHostDraft.validationMessage = validationError; - state.statusMessage = validationError; - return update; + apply_add_host_validation_error(state, validationError); + return; } const HostRecord *host = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); @@ -1839,27 +2141,93 @@ namespace app { state.hosts.push_back(make_host_record(normalizedAddress, parsedPort)); state.selectedHostIndex = state.hosts.size() - 1U; state.hostsDirty = true; - update.hostsChanged = true; + update->hostsChanged = true; host = &state.hosts.back(); } - if (enter_pair_host_screen(state, host->address, host->port)) { + 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->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.statusMessage.empty()) { + state.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.overlayVisible = !state.overlayVisible; + update.overlayChanged = true; + update.overlayVisibilityChanged = true; + return update; + } + + if (menuUpdate.backRequested) { + if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { + if (state.activeScreen == ScreenId::pair_host) { + update.pairingCancelledRequested = true; + } + state.statusMessage = state.activeScreen == ScreenId::apps ? std::string {} : state.statusMessage; + set_screen(state, ScreenId::hosts); update.screenChanged = true; - update.pairingRequested = true; - update.pairingAddress = state.pairingDraft.targetAddress; - update.pairingPort = state.pairingDraft.targetPort; - update.pairingPin = state.pairingDraft.generatedPin; } return update; } - if (menuUpdate.activatedItemId == "cancel-add-host") { - state.addHostDraft.validationMessage.clear(); - state.addHostDraft.connectionMessage.clear(); - set_screen(state, ScreenId::hosts); - update.screenChanged = true; + if (!menuUpdate.activationRequested) { + return update; + } + + update.activatedItemId = menuUpdate.activatedItemId; + + if (state.activeScreen == ScreenId::pair_host) { + if (menuUpdate.activatedItemId == "cancel-pairing") { + update.pairingCancelledRequested = true; + set_screen(state, ScreenId::hosts); + update.screenChanged = true; + } + return update; + } + + if (state.activeScreen != ScreenId::add_host) { + return update; } + handle_add_host_menu_activation(state, menuUpdate.activatedItemId, &update); return update; } diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 5f47874..71e5f6f 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -517,6 +517,36 @@ namespace { } } + 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")) { + if (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) { @@ -540,13 +570,8 @@ namespace { 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 (ascii_iequals(headerName, "Content-Length")) { - hasContentLength = try_parse_decimal_size(headerValue, &contentLength); // NOSONAR(cpp:S134) header parsing keeps validation adjacent to the matching header branch - if (!hasContentLength) { - return append_error(errorMessage, "Received an invalid Content-Length header while pairing"); - } - } else if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked")) { - isChunked = true; + if (!apply_http_message_length_header(headerName, headerValue, &hasContentLength, &contentLength, &isChunked, errorMessage)) { + return false; } } @@ -985,7 +1010,46 @@ namespace { bool try_parse_flag(std::string_view text, bool *value); bool try_parse_uint32(std::string_view text, uint32_t *value); - bool extract_xml_attribute_value(std::string_view openTag, std::string_view attributeName, std::string *value) { // NOSONAR(cpp:S3776) permissive host XML parsing stays centralized here + 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); @@ -993,34 +1057,18 @@ namespace { return false; } - if (nameIndex > 0) { - const char previousCharacter = openTag[nameIndex - 1]; - if (previousCharacter != '<' && !std::isspace(static_cast(previousCharacter))) { - cursor = nameIndex + 1; - continue; - } - } - - std::size_t separatorIndex = nameIndex + attributeName.size(); - while (separatorIndex < openTag.size() && std::isspace(static_cast(openTag[separatorIndex]))) { - ++separatorIndex; - } - if (separatorIndex >= openTag.size() || openTag[separatorIndex] != '=') { - cursor = nameIndex + 1; + if (!xml_attribute_name_has_valid_prefix(openTag, nameIndex)) { + cursor = nameIndex + 1U; continue; } - ++separatorIndex; - while (separatorIndex < openTag.size() && std::isspace(static_cast(openTag[separatorIndex]))) { - ++separatorIndex; - } - if (separatorIndex >= openTag.size() || (openTag[separatorIndex] != '"' && openTag[separatorIndex] != '\'')) { - cursor = nameIndex + 1; + 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 char quoteCharacter = openTag[separatorIndex]; - const std::size_t valueStart = separatorIndex + 1; const std::size_t valueEnd = openTag.find(quoteCharacter, valueStart); if (valueEnd == std::string_view::npos) { return false; @@ -1212,7 +1260,93 @@ namespace { return true; } - bool connect_socket(const std::string &address, uint16_t port, SocketGuard *socketGuard, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { // NOSONAR(cpp:S3776) connection setup keeps platform-specific error handling in one place + 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"); } @@ -1232,15 +1366,10 @@ namespace { } trace_pairing_phase("socket created"); - trace_pairing_phase("preparing IPv4 socket address"); sockaddr_in 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"); + if (!prepare_pairing_socket_address(address, port, &socketAddress, errorMessage)) { + return false; } - trace_pairing_phase("IPv4 socket address ready"); trace_pairing_phase("setting non-blocking connect mode"); if (!set_socket_non_blocking(socketGuard->handle, true, errorMessage)) { @@ -1252,61 +1381,12 @@ namespace { 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) + ")"); } - - 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(socketGuard->handle, &writeSet); - const int waitMilliseconds = std::min(remainingWaitMilliseconds, CONNECT_POLL_INTERVAL_MILLISECONDS); - timeval timeout { - waitMilliseconds / 1000, - (waitMilliseconds % 1000) * 1000, - }; - - selectResult = select(static_cast(socketGuard->handle) + 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(socketGuard->handle, SOL_SOCKET, SO_ERROR, reinterpret_cast(&socketError), &socketErrorLength) != 0) { -#else - if (socklen_t socketErrorLength = sizeof(socketError); getsockopt(socketGuard->handle, 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) + ")"); + if (!wait_for_socket_connect_completion(socketGuard->handle, address, port, errorMessage, cancelRequested)) { + return false; } } - trace_pairing_phase("restoring blocking mode after connect"); - if (!set_socket_non_blocking(socketGuard->handle, false, errorMessage)) { - return false; - } - - set_socket_timeouts(socketGuard->handle); - trace_pairing_phase("socket connected"); - - return true; + return finalize_connected_socket(socketGuard->handle, errorMessage); } bool recv_all_plain(SOCKET socketHandle, std::string *response, std::string *errorMessage, const std::atomic *cancelRequested = nullptr) { @@ -1509,7 +1589,103 @@ namespace { return append_error(errorMessage, std::move(combinedMessage)); } - bool http_get( // NOSONAR(cpp:S3776,cpp:S107) HTTP transport keeps TLS/plain fallback and error reporting in one place + 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, @@ -1535,79 +1711,15 @@ namespace { return false; } - const std::string request = - "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"; + const std::string request = build_http_get_request(address, port, pathAndQuery); std::string rawResponse; if (!useTls) { - trace_pairing_phase("http_get: sending plain request"); - if (!send_all_plain(socketGuard.handle, request, errorMessage, cancelRequested) || !recv_all_plain(socketGuard.handle, &rawResponse, errorMessage, cancelRequested)) { + if (!execute_plain_http_get(socketGuard.handle, request, &rawResponse, errorMessage, cancelRequested)) { return false; } } else { - 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(socketGuard.handle, 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(socketGuard.handle)) != 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) || !recv_all_ssl(ssl.get(), &rawResponse, errorMessage, cancelRequested)) { + if (!execute_tls_http_get(socketGuard.handle, request, tlsClientIdentity, expectedTlsCertificatePem, &rawResponse, errorMessage, cancelRequested)) { return false; } } @@ -1757,6 +1869,317 @@ namespace { 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"}; + } + + session->result.message = "Pairing failed during " + std::string(phase) + ": " + session->errorMessage; + 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; + } + + const network::PairingIdentity *tlsIdentity = useTls ? &session->request.identity : nullptr; + if (!http_get(session->request.address, useTls ? session->serverInfo.httpsPort : session->serverInfo.httpPort, path, useTls, tlsIdentity, 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 { @@ -2101,280 +2524,62 @@ namespace network { return append_error(errorMessage, std::move(combinedMessage)); } - HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested) { // NOSONAR(cpp:S3776) pairing protocol phases intentionally remain linear and explicit here + HostPairingResult pair_host(const HostPairingRequest &request, const std::atomic *cancelRequested) { trace_pairing_phase("pair_host entered"); - HostPairingResult result {false, false, "Pairing failed"}; - auto fail_if_cancelled = [&cancelRequested, &result]() { - if (!pairing_cancel_requested(cancelRequested)) { - return false; - } - - result = {false, false, "Pairing cancelled"}; - trace_pairing_detail(result.message); - return true; - }; - auto fail_with_phase = [&result](std::string_view phase, const std::string &message) { - result.message = "Pairing failed during " + std::string(phase) + ": " + message; - trace_pairing_detail(result.message); - return result; - }; - auto next_pairing_uuid = [&result](std::string *uuid, std::string *errorMessage) { - if (generate_uuid(uuid, errorMessage)) { - return true; - } - - result.message = errorMessage != nullptr && !errorMessage->empty() ? *errorMessage : "Failed to generate the UUID used for pairing"; - return false; - }; - - if (request.address.empty()) { - result.message = "Pairing requires a valid host address"; - return result; - } - if (request.pin.size() != 4U) { - result.message = "Pairing requires a four-digit PIN"; - return result; - } - if (!is_valid_pairing_identity(request.identity)) { - result.message = "Client pairing identity is missing or invalid"; - return result; - } - if (fail_if_cancelled()) { - return result; - } - - std::unique_ptr clientCertificate = load_certificate(request.identity.certificatePem); - std::unique_ptr clientPrivateKey = load_private_key(request.identity.privateKeyPem); - if (clientCertificate == nullptr || clientPrivateKey == nullptr) { - result.message = "Client pairing credentials could not be loaded"; - return result; + PairingSessionState session(request, cancelRequested); + if (!initialize_pairing_session(&session)) { + return session.result; } - - const uint16_t httpPort = request.httpPort == 0 ? DEFAULT_SERVERINFO_HTTP_PORT : request.httpPort; - const std::string uniqueId = request.identity.uniqueId; - const std::string deviceName = request.deviceName.empty() ? "MoonlightXboxOG" : request.deviceName; - - std::string errorMessage; - HttpResponse response {}; - std::string requestUuid; - if (!next_pairing_uuid(&requestUuid, &errorMessage)) { - return result; - } - - trace_pairing_phase("requesting /serverinfo"); - HostPairingServerInfo serverInfo {}; - if (!query_server_info_internal(request.address, httpPort, uniqueId, &response, &serverInfo, &errorMessage, cancelRequested)) { - return fail_with_phase("serverinfo", errorMessage); + if (!query_pairing_server_info(&session)) { + return fail_pairing_phase(&session, "serverinfo"); } - if (fail_if_cancelled()) { - return result; + if (pairing_session_cancelled(&session)) { + return session.result; } - - if (serverInfo.paired) { - result.success = true; - result.alreadyPaired = true; - result.message = "The host already reports this client as paired"; - return result; - } - - std::array saltBytes {}; - if (!fill_random_bytes(saltBytes.data(), saltBytes.size(), &errorMessage)) { - result.message = errorMessage; - return result; - } - if (fail_if_cancelled()) { - return result; - } - const std::string saltHex = hex_encode(saltBytes.data(), saltBytes.size()); - const std::string certHex = certificate_hex(request.identity.certificatePem); - - std::string phaseValue; - if (!next_pairing_uuid(&requestUuid, &errorMessage)) { - return 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; } - const std::string phase1Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&phrase=getservercert&salt=" + saltHex + "&clientcert=" + certHex; - trace_pairing_phase("phase 1 getservercert request"); - if (!http_get(request.address, serverInfo.httpPort, phase1Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { - return fail_with_phase("phase 1 (getservercert)", errorMessage); - } - if (fail_if_cancelled()) { - return result; - } - if (phaseValue != "1") { - result.message = "The host rejected the initial pairing request"; - return result; - } - - std::string plainCertHex; - if (!parse_pairing_tag(response, "plaincert", &plainCertHex, &errorMessage)) { - return fail_with_phase("phase 1 (getservercert)", errorMessage); - } - - std::vector plainCertBytes; - if (!hex_decode(plainCertHex, &plainCertBytes, &errorMessage)) { - return fail_with_phase("phase 1 (getservercert)", errorMessage); - } - const std::string plainCertPem(plainCertBytes.begin(), plainCertBytes.end()); - - std::vector aesKey; trace_pairing_phase("deriving AES key"); - if (!derive_aes_key(saltHex, request.pin, &aesKey, &errorMessage)) { - return fail_with_phase("phase 1 (derive AES key)", errorMessage); + if (!request_server_certificate(&session)) { + return fail_pairing_phase(&session, "phase 1 (getservercert)"); } - if (fail_if_cancelled()) { - return result; - } - - std::array clientChallengeBytes {}; - if (!fill_random_bytes(clientChallengeBytes.data(), clientChallengeBytes.size(), &errorMessage)) { - return fail_with_phase("phase 2 (client challenge random)", errorMessage); + if (pairing_session_cancelled(&session)) { + return session.result; } - std::vector encryptedClientChallenge; - if (!aes_ecb_encrypt(clientChallengeBytes.data(), clientChallengeBytes.size(), aesKey, &encryptedClientChallenge, &errorMessage)) { - return fail_with_phase("phase 2 (client challenge encrypt)", errorMessage); - } - - if (!next_pairing_uuid(&requestUuid, &errorMessage)) { - return result; - } - const std::string phase2Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&clientchallenge=" + hex_encode(encryptedClientChallenge.data(), encryptedClientChallenge.size()); - trace_pairing_phase("phase 2 clientchallenge request"); - if (!http_get(request.address, serverInfo.httpPort, phase2Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { - return fail_with_phase("phase 2 (client challenge)", errorMessage); - } - if (fail_if_cancelled()) { - return result; - } - if (phaseValue != "1") { - result.message = "The host rejected the client challenge during pairing"; - return result; - } - - std::string challengeResponseHex; - if (!parse_pairing_tag(response, "challengeresponse", &challengeResponseHex, &errorMessage)) { - return fail_with_phase("phase 2 (client challenge)", errorMessage); - } - - std::vector challengeResponseEncrypted; std::vector challengeResponsePlaintext; - if (!hex_decode(challengeResponseHex, &challengeResponseEncrypted, &errorMessage) || !aes_ecb_decrypt(challengeResponseEncrypted, aesKey, &challengeResponsePlaintext, &errorMessage)) { - return fail_with_phase("phase 2 (client challenge)", errorMessage); + if (!send_client_challenge(&session, &challengeResponsePlaintext)) { + return fail_pairing_phase(&session, "phase 2 (client challenge)"); } - - const std::size_t hashLength = pairing_hash_length(); - if (challengeResponsePlaintext.size() < hashLength + 16U) { - result.message = "The host returned an incomplete challenge response during pairing"; - return result; + if (pairing_session_cancelled(&session)) { + return session.result; } - - std::vector certificateSignature; - if (!load_certificate_signature(clientCertificate.get(), &certificateSignature, &errorMessage)) { - return fail_with_phase("phase 3 (server challenge response)", errorMessage); + if (!send_server_challenge_response(&session, challengeResponsePlaintext)) { + return fail_pairing_phase(&session, "phase 3 (server challenge response)"); } - - std::array clientSecretBytes {}; - if (!fill_random_bytes(clientSecretBytes.data(), clientSecretBytes.size(), &errorMessage)) { - return fail_with_phase("phase 3 (client secret random)", errorMessage); + if (pairing_session_cancelled(&session)) { + return session.result; } - - 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(), clientSecretBytes.begin(), clientSecretBytes.end()); - - std::vector clientHash; - if (!compute_digest(clientHashSource.data(), clientHashSource.size(), &clientHash, &errorMessage)) { - return fail_with_phase("phase 3 (server challenge response)", errorMessage); - } - - std::vector encryptedClientHash; - if (!aes_ecb_encrypt(clientHash.data(), clientHash.size(), aesKey, &encryptedClientHash, &errorMessage)) { - return fail_with_phase("phase 3 (server challenge response)", errorMessage); + if (!send_client_pairing_secret(&session)) { + return fail_pairing_phase(&session, "phase 4 (client pairing secret)"); } - - if (!next_pairing_uuid(&requestUuid, &errorMessage)) { - return result; - } - const std::string phase3Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&serverchallengeresp=" + hex_encode(encryptedClientHash.data(), encryptedClientHash.size()); - trace_pairing_phase("phase 3 serverchallengeresp request"); - if (!http_get(request.address, serverInfo.httpPort, phase3Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { - return fail_with_phase("phase 3 (server challenge response)", errorMessage); - } - if (fail_if_cancelled()) { - return result; - } - if (phaseValue != "1") { - result.message = "The host rejected the server challenge response during pairing"; - return result; - } - - std::string pairingSecretHex; - if (!parse_pairing_tag(response, "pairingsecret", &pairingSecretHex, &errorMessage)) { - return fail_with_phase("phase 3 (server challenge response)", errorMessage); - } - - std::vector pairingSecretBytes; - if (!hex_decode(pairingSecretHex, &pairingSecretBytes, &errorMessage) || pairingSecretBytes.size() <= 16U) { - return fail_with_phase("phase 4 (pairing secret)", "The host returned an invalid pairing secret"); - } - - std::unique_ptr plainCertificate = load_certificate(plainCertPem); - if (plainCertificate == nullptr) { - return fail_with_phase("phase 4 (pairing secret)", "The host returned an invalid server certificate during pairing"); - } - - std::vector serverSecret(pairingSecretBytes.begin(), pairingSecretBytes.begin() + 16); - if (std::vector serverSignature(pairingSecretBytes.begin() + 16, pairingSecretBytes.end()); !verify_sha256_signature(serverSecret, serverSignature, plainCertificate.get(), &errorMessage)) { - return fail_with_phase("phase 4 (pairing secret)", errorMessage); - } - - std::vector clientSecretVector(clientSecretBytes.begin(), clientSecretBytes.end()); - std::vector clientPairingSignature; - if (!sign_sha256(clientSecretVector, clientPrivateKey.get(), &clientPairingSignature, &errorMessage)) { - return fail_with_phase("phase 4 (client pairing secret)", errorMessage); - } - - std::vector clientPairingSecret; - clientPairingSecret.insert(clientPairingSecret.end(), clientSecretBytes.begin(), clientSecretBytes.end()); - clientPairingSecret.insert(clientPairingSecret.end(), clientPairingSignature.begin(), clientPairingSignature.end()); - - if (!next_pairing_uuid(&requestUuid, &errorMessage)) { - return result; - } - const std::string phase4Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&clientpairingsecret=" + hex_encode(clientPairingSecret.data(), clientPairingSecret.size()); - trace_pairing_phase("phase 4 clientpairingsecret request"); - if (!http_get(request.address, serverInfo.httpPort, phase4Path, false, nullptr, {}, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { - return fail_with_phase("phase 4 (client pairing secret)", errorMessage); - } - if (fail_if_cancelled()) { - return result; - } - if (phaseValue != "1") { - result.message = "The host rejected the client pairing secret"; - return result; - } - - if (!next_pairing_uuid(&requestUuid, &errorMessage)) { - return result; - } - const std::string phase5Path = "/pair?uniqueid=" + uniqueId + "&uuid=" + requestUuid + "&devicename=" + deviceName + "&updateState=1&phrase=pairchallenge"; - trace_pairing_phase("phase 5 pairchallenge request"); - if (!http_get(request.address, serverInfo.httpsPort, phase5Path, true, &request.identity, plainCertPem, &response, &errorMessage, cancelRequested) || !parse_pairing_tag(response, "paired", &phaseValue, &errorMessage)) { - return fail_with_phase("phase 5 (pairchallenge)", errorMessage); + if (pairing_session_cancelled(&session)) { + return session.result; } - if (fail_if_cancelled()) { - return result; + if (!send_pair_challenge(&session)) { + return fail_pairing_phase(&session, "phase 5 (pairchallenge)"); } - if (phaseValue != "1") { - result.message = "The host rejected the final encrypted pairing challenge"; - return result; + if (pairing_session_cancelled(&session)) { + return session.result; } - result.success = true; - result.message = "Paired successfully with " + request.address; + session.result.success = true; + session.result.message = "Paired successfully with " + request.address; trace_pairing_phase("pair_host succeeded"); - return result; + return session.result; } } // namespace network diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index d607792..4152864 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -1076,6 +1076,65 @@ namespace { 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.logViewerLines.empty()) { + layout.visibleLines.push_back(nullptr); + return layout; + } + + int usedHeight = 0; + std::size_t endIndex = viewModel.logViewerLines.size() > clampedOffset ? viewModel.logViewerLines.size() - clampedOffset : 0U; + layout.firstVisibleIndex = endIndex; + while (endIndex > 0U) { + const std::string renderedLine = truncate_text_for_render(viewModel.logViewerLines[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.logViewerLines[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.logViewerScrollOffset > 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 @@ -1095,7 +1154,7 @@ namespace { fill_rect(renderer, thumbRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xD0); } - bool render_log_viewer_modal( // NOSONAR(cpp:S3776,cpp:S107) modal rendering stays centralized to keep layout behavior consistent + bool render_log_viewer_modal( // NOSONAR(cpp:S107) modal rendering keeps the layout inputs explicit SDL_Renderer *renderer, TTF_Font *bodyFont, TTF_Font *smallFont, @@ -1144,44 +1203,15 @@ namespace { }; fill_rect(renderer, contentRect, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0x70); - struct LogViewerLayout { - std::vector visibleLines; - std::size_t firstVisibleIndex = 0U; - }; - const std::size_t maxOffset = viewModel.logViewerLines.size() > 1U ? viewModel.logViewerLines.size() - 1U : 0U; const std::size_t clampedOffset = std::min(viewModel.logViewerScrollOffset, maxOffset); - auto build_log_viewer_layout = [&](int availableWidth) { // NOSONAR(cpp:S1188) kept adjacent to modal layout state for readability - LogViewerLayout layout {}; - if (viewModel.logViewerLines.empty()) { - layout.visibleLines.push_back(nullptr); - return layout; - } - - int usedHeight = 0; - std::size_t endIndex = viewModel.logViewerLines.size() > clampedOffset ? viewModel.logViewerLines.size() - clampedOffset : 0U; - layout.firstVisibleIndex = endIndex; - while (endIndex > 0U) { - const std::string renderedLine = truncate_text_for_render(viewModel.logViewerLines[endIndex - 1U], LOG_VIEWER_MAX_RENDER_CHARACTERS); - const int lineHeight = measure_wrapped_text_height(smallFont, renderedLine, std::max(1, availableWidth - 12)) + 4; - if (!layout.visibleLines.empty() && usedHeight + lineHeight > contentRect.h - 8) { - break; - } - layout.visibleLines.push_back(&viewModel.logViewerLines[endIndex - 1U]); - usedHeight += lineHeight; - --endIndex; - } - layout.firstVisibleIndex = endIndex; - std::reverse(layout.visibleLines.begin(), layout.visibleLines.end()); - return layout; - }; constexpr int logViewerScrollbarWidth = 10; constexpr int logViewerScrollbarGap = 12; - LogViewerLayout logViewerLayout = build_log_viewer_layout(contentRect.w); + LogViewerLayout logViewerLayout = build_log_viewer_layout(viewModel, smallFont, contentRect.w, contentRect.h, clampedOffset); const bool overflow = !viewModel.logViewerLines.empty() && viewModel.logViewerLines.size() > logViewerLayout.visibleLines.size(); if (overflow) { - logViewerLayout = build_log_viewer_layout(std::max(1, contentRect.w - logViewerScrollbarWidth - logViewerScrollbarGap)); + logViewerLayout = build_log_viewer_layout(viewModel, smallFont, std::max(1, contentRect.w - logViewerScrollbarWidth - logViewerScrollbarGap), contentRect.h, clampedOffset); } const SDL_Rect textRect { @@ -1190,29 +1220,7 @@ namespace { std::max(1, contentRect.w - (overflow ? logViewerScrollbarWidth + logViewerScrollbarGap : 0)), contentRect.h, }; - int contentCursorY = textRect.y + 6; - if (const bool olderLinesAvailable = logViewerLayout.firstVisibleIndex > 0U; olderLinesAvailable) { - 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 (logViewerLayout.visibleLines.size() == 1U && logViewerLayout.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 : logViewerLayout.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.logViewerScrollOffset > 0U && !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))) { + if (!render_log_viewer_lines(renderer, smallFont, viewModel, textRect, logViewerLayout)) { return false; } @@ -1322,7 +1330,66 @@ namespace { 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)); } - bool render_footer_actions( // NOSONAR(cpp:S3776) footer chip layout is intentionally centralized for all shell surfaces + 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, @@ -1335,41 +1402,17 @@ namespace { const int chipY = footerRect.y + (footerRect.h - chipHeight) / 2; for (const ui::ShellFooterAction &action : actions) { - int labelWidth = 0; - if (int labelHeight = 0; TTF_SizeUTF8(font, action.label.c_str(), &labelWidth, &labelHeight) != 0) { - labelWidth = static_cast(action.label.size()) * 8; - } - - const int iconSize = (action.iconAssetPath.empty() && action.secondaryIconAssetPath.empty()) ? 0 : std::max(18, chipHeight - 14); - const int iconCount = (action.iconAssetPath.empty() ? 0 : 1) + (action.secondaryIconAssetPath.empty() ? 0 : 1); - const int iconBlockWidth = iconCount == 0 ? 0 : (iconSize * iconCount) + ((iconCount - 1) * 4); - const int chipWidth = 18 + iconBlockWidth + (iconBlockWidth > 0 ? 8 : 0) + labelWidth + 18; - if (cursorX + chipWidth > availableRight) { + const FooterActionChipLayout layout = measure_footer_action_chip(font, action, chipHeight); + if (cursorX + layout.chipWidth > availableRight) { break; } - const SDL_Rect chipRect {cursorX, chipY, chipWidth, chipHeight}; - - int contentX = chipRect.x + 10; - if (iconSize > 0) { - if (!action.iconAssetPath.empty()) { - const SDL_Rect iconRect {contentX, chipRect.y + (chipRect.h - iconSize) / 2, iconSize, iconSize}; - render_asset_icon(renderer, assetCache, action.iconAssetPath, iconRect); - contentX += iconSize + 4; - } - if (!action.secondaryIconAssetPath.empty()) { - const SDL_Rect iconRect {contentX, chipRect.y + (chipRect.h - iconSize) / 2, iconSize, iconSize}; - render_asset_icon(renderer, assetCache, action.secondaryIconAssetPath, iconRect); - contentX += iconSize + 4; - } - contentX += 4; - } - - if (!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)) { + const SDL_Rect chipRect {cursorX, chipY, layout.chipWidth, chipHeight}; + if (!render_footer_action_chip(renderer, font, assetCache, action, layout, chipRect)) { return false; } - cursorX += chipWidth + 12; + cursorX += layout.chipWidth + 12; } return true; @@ -1969,42 +2012,66 @@ namespace { logging::error("settings", saveResult.errorMessage); } - void apply_server_info_to_host(app::ClientState &state, const std::string &address, uint16_t port, const network::HostPairingServerInfo &serverInfo) { // NOSONAR(cpp:S3776) host metadata updates intentionally stay grouped with pairing-state transitions + 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.selectedAppIndex = 0U; + if (state.activeScreen == app::ScreenId::apps && state.activeHostLoaded && host == &state.activeHost) { + state.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 = false; - const bool hostRequiresManualPairing = app::host_requires_manual_pairing(state, address, port); - 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; - if (serverInfo.pairingStatusCurrentClientKnown) { - const bool clientIsEffectivelyPaired = serverInfo.pairingStatusCurrentClient && !hostRequiresManualPairing; - const app::PairingState resolvedPairingState = clientIsEffectivelyPaired ? app::PairingState::paired : app::PairingState::not_paired; - persistedMetadataChanged = persistedMetadataChanged || host.pairingState != resolvedPairingState; - host.pairingState = resolvedPairingState; - if (!clientIsEffectivelyPaired) { - 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.selectedAppIndex = 0U; - if (state.activeScreen == app::ScreenId::apps && state.activeHostLoaded && &host == &state.activeHost) { - state.statusMessage = host.appListStatusMessage; - } - } - } + 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.hostsDirty = state.hostsDirty || persistedMetadataChanged; }; @@ -3059,7 +3126,90 @@ namespace { logging::info("logging", statusMessage + ": " + loadedLog.filePath); } - bool draw_shell( // NOSONAR(cpp:S3776,cpp:S107) one-frame shell rendering is intentionally centralized to keep layout and failure handling consistent + 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; + } + + bool render_body_lines( + SDL_Renderer *renderer, + TTF_Font *font, + const std::vector &lines, + const SDL_Color &color, + int x, + int y, + int maxWidth, + int lineGap + ) { + int cursorY = y; + for (const std::string &line : lines) { + int drawnHeight = 0; + if (!render_text_line(renderer, font, line, color, x, cursorY, maxWidth, &drawnHeight)) { + return false; + } + cursorY += drawnHeight + lineGap; + } + return true; + } + + bool render_app_tiles_grid( + SDL_Renderer *renderer, + TTF_Font *smallFont, + const ui::ShellViewModel &viewModel, + const SDL_Rect &gridRect, + CoverArtTextureCache *textureCache, + AssetTextureCache *assetCache + ) { + const int columnCount = std::max(1, static_cast(viewModel.appColumnCount)); + const int tileGap = 16; + const int gridPadding = 10; + const GridViewport viewport = calculate_grid_viewport(viewModel.appTiles.size(), viewModel.appColumnCount, selected_app_tile_index(viewModel.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.appColumnCount; + const std::size_t endIndex = std::min(viewModel.appTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.appColumnCount); + + for (std::size_t index = startIndex; index < endIndex; ++index) { + const int row = static_cast(index / viewModel.appColumnCount) - viewport.startRow; + const auto column = static_cast(index % viewModel.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.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.bodyLines, gridRect.w - 48, lineGap); + return render_body_lines(renderer, smallFont, viewModel.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, @@ -3235,57 +3385,12 @@ namespace { }; if (!viewModel.appTiles.empty()) { - const int columnCount = std::max(1, static_cast(viewModel.appColumnCount)); - const int tileGap = 16; - const int gridPadding = 10; - const GridViewport viewport = calculate_grid_viewport(viewModel.appTiles.size(), viewModel.appColumnCount, selected_app_tile_index(viewModel.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.appColumnCount; - const std::size_t endIndex = std::min(viewModel.appTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.appColumnCount); - for (std::size_t index = startIndex; index < endIndex; ++index) { - const int row = static_cast(index / viewModel.appColumnCount) - viewport.startRow; - const auto column = static_cast(index % viewModel.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, - }; - const ui::ShellAppTile &tile = viewModel.appTiles[index]; - if (!render_app_cover(renderer, smallFont, tile, tileRect, textureCache, assetCache)) { // NOSONAR(cpp:S134) app-grid rendering keeps per-tile failure handling inline with layout - 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 - ); + if (!render_app_tiles_grid(renderer, smallFont, viewModel, gridRect, textureCache, assetCache)) { + return false; } } else if (!viewModel.bodyLines.empty()) { - const int lineGap = 8; - int textHeight = 0; - for (std::size_t index = 0; index < viewModel.bodyLines.size(); ++index) { - textHeight += measure_wrapped_text_height(smallFont, viewModel.bodyLines[index], gridRect.w - 48); - if (index + 1U < viewModel.bodyLines.size()) { // NOSONAR(cpp:S134) empty-state text height is accumulated inline with layout calculation - textHeight += lineGap; - } - } - - int messageY = gridRect.y + std::max(16, (gridRect.h - textHeight) / 2); - for (const std::string &line : viewModel.bodyLines) { - int drawnHeight = 0; - if (!render_text_line(renderer, smallFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, gridRect.x + 24, messageY, gridRect.w - 48, &drawnHeight)) { - return false; - } - messageY += drawnHeight + lineGap; + if (!render_apps_empty_state(renderer, smallFont, viewModel, gridRect)) { + return false; } } } else { @@ -3389,13 +3494,8 @@ namespace { return false; } } else { - int bodyY = bodyPanel.y + panelPadding; - for (const std::string &line : viewModel.bodyLines) { - int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, line, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyY, bodyPanel.w - (panelPadding * 2), &drawnHeight)) { // NOSONAR(cpp:S134) settings-body rendering keeps layout failure handling local - return false; - } - bodyY += drawnHeight + 8; + if (!render_body_lines(renderer, bodyFont, viewModel.bodyLines, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyPanel.y + panelPadding, bodyPanel.w - (panelPadding * 2), 8)) { + return false; } } } @@ -3579,11 +3679,42 @@ namespace { } } + bool should_open_added_controller(SDL_GameController *controller, const SDL_ControllerDeviceEvent &event) { + return controller == nullptr && SDL_IsGameController(event.which); + } + + bool should_close_removed_controller(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.activeScreen == app::ScreenId::home || state.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); + } + } // namespace namespace ui { - int run_shell( // NOSONAR(cpp:S3776) shell loop owns all frame/update orchestration in one place by design + int run_shell( SDL_Window *window, const VIDEO_MODE &videoMode, app::ClientState &state @@ -3743,9 +3874,7 @@ namespace ui { start_app_list_task_if_needed(state, &appListTask, SDL_GetTicks()); start_app_art_task_if_needed(state, &appArtTask); - if ( - !controllerExitComboTriggered && controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) - ) { + if (!controllerExitComboTriggered && controllerStartPressed && controllerBackPressed && hosts_screen_exit_combo_allowed(state)) { controllerExitComboArmed = true; const Uint32 comboStartTick = controllerStartDownTick > controllerBackDownTick ? controllerStartDownTick : controllerBackDownTick; if (SDL_GetTicks() - comboStartTick >= EXIT_COMBO_HOLD_MILLISECONDS) { @@ -3786,7 +3915,7 @@ namespace ui { state.shouldExit = true; break; case SDL_CONTROLLERDEVICEADDED: - if (controller == nullptr && SDL_IsGameController(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing + if (should_open_added_controller(controller, event.cdevice)) { controller = SDL_GameControllerOpen(event.cdevice.which); if (controller != nullptr) { logging::info("input", "Controller connected"); @@ -3794,7 +3923,7 @@ namespace ui { } break; case SDL_CONTROLLERDEVICEREMOVED: - if (controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.cdevice.which)) { // NOSONAR(cpp:S134) controller lifecycle handling stays inline with SDL event routing + if (should_close_removed_controller(controller, event.cdevice)) { close_controller(controller); controller = nullptr; leftTriggerPressed = false; @@ -3845,9 +3974,7 @@ namespace ui { } } - if ( // NOSONAR(cpp:S134) exit-combo arming stays inline with the button state machine - controllerStartPressed && controllerBackPressed && (state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts) - ) { + if (controllerStartPressed && controllerBackPressed && hosts_screen_exit_combo_allowed(state)) { controllerExitComboArmed = true; } break; @@ -3880,16 +4007,10 @@ namespace ui { break; case SDL_CONTROLLERAXISMOTION: command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); - if (command == input::UiCommand::fast_previous_page) { // NOSONAR(cpp:S134) trigger repeat bookkeeping stays inline with translated command handling - leftTriggerRepeatTick = SDL_GetTicks(); - } else if (command == input::UiCommand::fast_next_page) { - rightTriggerRepeatTick = SDL_GetTicks(); - } + update_trigger_repeat_tick(command, SDL_GetTicks(), &leftTriggerRepeatTick, &rightTriggerRepeatTick); break; case SDL_KEYDOWN: - if (event.key.repeat == 0) { // NOSONAR(cpp:S134) keyboard translation stays inline with SDL event routing - command = translate_keyboard_key(event.key.keysym.sym, event.key.keysym.mod); - } + command = translate_unrepeated_keydown(event.key); break; default: break; diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index 80ad497..688d4bc 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -3,7 +3,6 @@ // standard includes #include -#include #include #include @@ -222,110 +221,127 @@ namespace { return lines; } - std::vector body_lines(const app::ClientState &state) { // NOSONAR(cpp:S3776) screen-specific copy is kept local for render-model assembly + std::vector hosts_body_lines(const app::ClientState &state) { + if (state.hosts.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.selectedSettingsCategory), + }; + if (state.selectedSettingsCategory == app::SettingsCategory::logging) { + lines.emplace_back("Runtime log file: reset on every startup"); + lines.push_back(std::string("Log file path: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); + lines.push_back(std::string("File logging level: ") + logging::to_string(state.loggingLevel)); + lines.push_back(std::string("xemu console logging level: ") + logging::to_string(state.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.selectedSettingsCategory == app::SettingsCategory::reset) { + if (state.savedFiles.empty()) { + lines.emplace_back("Saved files: none found."); + return lines; + } + lines.emplace_back("Saved files on disk:"); + for (const startup::SavedFileEntry &savedFile : state.savedFiles) { + lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); + } + return lines; + } + if (state.selectedSettingsCategory == 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.activeScreen) { case app::ScreenId::home: case app::ScreenId::hosts: - if (state.hosts.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.", - }; + return hosts_body_lines(state); case app::ScreenId::apps: - { - 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 {}; - } + return apps_body_lines(state); case app::ScreenId::add_host: - { - 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; - } + return add_host_body_lines(state); case app::ScreenId::pair_host: - { - 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; - } + return pair_host_body_lines(state); case app::ScreenId::settings: - { - std::vector lines = { - std::string("Category: ") + settings_category_label(state.selectedSettingsCategory), - }; - if (state.selectedSettingsCategory == app::SettingsCategory::logging) { - lines.emplace_back("Runtime log file: reset on every startup"); - lines.push_back(std::string("Log file path: ") + (state.logFilePath.empty() ? "not configured" : state.logFilePath)); - lines.push_back(std::string("File logging level: ") + logging::to_string(state.loggingLevel)); - lines.push_back(std::string("xemu console logging level: ") + logging::to_string(state.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."); - } else if (state.selectedSettingsCategory == app::SettingsCategory::reset) { - if (state.savedFiles.empty()) { - lines.emplace_back("Saved files: none found."); - return lines; - } - lines.emplace_back("Saved files on disk:"); - for (const startup::SavedFileEntry &savedFile : state.savedFiles) { - lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); - } - } else if (state.selectedSettingsCategory == app::SettingsCategory::display) { - lines.emplace_back("Display options will be added here."); - } else { - lines.emplace_back("Input options will be added here."); - } - return lines; - } + return settings_body_lines(state); } return {}; @@ -371,7 +387,108 @@ namespace { }; } - void fill_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { // NOSONAR(cpp:S3776) modal rendering data stays centralized to keep state/view mapping explicit + void fill_support_modal_view(ui::ShellViewModel *viewModel) { + viewModel->modalTitle = "Support"; + viewModel->modalLines = { + "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->modalFooterActions = { + {"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->modalTitle = "Host Actions"; + viewModel->modalActions = { + {"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->modalTitle = "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->modalLines = { + "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->modalTitle = appRecord->name; + viewModel->modalActions = { + {"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->modalTitle = "App Details"; + viewModel->modalLines = { + "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->modalTitle = "Log File"; + viewModel->logViewerVisible = true; + viewModel->logViewerPath = state.logFilePath.empty() ? "not configured" : state.logFilePath; + viewModel->logViewerLines = state.logViewerLines; + viewModel->logViewerScrollOffset = state.logViewerScrollOffset; + viewModel->logViewerPlacement = state.logViewerPlacement; + viewModel->modalFooterActions = { + {"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->modalTitle = state.confirmation.title; + viewModel->modalLines = state.confirmation.lines; + viewModel->modalFooterActions = { + {"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; } @@ -379,97 +496,25 @@ namespace { viewModel->modalVisible = true; switch (state.modal.id) { case app::ModalId::support: - viewModel->modalTitle = "Support"; - viewModel->modalLines = { - "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->modalFooterActions = { - {"close", "Close", "icons\\button-a.svg", "icons\\button-start.svg", true}, - {"back", "Back", "icons\\button-b.svg", {}, false}, - }; + fill_support_modal_view(viewModel); return; case app::ModalId::host_actions: - viewModel->modalTitle = "Host Actions"; - viewModel->modalActions = { - {"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}, - }; + fill_host_actions_modal_view(state, viewModel); return; case app::ModalId::host_details: - { - viewModel->modalTitle = "Host Details"; - if (const app::HostRecord *host = app::selected_host(state); host != nullptr) { - const char *reachabilityLabel = "UNKNOWN"; - if (host->reachability == app::HostReachability::online) { - reachabilityLabel = "ONLINE"; - } else if (host->reachability == app::HostReachability::offline) { - reachabilityLabel = "OFFLINE"; - } - - viewModel->modalLines = { - "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)), - }; - } - return; - } + fill_host_details_modal_view(state, viewModel); + return; case app::ModalId::app_actions: - if (const app::HostAppRecord *appRecord = app::selected_app(state); appRecord != nullptr) { - viewModel->modalTitle = appRecord->name; - viewModel->modalActions = { - {"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}, - }; - } + fill_app_actions_modal_view(state, viewModel); return; case app::ModalId::app_details: - if (const app::HostAppRecord *appRecord = app::selected_app(state); appRecord != nullptr) { - viewModel->modalTitle = "App Details"; - viewModel->modalLines = { - "Name: " + appRecord->name, - std::string("HDR Supported: ") + (appRecord->hdrSupported ? "YES" : "NO"), - "ID: " + std::to_string(appRecord->id), - }; - } + fill_app_details_modal_view(state, viewModel); return; case app::ModalId::log_viewer: - viewModel->modalTitle = "Log File"; - viewModel->logViewerVisible = true; - viewModel->logViewerPath = state.logFilePath.empty() ? "not configured" : state.logFilePath; - viewModel->logViewerLines = state.logViewerLines; - viewModel->logViewerScrollOffset = state.logViewerScrollOffset; - viewModel->logViewerPlacement = state.logViewerPlacement; - viewModel->modalFooterActions = { - {"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}, - }; + fill_log_viewer_modal_view(state, viewModel); return; case app::ModalId::confirmation: - viewModel->modalTitle = state.confirmation.title; - viewModel->modalLines = state.confirmation.lines; - viewModel->modalFooterActions = { - {"confirm", "OK", "icons\\button-a.svg", {}, state.modal.selectedActionIndex == 0U}, - {"cancel", "Cancel", "icons\\button-b.svg", {}, state.modal.selectedActionIndex != 0U}, - }; + fill_confirmation_modal_view(state, viewModel); return; case app::ModalId::none: return; @@ -537,7 +582,63 @@ namespace { namespace ui { - ShellViewModel build_shell_view_model( // NOSONAR(cpp:S3776) this function intentionally assembles the complete render snapshot in one place + bool screen_uses_split_menu_layout(app::ScreenId screen) { + return screen == app::ScreenId::settings || screen == app::ScreenId::add_host || screen == app::ScreenId::pair_host; + } + + void fill_view_model_panel_state(const app::ClientState &state, ShellViewModel *viewModel) { + if (!screen_uses_split_menu_layout(state.activeScreen)) { + return; + } + + viewModel->leftPanelActive = state.activeScreen != app::ScreenId::settings || state.settingsFocusArea == app::SettingsFocusArea::categories; + viewModel->rightPanelActive = state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options; + } + + void fill_view_model_selected_menu_details(const app::ClientState &state, ShellViewModel *viewModel) { + if (!screen_uses_split_menu_layout(state.activeScreen)) { + return; + } + + if (state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { + viewModel->selectedMenuRowLabel = state.detailMenu.selected_item()->label; + viewModel->selectedMenuRowDescription = state.detailMenu.selected_item()->description; + return; + } + if (state.menu.selected_item() != nullptr) { + viewModel->selectedMenuRowLabel = state.menu.selected_item()->label; + viewModel->selectedMenuRowDescription = state.menu.selected_item()->description; + } + } + + void fill_view_model_overlay(const app::ClientState &state, const std::vector &logEntries, const std::vector &statsLines, ShellViewModel *viewModel) { + if (!viewModel->overlayVisible) { + return; + } + + if (!statsLines.empty()) { + viewModel->overlayLines.insert(viewModel->overlayLines.end(), statsLines.begin(), statsLines.end()); + } else { + viewModel->overlayLines.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.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->overlayLines.push_back(logging::format_entry(logEntries[index])); + } + + if (clampedOffset > 0U) { + viewModel->overlayLines.emplace(viewModel->overlayLines.begin(), "Showing earlier log entries"); + } + } + + ShellViewModel build_shell_view_model( const app::ClientState &state, const std::vector &logEntries, const std::vector &statsLines @@ -559,19 +660,8 @@ namespace ui { viewModel.bodyLines = body_lines(state); viewModel.menuRows = menu_rows(state); viewModel.detailMenuRows = detail_menu_rows(state); - if (state.activeScreen == app::ScreenId::settings || state.activeScreen == app::ScreenId::add_host || state.activeScreen == app::ScreenId::pair_host) { - viewModel.leftPanelActive = state.activeScreen != app::ScreenId::settings || state.settingsFocusArea == app::SettingsFocusArea::categories; - viewModel.rightPanelActive = state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options; - } - if (state.activeScreen == app::ScreenId::settings || state.activeScreen == app::ScreenId::add_host || state.activeScreen == app::ScreenId::pair_host) { - if (state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { - viewModel.selectedMenuRowLabel = state.detailMenu.selected_item()->label; - viewModel.selectedMenuRowDescription = state.detailMenu.selected_item()->description; - } else if (state.menu.selected_item() != nullptr) { - viewModel.selectedMenuRowLabel = state.menu.selected_item()->label; - viewModel.selectedMenuRowDescription = state.menu.selected_item()->description; - } - } + fill_view_model_panel_state(state, &viewModel); + fill_view_model_selected_menu_details(state, &viewModel); viewModel.footerActions = footer_actions(state); viewModel.overlayVisible = state.overlayVisible; viewModel.overlayTitle = "Diagnostics"; @@ -586,27 +676,7 @@ namespace ui { fill_modal_view(state, &viewModel); - if (viewModel.overlayVisible) { - if (!statsLines.empty()) { - viewModel.overlayLines.insert(viewModel.overlayLines.end(), statsLines.begin(), statsLines.end()); - } else { - viewModel.overlayLines.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.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.overlayLines.push_back(logging::format_entry(logEntries[index])); - } - - if (clampedOffset > 0) { - viewModel.overlayLines.emplace(viewModel.overlayLines.begin(), "Showing earlier log entries"); - } - } + fill_view_model_overlay(state, logEntries, statsLines, &viewModel); return viewModel; } From 9b314256cf27f45c75cd40875f08558293a57721 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 12 Apr 2026 12:16:59 -0400 Subject: [PATCH 20/35] style: sonar fixes --- cmake/xbox-build.cmake | 7 +- src/app/client_state.cpp | 22 +- src/app/host_records.cpp | 30 +- src/app/pairing_flow.cpp | 6 +- src/app/pairing_flow.h | 5 +- src/app/settings_storage.cpp | 96 ++-- src/logging/logger.cpp | 32 +- src/main.cpp | 3 +- src/network/host_pairing.cpp | 34 +- src/startup/saved_files.cpp | 9 +- src/startup/storage_paths.cpp | 17 +- src/ui/host_probe_result_queue.cpp | 12 +- src/ui/shell_screen.cpp | 651 ++++++++++++++--------- tests/CMakeLists.txt | 4 + tests/unit/app/settings_storage_test.cpp | 3 +- 15 files changed, 552 insertions(+), 379 deletions(-) diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index 378e072..e896b43 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -81,7 +81,12 @@ target_compile_options(${CMAKE_PROJECT_NAME} ${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/src/app/client_state.cpp b/src/app/client_state.cpp index a7a74ad..35d7c05 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include @@ -627,8 +628,7 @@ namespace { } const std::size_t targetRowStart = static_cast(targetRow) * ADD_HOST_KEYPAD_COLUMN_COUNT; - const std::size_t targetRowWidth = std::min(ADD_HOST_KEYPAD_COLUMN_COUNT, layout.buttonCount - targetRowStart); - if (columnDelta != 0 && targetRowWidth > 0U) { + 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)); } @@ -848,7 +848,7 @@ namespace { } bool enter_apps_screen(app::ClientState &state, bool showHiddenApps) { - app::HostRecord *host = state.hosts.empty() ? nullptr : &state.hosts[state.selectedHostIndex]; + const app::HostRecord *host = state.hosts.empty() ? nullptr : &state.hosts[state.selectedHostIndex]; if (host == nullptr) { return false; } @@ -1130,8 +1130,7 @@ namespace { */ bool handle_app_actions_modal_activation(app::ClientState &state, app::AppUpdate *update) { const app::HostRecord *host = app::apps_host(state); - const app::HostAppRecord *selectedApp = app::selected_app(state); - if (host == nullptr || selectedApp == nullptr) { + if (const app::HostAppRecord *selectedApp = app::selected_app(state); host == nullptr || selectedApp == nullptr) { close_modal_and_mark_closed(state, update); return true; } @@ -1400,8 +1399,7 @@ namespace app { * @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) { - HostRecord *host = find_host_by_endpoint(state.hosts, address, port); - if (host != nullptr) { + if (HostRecord *host = find_host_by_endpoint(state.hosts, address, port); host != nullptr) { return host; } if (state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { @@ -1552,8 +1550,7 @@ namespace app { } bool persistedAppCacheChanged = false; - const bool appListChanged = host->apps.empty() || host->appListContentHash == 0U || host->appListContentHash != appListContentHash; - if (appListChanged) { + 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 { @@ -1737,8 +1734,7 @@ namespace app { return true; case input::UiCommand::activate: { - char character = '\0'; - if (selected_add_host_keypad_character(state, &character)) { + if (char character = '\0'; selected_add_host_keypad_character(state, &character)) { append_to_active_add_host_field(state, character); } return true; @@ -2065,7 +2061,7 @@ namespace app { * @param state Client state containing the add-host draft. * @param validationError Validation message to display. */ - void apply_add_host_validation_error(ClientState &state, const std::string &validationError) { + void apply_add_host_validation_error(ClientState &state, std::string_view validationError) { state.addHostDraft.validationMessage = validationError; state.statusMessage = validationError; } @@ -2077,7 +2073,7 @@ namespace app { * @param activatedItemId Activated menu item identifier. * @param update Update structure that receives side effects. */ - void handle_add_host_menu_activation(ClientState &state, const std::string &activatedItemId, AppUpdate *update) { + void handle_add_host_menu_activation(ClientState &state, std::string_view activatedItemId, AppUpdate *update) { if (update == nullptr) { return; } diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index db04d4f..1122203 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -4,6 +4,7 @@ // standard includes #include #include +#include #include #include #include @@ -57,8 +58,20 @@ namespace { return segments; } - char hex_digit(unsigned int value) { - return static_cast(value < 10U ? ('0' + value) : ('A' + (value - 10U))); + 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) { @@ -75,9 +88,7 @@ namespace { continue; } - encoded.push_back('%'); - encoded.push_back(hex_digit((character >> 4U) & 0x0FU)); - encoded.push_back(hex_digit(character & 0x0FU)); + append_percent_encoded_byte(&encoded, character); } return encoded; @@ -103,9 +114,11 @@ namespace { std::string result; result.reserve(text.size()); - for (std::size_t index = 0; index < text.size(); ++index) { + std::size_t index = 0; + while (index < text.size()) { if (text[index] != '%') { result.push_back(text[index]); + ++index; continue; } @@ -120,7 +133,7 @@ namespace { } result.push_back(static_cast((highNibble << 4U) | lowNibble)); - index += 2U; + index += 3U; } *decoded = std::move(result); @@ -445,8 +458,7 @@ namespace app { pairingState, }; - std::string errorMessage; - if (!validate_host_record(record, &errorMessage)) { + if (std::string errorMessage; !validate_host_record(record, &errorMessage)) { result->errors.push_back("Line " + std::to_string(lineNumber) + ": " + errorMessage); return; } diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp index 35ecd70..72f57cb 100644 --- a/src/app/pairing_flow.cpp +++ b/src/app/pairing_flow.cpp @@ -6,9 +6,9 @@ namespace app { - PairingDraft create_pairing_draft(const std::string &targetAddress, uint16_t targetPort, std::string generatedPin) { + PairingDraft create_pairing_draft(std::string_view targetAddress, uint16_t targetPort, std::string generatedPin) { PairingDraft draft { - targetAddress, + std::string(targetAddress), targetPort, std::move(generatedPin), PairingStage::idle, @@ -17,7 +17,7 @@ namespace app { return draft; } - bool is_valid_pairing_pin(const std::string &pin) { + bool is_valid_pairing_pin(std::string_view pin) { if (pin.size() != 4) { return false; } diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h index 10a4ee0..392a81c 100644 --- a/src/app/pairing_flow.h +++ b/src/app/pairing_flow.h @@ -3,6 +3,7 @@ // standard includes #include #include +#include namespace app { @@ -36,7 +37,7 @@ namespace app { * @param generatedPin Client-generated PIN to show to the user. * @return Initialized pairing draft. */ - PairingDraft create_pairing_draft(const std::string &targetAddress, uint16_t targetPort, std::string generatedPin); + 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. @@ -44,6 +45,6 @@ namespace app { * @param pin Candidate PIN string. * @return true when the PIN contains exactly four digits. */ - bool is_valid_pairing_pin(const std::string &pin); + 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 index 4b9f0f0..c9db7f6 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -16,8 +16,6 @@ extern "C" FILE *_wfopen(const wchar_t *path, const wchar_t *mode); #endif -#define TOML_EXCEPTIONS 0 -#define TOML_ENABLE_WINDOWS_COMPAT 0 #include // local includes @@ -208,6 +206,47 @@ namespace { 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"; @@ -315,55 +354,26 @@ namespace app { inspect_top_level_keys(settingsTable, filePath, &result); const auto loggingLevelNode = settingsTable["logging"]["file_minimum_level"]; - if (loggingLevelNode) { - if (const auto loggingLevelText = loggingLevelNode.value(); loggingLevelText) { - if (!try_parse_logging_level(*loggingLevelText, &result.settings.loggingLevel)) { - append_invalid_value_warning(&result.warnings, filePath, "logging.file_minimum_level", *loggingLevelText); - } - } else { - append_invalid_value_warning(&result.warnings, filePath, "logging.file_minimum_level", ""); - } - } + load_logging_level_setting(loggingLevelNode, filePath, "logging.file_minimum_level", &result.settings.loggingLevel, &result.warnings); - const auto legacyLoggingLevelNode = settingsTable["logging"]["minimum_level"]; - if (legacyLoggingLevelNode && !loggingLevelNode) { - if (const auto loggingLevelText = legacyLoggingLevelNode.value(); loggingLevelText) { - if (!try_parse_logging_level(*loggingLevelText, &result.settings.loggingLevel)) { - append_invalid_value_warning(&result.warnings, filePath, "logging.minimum_level", *loggingLevelText); - } - } else { - append_invalid_value_warning(&result.warnings, filePath, "logging.minimum_level", ""); - } + if (const auto legacyLoggingLevelNode = settingsTable["logging"]["minimum_level"]; legacyLoggingLevelNode && !loggingLevelNode) { + load_logging_level_setting(legacyLoggingLevelNode, filePath, "logging.minimum_level", &result.settings.loggingLevel, &result.warnings); } - const auto xemuConsoleLoggingLevelNode = settingsTable["logging"]["xemu_console_minimum_level"]; - if (xemuConsoleLoggingLevelNode) { - if (const auto xemuConsoleLoggingLevelText = xemuConsoleLoggingLevelNode.value(); xemuConsoleLoggingLevelText) { - if (!try_parse_logging_level(*xemuConsoleLoggingLevelText, &result.settings.xemuConsoleLoggingLevel)) { - append_invalid_value_warning(&result.warnings, filePath, "logging.xemu_console_minimum_level", *xemuConsoleLoggingLevelText); - } - } else { - append_invalid_value_warning(&result.warnings, filePath, "logging.xemu_console_minimum_level", ""); - } - } - - const auto logViewerPlacementNode = settingsTable["ui"]["log_viewer_placement"]; - if (logViewerPlacementNode) { - if (const auto logViewerPlacementText = logViewerPlacementNode.value(); logViewerPlacementText) { - if (!try_parse_log_viewer_placement(*logViewerPlacementText, &result.settings.logViewerPlacement)) { - append_invalid_value_warning(&result.warnings, filePath, "ui.log_viewer_placement", *logViewerPlacementText); - } - } else { - append_invalid_value_warning(&result.warnings, filePath, "ui.log_viewer_placement", ""); - } - } + 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) { - std::string errorMessage; - if (!write_text_file(filePath, format_settings_toml(settings), &errorMessage)) { + if (std::string errorMessage; !write_text_file(filePath, format_settings_toml(settings), &errorMessage)) { return {false, errorMessage}; } diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index 52b1afe..a6d41dd 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -2,6 +2,7 @@ #include "src/logging/logger.h" // standard includes +#include #include #include #include @@ -20,15 +21,22 @@ namespace { - logging::Logger *g_globalLogger = nullptr; - bool g_startupConsoleEnabled = true; + logging::Logger *&global_logger_slot() { + static logging::Logger *globalLogger = nullptr; + return globalLogger; + } + + bool &startup_console_enabled_slot() { + static bool startupConsoleEnabled = true; + return startupConsoleEnabled; + } bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { return static_cast(candidateLevel) >= static_cast(minimumLevel); } logging::Logger *registered_logger() { - return g_globalLogger; + return global_logger_slot(); } logging::LogLevel startup_console_level(logging::LogLevel level) { @@ -187,7 +195,7 @@ namespace logging { } void set_global_logger(Logger *logger) { - g_globalLogger = logger; + global_logger_slot() = logger; } bool has_global_logger() { @@ -253,7 +261,7 @@ namespace logging { } std::vector snapshot(LogLevel minimumLevel) { - if (Logger *logger = registered_logger(); logger != nullptr) { + if (const Logger *logger = registered_logger(); logger != nullptr) { return logger->snapshot(minimumLevel); } @@ -271,11 +279,11 @@ namespace logging { } void set_startup_console_enabled(bool enabled) { - g_startupConsoleEnabled = enabled; + startup_console_enabled_slot() = enabled; } bool startup_console_enabled() { - return g_startupConsoleEnabled; + return startup_console_enabled_slot(); } void print_startup_console_line(LogLevel level, std::string_view category, std::string_view message) { @@ -351,13 +359,9 @@ namespace logging { return true; } - for (const RegisteredSink ®isteredSink : sinks_) { - if (registeredSink.sink && is_enabled(level, registeredSink.minimumLevel)) { - return true; - } - } - - return false; + 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) { diff --git a/src/main.cpp b/src/main.cpp index 6a28a2b..8bd2251 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -162,8 +162,7 @@ int main() { logging::RuntimeLogFileSink runtimeLogFile(logFilePath); app::set_log_file_path(clientState, logFilePath); - std::string logFileResetError; - if (!runtimeLogFile.reset(&logFileResetError)) { + if (std::string logFileResetError; !runtimeLogFile.reset(&logFileResetError)) { logging::print_startup_console_line( logging::LogLevel::warning, "logging", diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 71e5f6f..63f3bf1 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -96,12 +97,16 @@ namespace { constexpr std::string_view UNPAIRED_CLIENT_ERROR_MESSAGE = "The host reports that this client is no longer paired. Pair the host again."; struct WsaGuard { - WsaGuard() { + WsaGuard(): + initialized(initialize()) { + } + + static bool initialize() { #if defined(NXDK) || !defined(_WIN32) - initialized = true; + return true; #else WSADATA wsaData {}; - initialized = WSAStartup(MAKEWORD(2, 2), &wsaData) == 0; + return WSAStartup(MAKEWORD(2, 2), &wsaData) == 0; #endif } @@ -144,20 +149,20 @@ namespace { return append_error(errorMessage, "Pairing cancelled"); } - void append_hash_bytes(uint64_t *hash, const unsigned char *bytes, std::size_t byteCount) { + 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 ^= bytes[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 unsigned char delimiter = 0x1F; + append_hash_bytes(hash, reinterpret_cast(text.data()), text.size()); + static constexpr std::byte delimiter {0x1F}; append_hash_bytes(hash, &delimiter, 1U); } @@ -539,10 +544,8 @@ namespace { return true; } - if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked")) { - if (isChunked != nullptr) { - *isChunked = true; - } + if (ascii_iequals(headerName, "Transfer-Encoding") && header_value_contains_token(headerValue, "chunked") && isChunked != nullptr) { + *isChunked = true; } return true; } @@ -1964,8 +1967,7 @@ namespace { return false; } - const network::PairingIdentity *tlsIdentity = useTls ? &session->request.identity : nullptr; - if (!http_get(session->request.address, useTls ? session->serverInfo.httpsPort : session->serverInfo.httpPort, path, useTls, tlsIdentity, expectedTlsCertificatePem, &session->response, &session->errorMessage, session->cancelRequested)) { + 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); @@ -2467,9 +2469,9 @@ namespace network { 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)); + 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; } diff --git a/src/startup/saved_files.cpp b/src/startup/saved_files.cpp index 306c869..f49719d 100644 --- a/src/startup/saved_files.cpp +++ b/src/startup/saved_files.cpp @@ -110,11 +110,8 @@ namespace { const std::string searchPattern = join_path(rootPath, "*"); HANDLE handle = FindFirstFileA(searchPattern.c_str(), &findData); if (handle == INVALID_HANDLE_VALUE) { - const DWORD errorCode = GetLastError(); - if (errorCode != ERROR_FILE_NOT_FOUND && errorCode != ERROR_PATH_NOT_FOUND) { - if (warnings != nullptr) { - warnings->push_back("Failed to enumerate saved files in '" + rootPath + "': error " + std::to_string(static_cast(errorCode))); - } + 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; } @@ -139,7 +136,7 @@ namespace { 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(static_cast(lastError))); + warnings->push_back("Stopped enumerating saved files in '" + rootPath + "' early: error " + std::to_string(lastError)); } #else DIR *directory = opendir(rootPath.c_str()); diff --git a/src/startup/storage_paths.cpp b/src/startup/storage_paths.cpp index c16eb78..80e255a 100644 --- a/src/startup/storage_paths.cpp +++ b/src/startup/storage_paths.cpp @@ -5,28 +5,21 @@ #include // nxdk includes -#if defined(__has_include) - #if __has_include() - #include - #include - #define MOONLIGHT_HAS_NXDK_XBE 1 - #endif +#if defined(__has_include) && __has_include() + #include #if __has_include() #include - #define MOONLIGHT_HAS_NXDK_MOUNT 1 #endif -#endif - -#ifdef MOONLIGHT_HAS_NXDK_XBE #include #include + #include #endif namespace startup { std::string title_scoped_storage_root() { -#ifdef MOONLIGHT_HAS_NXDK_XBE - #ifdef MOONLIGHT_HAS_NXDK_MOUNT +#if defined(__has_include) && __has_include() + #if __has_include() if (!nxIsDriveMounted('E') && !nxMountDrive('E', "\\Device\\Harddisk0\\Partition1\\")) { return {}; } diff --git a/src/ui/host_probe_result_queue.cpp b/src/ui/host_probe_result_queue.cpp index 6ec3c67..047a21f 100644 --- a/src/ui/host_probe_result_queue.cpp +++ b/src/ui/host_probe_result_queue.cpp @@ -11,7 +11,7 @@ namespace ui { return; } - const std::lock_guard lock(queue->mutex); + const std::scoped_lock lock(queue->mutex); queue->targetCount = 0U; queue->publishedCount = 0U; queue->pendingResults.clear(); @@ -22,7 +22,7 @@ namespace ui { return; } - const std::lock_guard lock(queue->mutex); + const std::scoped_lock lock(queue->mutex); queue->targetCount = targetCount; queue->publishedCount = 0U; queue->pendingResults.clear(); @@ -33,7 +33,7 @@ namespace ui { return; } - const std::lock_guard lock(queue->mutex); + const std::scoped_lock lock(queue->mutex); queue->pendingResults.push_back(std::move(result)); ++queue->publishedCount; } @@ -43,7 +43,7 @@ namespace ui { return; } - const std::lock_guard lock(queue->mutex); + const std::scoped_lock lock(queue->mutex); if (queue->targetCount > 0U) { --queue->targetCount; } @@ -54,14 +54,14 @@ namespace ui { return {}; } - const std::lock_guard lock(queue->mutex); + 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::lock_guard lock(queue.mutex); + const std::scoped_lock lock(queue.mutex); return queue.targetCount != 0U && queue.publishedCount >= queue.targetCount; } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 4152864..03b98d2 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -1023,7 +1024,7 @@ namespace { const ui::ShellAppTile &tile, const SDL_Rect &rect, CoverArtTextureCache *textureCache, - AssetTextureCache *assetCache + 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) { @@ -1214,13 +1215,13 @@ namespace { logViewerLayout = build_log_viewer_layout(viewModel, smallFont, std::max(1, contentRect.w - logViewerScrollbarWidth - logViewerScrollbarGap), contentRect.h, clampedOffset); } - const SDL_Rect textRect { - contentRect.x, - contentRect.y, - std::max(1, contentRect.w - (overflow ? logViewerScrollbarWidth + logViewerScrollbarGap : 0)), - contentRect.h, - }; - if (!render_log_viewer_lines(renderer, smallFont, viewModel, textRect, logViewerLayout)) { + 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; } @@ -1407,8 +1408,7 @@ namespace { break; } - const SDL_Rect chipRect {cursorX, chipY, layout.chipWidth, chipHeight}; - if (!render_footer_action_chip(renderer, font, assetCache, action, layout, chipRect)) { + if (const SDL_Rect chipRect {cursorX, chipY, layout.chipWidth, chipHeight}; !render_footer_action_chip(renderer, font, assetCache, action, layout, chipRect)) { return false; } @@ -3137,27 +3137,89 @@ namespace { 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 SDL_Color &color, - int x, - int y, - int maxWidth, - int lineGap + const BodyLinesRenderLayout &layout ) { - int cursorY = y; + int cursorY = layout.y; for (const std::string &line : lines) { int drawnHeight = 0; - if (!render_text_line(renderer, font, line, color, x, cursorY, maxWidth, &drawnHeight)) { + if (!render_text_line(renderer, font, line, layout.color, layout.x, cursorY, layout.maxWidth, &drawnHeight)) { return false; } - cursorY += drawnHeight + lineGap; + 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.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.selectedMenuRowLabel.empty()) { + int drawnHeight = 0; + if (!render_text_line(renderer, bodyFont, viewModel.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.selectedMenuRowDescription.empty() ? std::string("No description is available for the selected setting.") : viewModel.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, @@ -3206,7 +3268,12 @@ namespace { 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.bodyLines, gridRect.w - 48, lineGap); - return render_body_lines(renderer, smallFont, viewModel.bodyLines, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, gridRect.x + 24, gridRect.y + std::max(16, (gridRect.h - textHeight) / 2), gridRect.w - 48, lineGap); + return render_body_lines( + renderer, + smallFont, + viewModel.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 @@ -3388,10 +3455,8 @@ namespace { if (!render_app_tiles_grid(renderer, smallFont, viewModel, gridRect, textureCache, assetCache)) { return false; } - } else if (!viewModel.bodyLines.empty()) { - if (!render_apps_empty_state(renderer, smallFont, viewModel, gridRect)) { - return false; - } + } else if (!viewModel.bodyLines.empty() && !render_apps_empty_state(renderer, smallFont, viewModel, gridRect)) { + return false; } } else { const bool settingsScreen = viewModel.screen == app::ScreenId::settings; @@ -3443,60 +3508,18 @@ namespace { } if (hasDetailMenu) { - 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))) { + if (!render_settings_detail_panel(renderer, bodyFont, smallFont, viewModel, bodyPanel, panelPadding)) { return false; } - if (!render_action_rows( + } else { + if (!render_body_lines( renderer, bodyFont, - viewModel.detailMenuRows, - optionsRect, - std::max(34, TTF_FontLineSkip(bodyFont) + 12) + viewModel.bodyLines, + {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyPanel.y + panelPadding, bodyPanel.w - (panelPadding * 2), 8} )) { 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.selectedMenuRowLabel.empty()) { - int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, viewModel.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.selectedMenuRowDescription.empty() ? std::string("No description is available for the selected setting.") : viewModel.selectedMenuRowDescription; - if (!render_text_line(renderer, smallFont, descriptionText, {MUTED_RED, MUTED_GREEN, MUTED_BLUE, 0xFF}, descriptionRect.x + 10, descriptionY, descriptionRect.w - 20)) { - return false; - } - } else { - if (!render_body_lines(renderer, bodyFont, viewModel.bodyLines, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyPanel.y + panelPadding, bodyPanel.w - (panelPadding * 2), 8)) { - return false; - } } } @@ -3679,11 +3702,11 @@ namespace { } } - bool should_open_added_controller(SDL_GameController *controller, const SDL_ControllerDeviceEvent &event) { + 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(SDL_GameController *controller, const SDL_ControllerDeviceEvent &event) { + bool should_close_removed_controller(const SDL_GameController *controller, const SDL_ControllerDeviceEvent &event) { return controller != nullptr && controller == SDL_GameControllerFromInstanceID(event.which); } @@ -3710,6 +3733,306 @@ namespace { 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.shouldExit = true; + logging::info("app", "Exit requested from held Start+Back on the hosts screen"); + } + + void process_log_viewer_repeat_commands( + const app::ClientState &state, + Uint32 now, + ShellInputState *inputState, + const std::function &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.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; + } + } + + void process_polled_shell_events( + app::ClientState &state, + SDL_GameController **controller, + ShellInputState *inputState, + const std::function &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)); + } + + void process_controller_navigation( + SDL_GameController *controller, + ShellInputState *inputState, + bool skipPolledControllerNavigation, + const std::function &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 + )); + } + } // namespace namespace ui { @@ -3776,26 +4099,8 @@ namespace ui { } bool running = true; - 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 = 0; - Uint32 controllerBackDownTick = 0; + ShellInputState inputState {}; Uint32 nextHostProbeTick = 0; - Uint32 leftShoulderRepeatTick = 0; - Uint32 rightShoulderRepeatTick = 0; - Uint32 leftTriggerRepeatTick = 0; - Uint32 rightTriggerRepeatTick = 0; - ControllerNavigationHoldState moveUpHoldState {}; - ControllerNavigationHoldState moveDownHoldState {}; - ControllerNavigationHoldState moveLeftHoldState {}; - ControllerNavigationHoldState moveRightHoldState {}; - bool controllerNavigationNeutralRequired = false; PairingTaskState pairingTask {}; AppListTaskState appListTask {}; AppArtTaskState appArtTask {}; @@ -3874,168 +4179,12 @@ namespace ui { start_app_list_task_if_needed(state, &appListTask, SDL_GetTicks()); start_app_art_task_if_needed(state, &appArtTask); - if (!controllerExitComboTriggered && controllerStartPressed && controllerBackPressed && hosts_screen_exit_combo_allowed(state)) { - controllerExitComboArmed = true; - const Uint32 comboStartTick = controllerStartDownTick > controllerBackDownTick ? controllerStartDownTick : controllerBackDownTick; - if (SDL_GetTicks() - comboStartTick >= EXIT_COMBO_HOLD_MILLISECONDS) { - controllerExitComboTriggered = true; - state.shouldExit = true; - logging::info("app", "Exit requested from held Start+Back on the hosts screen"); - } - } - - if (state.modal.id == app::ModalId::log_viewer) { - const Uint32 now = SDL_GetTicks(); - if (leftShoulderPressed && now - leftShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { - leftShoulderRepeatTick = now; - process_command(input::UiCommand::previous_page); - } - if (rightShoulderPressed && now - rightShoulderRepeatTick >= LOG_VIEWER_SCROLL_REPEAT_MILLISECONDS) { - rightShoulderRepeatTick = now; - process_command(input::UiCommand::next_page); - } - if (leftTriggerPressed && now - leftTriggerRepeatTick >= LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS) { - leftTriggerRepeatTick = now; - process_command(input::UiCommand::fast_previous_page); - } - if (rightTriggerPressed && now - rightTriggerRepeatTick >= LOG_VIEWER_FAST_SCROLL_REPEAT_MILLISECONDS) { - rightTriggerRepeatTick = now; - process_command(input::UiCommand::fast_next_page); - } - } + update_exit_combo_hold(state, &inputState); + process_log_viewer_repeat_commands(state, SDL_GetTicks(), &inputState, process_command); - SDL_Event event; bool skipPolledControllerNavigation = false; - if (SDL_WaitEventTimeout(&event, SHELL_EVENT_WAIT_TIMEOUT_MILLISECONDS) != 0) { - do { - input::UiCommand command = input::UiCommand::none; - - switch (event.type) { - case SDL_QUIT: - state.shouldExit = true; - break; - case SDL_CONTROLLERDEVICEADDED: - if (should_open_added_controller(controller, event.cdevice)) { - controller = SDL_GameControllerOpen(event.cdevice.which); - if (controller != nullptr) { - logging::info("input", "Controller connected"); - } - } - break; - case SDL_CONTROLLERDEVICEREMOVED: - if (should_close_removed_controller(controller, event.cdevice)) { - close_controller(controller); - controller = nullptr; - leftTriggerPressed = false; - rightTriggerPressed = false; - leftShoulderPressed = false; - rightShoulderPressed = false; - controllerStartPressed = false; - controllerBackPressed = false; - controllerExitComboArmed = false; - controllerExitComboTriggered = false; - controllerNavigationNeutralRequired = false; - reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); - logging::warn("input", "Controller disconnected"); - } - break; - case SDL_CONTROLLERBUTTONDOWN: - { - const Uint32 controllerButtonDownTick = SDL_GetTicks(); - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START) { - if (!controllerStartPressed) { - controllerStartPressed = true; - controllerStartDownTick = controllerButtonDownTick; - } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK) { - if (!controllerBackPressed) { - controllerBackPressed = true; - controllerBackDownTick = controllerButtonDownTick; - } - } else { - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { - leftShoulderPressed = true; - leftShoulderRepeatTick = controllerButtonDownTick; - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { - rightShoulderPressed = true; - rightShoulderRepeatTick = controllerButtonDownTick; - } - - command = translate_controller_button(event.cbutton.button); - if (is_navigation_command(command)) { - seed_controller_navigation_hold_state( - controllerButtonDownTick, - command, - &moveUpHoldState, - &moveDownHoldState, - &moveLeftHoldState, - &moveRightHoldState - ); - } - } - - if (controllerStartPressed && controllerBackPressed && hosts_screen_exit_combo_allowed(state)) { - controllerExitComboArmed = true; - } - break; - } - case SDL_CONTROLLERBUTTONUP: - if (event.cbutton.button == SDL_CONTROLLER_BUTTON_START && controllerStartPressed) { - controllerStartPressed = false; - if (!controllerExitComboArmed && !controllerExitComboTriggered) { - command = input::map_gamepad_button_to_ui_command(input::GamepadButton::start); - } - if (!controllerStartPressed && !controllerBackPressed) { - controllerExitComboArmed = false; - controllerExitComboTriggered = false; - } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_BACK && controllerBackPressed) { - controllerBackPressed = false; - if (!controllerExitComboArmed && !controllerExitComboTriggered) { - command = input::map_gamepad_button_to_ui_command(input::GamepadButton::back); - } - if (!controllerStartPressed && !controllerBackPressed) { - controllerExitComboArmed = false; - controllerExitComboTriggered = false; - } - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_LEFTSHOULDER) { - leftShoulderPressed = false; - } else if (event.cbutton.button == SDL_CONTROLLER_BUTTON_RIGHTSHOULDER) { - rightShoulderPressed = false; - } - release_controller_navigation_hold_state(translate_controller_button(event.cbutton.button), &moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); - break; - case SDL_CONTROLLERAXISMOTION: - command = translate_trigger_axis(event.caxis, &leftTriggerPressed, &rightTriggerPressed); - update_trigger_repeat_tick(command, SDL_GetTicks(), &leftTriggerRepeatTick, &rightTriggerRepeatTick); - break; - case SDL_KEYDOWN: - command = translate_unrepeated_keydown(event.key); - break; - default: - break; - } - - process_command(command); - if (command != input::UiCommand::none && !is_navigation_command(command)) { - controllerNavigationNeutralRequired = true; - skipPolledControllerNavigation = true; - reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); - } - } while (SDL_PollEvent(&event)); - } - - if (controllerNavigationNeutralRequired) { - if (is_controller_navigation_active(controller)) { - reset_controller_navigation_hold_states(&moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState); - } else { - controllerNavigationNeutralRequired = false; - } - } - - if (!skipPolledControllerNavigation && !controllerNavigationNeutralRequired) { - process_command(poll_controller_navigation(controller, SDL_GetTicks(), &moveUpHoldState, &moveDownHoldState, &moveLeftHoldState, &moveRightHoldState)); - } + process_polled_shell_events(state, &controller, &inputState, process_command, &skipPolledControllerNavigation); + process_controller_navigation(controller, &inputState, skipPolledControllerNavigation, process_command); finish_pairing_task_if_ready(state, &pairingTask); finish_app_list_task_if_ready(state, &appListTask); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 10c0b37..059dd40 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,10 @@ target_link_libraries(${PROJECT_NAME} PRIVATE 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() diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index bedc912..67c9c71 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -4,6 +4,7 @@ // standard includes #include #include +#include // lib includes #include @@ -13,7 +14,7 @@ namespace { - void write_text_file(const std::string &path, const std::string &content) { + 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()); From 1e6c2886a87e5fe305618779a476a95db43d321d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:01:35 -0400 Subject: [PATCH 21/35] style: sonar fixes --- src/logging/logger.cpp | 18 +- src/ui/shell_screen.cpp | 558 +++++++++++++++++++++++++++------------- 2 files changed, 390 insertions(+), 186 deletions(-) diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index a6d41dd..63859fe 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -21,22 +21,16 @@ namespace { - logging::Logger *&global_logger_slot() { - static logging::Logger *globalLogger = nullptr; - return globalLogger; - } + inline logging::Logger *globalLogger = nullptr; - bool &startup_console_enabled_slot() { - static bool startupConsoleEnabled = true; - return startupConsoleEnabled; - } + inline bool startupConsoleEnabled = true; bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { return static_cast(candidateLevel) >= static_cast(minimumLevel); } logging::Logger *registered_logger() { - return global_logger_slot(); + return globalLogger; } logging::LogLevel startup_console_level(logging::LogLevel level) { @@ -195,7 +189,7 @@ namespace logging { } void set_global_logger(Logger *logger) { - global_logger_slot() = logger; + globalLogger = logger; } bool has_global_logger() { @@ -279,11 +273,11 @@ namespace logging { } void set_startup_console_enabled(bool enabled) { - startup_console_enabled_slot() = enabled; + startupConsoleEnabled = enabled; } bool startup_console_enabled() { - return startup_console_enabled_slot(); + return startupConsoleEnabled; } void print_startup_console_line(LogLevel level, std::string_view category, std::string_view message) { diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 03b98d2..898f540 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -2567,7 +2567,7 @@ namespace { task->request.identity = std::move(identity); task->result = network::pair_host(task->request, &task->cancelRequested); - task->completed.store(true, std::memory_order_release); + task->completed.store(true); return 0; } @@ -2700,7 +2700,7 @@ namespace { worker->serverInfo, } ); - worker->completed.store(true, std::memory_order_release); + worker->completed.store(true); return 0; } @@ -2734,7 +2734,7 @@ namespace { auto iterator = task->workers.begin(); while (iterator != task->workers.end()) { - if ((*iterator)->thread == nullptr || !(*iterator)->completed.load(std::memory_order_acquire)) { + if ((*iterator)->thread == nullptr || !(*iterator)->completed.load()) { ++iterator; continue; } @@ -3226,7 +3226,7 @@ namespace { const ui::ShellViewModel &viewModel, const SDL_Rect &gridRect, CoverArtTextureCache *textureCache, - AssetTextureCache *assetCache + const AssetTextureCache *assetCache ) { const int columnCount = std::max(1, static_cast(viewModel.appColumnCount)); const int tileGap = 16; @@ -3791,11 +3791,12 @@ namespace { 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 std::function &processCommand + const ProcessCommand &processCommand ) { if (inputState == nullptr || state.modal.id != app::ModalId::log_viewer) { return; @@ -3957,11 +3958,12 @@ namespace { } } + template void process_polled_shell_events( app::ClientState &state, SDL_GameController **controller, ShellInputState *inputState, - const std::function &processCommand, + const ProcessCommand &processCommand, bool *skipPolledControllerNavigation ) { SDL_Event event; @@ -3996,11 +3998,12 @@ namespace { } while (SDL_PollEvent(&event)); } + template void process_controller_navigation( SDL_GameController *controller, ShellInputState *inputState, bool skipPolledControllerNavigation, - const std::function &processCommand + const ProcessCommand &processCommand ) { if (inputState == nullptr) { return; @@ -4033,216 +4036,423 @@ namespace { )); } -} // namespace + /** + * @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. + SDL_Texture *titleLogoTexture = nullptr; ///< Cached Moonlight title logo texture. + 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. + }; -namespace ui { + /** + * @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. + }; - 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"); + /** + * @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); + destroy_texture(resources->titleLogoTexture); + resources->titleLogoTexture = nullptr; + + 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) { - return report_shell_failure("ttf", std::string("TTF_Init failed: ") + TTF_GetError()); + 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"); - SDL_Renderer *renderer = SDL_CreateRenderer(window, -1, 0); - if (renderer == nullptr) { - IMG_Quit(); - TTF_Quit(); - return report_shell_failure("sdl", std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); + 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(renderer, SDL_BLENDMODE_BLEND); - SDL_SetRenderDrawColor(renderer, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0xFF); - SDL_RenderClear(renderer); - SDL_RenderPresent(renderer); + + 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"); - TTF_Font *titleFont = TTF_OpenFont(fontPath.c_str(), std::max(24, videoMode.height / 16)); - TTF_Font *bodyFont = TTF_OpenFont(fontPath.c_str(), std::max(18, videoMode.height / 24)); - TTF_Font *smallFont = TTF_OpenFont(fontPath.c_str(), std::max(14, videoMode.height / 34)); - if (titleFont == nullptr || bodyFont == nullptr || smallFont == nullptr) { - if (titleFont != nullptr) { - TTF_CloseFont(titleFont); - } - if (bodyFont != nullptr) { - TTF_CloseFont(bodyFont); - } - if (smallFont != nullptr) { - TTF_CloseFont(smallFont); - } - SDL_DestroyRenderer(renderer); - IMG_Quit(); - TTF_Quit(); - return report_shell_failure("ttf", std::string("Failed to load shell font from ") + fontPath + ": " + TTF_GetError()); + 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; } - SDL_Texture *titleLogoTexture = load_texture_from_asset(renderer, "moonlight-logo.svg"); + resources->titleLogoTexture = load_texture_from_asset(resources->renderer, "moonlight-logo.svg"); + resources->controller = open_primary_controller(); + resources->encoderSettings = XVideoGetEncoderSettings(); + return true; + } - SDL_GameController *controller = nullptr; - for (int joystickIndex = 0; joystickIndex < SDL_NumJoysticks(); ++joystickIndex) { - if (SDL_IsGameController(joystickIndex)) { - controller = SDL_GameControllerOpen(joystickIndex); - if (controller != nullptr) { - logging::info("input", "Opened primary controller"); - break; - } - } + /** + * @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(app::ClientState &state, ShellRuntimeState *runtime) { + if (runtime == nullptr) { + return; } - bool running = true; - ShellInputState inputState {}; - Uint32 nextHostProbeTick = 0; - PairingTaskState pairingTask {}; - AppListTaskState appListTask {}; - AppArtTaskState appArtTask {}; - HostProbeTaskState hostProbeTask {}; - CoverArtTextureCache coverArtTextureCache {}; - AssetTextureCache assetTextureCache {}; - KeypadModalLayoutCache keypadModalLayoutCache {}; - const unsigned long encoderSettings = XVideoGetEncoderSettings(); - reset_pairing_task(&pairingTask); - reset_app_list_task(&appListTask); - reset_app_art_task(&appArtTask); - reset_host_probe_task(&hostProbeTask); + 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.loggingLevel); logging::set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); logging::info("app", "Entered interactive shell"); - bool keypadRedrawRequested = true; + } - const auto draw_current_shell = [&]() { - const std::vector retainedEntries = logging::snapshot(logging::LogLevel::info); - if (const auto viewModel = build_shell_view_model(state, retainedEntries); draw_shell(renderer, videoMode, encoderSettings, titleLogoTexture, titleFont, bodyFont, smallFont, viewModel, &coverArtTextureCache, &assetTextureCache, &keypadModalLayoutCache)) { - keypadRedrawRequested = false; - return true; - } + /** + * @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; + } - report_shell_failure("render", std::string("Shell render failed: ") + SDL_GetError()); - running = false; - state.shouldExit = true; + 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->titleLogoTexture, 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.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.activeScreen; + const app::AppUpdate update = app::handle_command(state, command); + logging::set_file_minimum_level(state.loggingLevel); + logging::set_debugger_console_minimum_level(state.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.activeScreen) { + release_page_resources_for_screen(previousScreen, state.activeScreen, &resources->coverArtTextureCache, &resources->keypadModalLayoutCache); + ensure_hosts_loaded_for_active_screen(state); + } + if ((previousScreen != state.activeScreen || update.screenChanged) && !draw_current_shell_frame(videoMode, state, resources, runtime)) { + return; + } + if (state.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); }; - const auto process_command = [&](input::UiCommand command) { // NOSONAR(cpp:S1188) inline command pipeline keeps one-frame side effects adjacent to the shell loop - if (command == input::UiCommand::none) { - return; - } + 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()); - keypadRedrawRequested = true; - - const app::ScreenId previousScreen = state.activeScreen; - const app::AppUpdate update = app::handle_command(state, command); - logging::set_file_minimum_level(state.loggingLevel); - logging::set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); - log_app_update(state, update); - show_log_file_if_requested(state, update); - cancel_pairing_if_requested(state, update, &pairingTask); - test_host_connection_if_requested(state, update); - browse_host_apps_if_requested(state, update); - pair_host_if_requested(state, update, &pairingTask); - delete_host_data_if_requested(state, update, &coverArtTextureCache); - delete_saved_file_if_requested(state, update, &coverArtTextureCache); - factory_reset_if_requested(state, update, &coverArtTextureCache); - refresh_saved_files_if_needed(state); - persist_settings_if_needed(state, update); - persist_hosts_if_needed(state, update); - - if (previousScreen != state.activeScreen) { - release_page_resources_for_screen(previousScreen, state.activeScreen, &coverArtTextureCache, &keypadModalLayoutCache); - ensure_hosts_loaded_for_active_screen(state); - } - if ((previousScreen != state.activeScreen || update.screenChanged) && !draw_current_shell()) { - return; - } - if (state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { - clear_keypad_modal_layout_cache(&keypadModalLayoutCache); - } - }; + update_exit_combo_hold(state, &runtime->inputState); + process_log_viewer_repeat_commands(state, SDL_GetTicks(), &runtime->inputState, processCommand); - while (running && !state.shouldExit) { - ensure_hosts_loaded_for_active_screen(state); - finish_pairing_task_if_ready(state, &pairingTask); - finish_app_list_task_if_ready(state, &appListTask); - finish_app_art_task_if_ready(state, &appArtTask, &coverArtTextureCache); - finish_host_probe_task_if_ready(state, &hostProbeTask); - refresh_saved_files_if_needed(state); - start_host_probe_task_if_needed(state, &hostProbeTask, SDL_GetTicks(), &nextHostProbeTick); - start_app_list_task_if_needed(state, &appListTask, SDL_GetTicks()); - start_app_art_task_if_needed(state, &appArtTask); - - update_exit_combo_hold(state, &inputState); - process_log_viewer_repeat_commands(state, SDL_GetTicks(), &inputState, process_command); - - bool skipPolledControllerNavigation = false; - process_polled_shell_events(state, &controller, &inputState, process_command, &skipPolledControllerNavigation); - process_controller_navigation(controller, &inputState, skipPolledControllerNavigation, process_command); - - finish_pairing_task_if_ready(state, &pairingTask); - finish_app_list_task_if_ready(state, &appListTask); - finish_app_art_task_if_ready(state, &appArtTask, &coverArtTextureCache); - finish_host_probe_task_if_ready(state, &hostProbeTask); - const Uint32 backgroundTaskTick = SDL_GetTicks(); - start_host_probe_task_if_needed(state, &hostProbeTask, backgroundTaskTick, &nextHostProbeTick); - start_app_list_task_if_needed(state, &appListTask, backgroundTaskTick); - start_app_art_task_if_needed(state, &appArtTask); - - if ((state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || keypadRedrawRequested) && !draw_current_shell()) { - break; - } + 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.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && + !draw_current_shell_frame(videoMode, state, resources, runtime)) { + return false; } - if (pairingTask.activeAttempt != nullptr) { - pairingTask.activeAttempt->discardResult.store(true); - finalize_pairing_attempt(nullptr, std::move(pairingTask.activeAttempt)); + return runtime->running && !state.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 (!pairingTask.retiredAttempts.empty()) { - std::unique_ptr attempt = std::move(pairingTask.retiredAttempts.back()); - pairingTask.retiredAttempts.pop_back(); + 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)); } - if (appListTask.thread != nullptr) { - int threadResult = 0; - SDL_WaitThread(appListTask.thread, &threadResult); - (void) threadResult; - } - if (appArtTask.thread != nullptr) { - int threadResult = 0; - SDL_WaitThread(appArtTask.thread, &threadResult); - (void) threadResult; - } - for (const std::unique_ptr &worker : hostProbeTask.workers) { - if (worker == nullptr || worker->thread == nullptr) { + + 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; } - int threadResult = 0; - SDL_WaitThread(worker->thread, &threadResult); - (void) threadResult; + 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 {}; + ShellInitializationFailure initializationFailure {}; + if (!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.shouldExit) { + if (!run_shell_frame(videoMode, state, &resources, &runtime)) { + break; + } } - close_controller(controller); - clear_cover_art_texture_cache(&coverArtTextureCache); - clear_asset_texture_cache(&assetTextureCache); - clear_keypad_modal_layout_cache(&keypadModalLayoutCache); - destroy_texture(titleLogoTexture); - TTF_CloseFont(smallFont); - TTF_CloseFont(bodyFont); - TTF_CloseFont(titleFont); - SDL_DestroyRenderer(renderer); - IMG_Quit(); - TTF_Quit(); + finalize_shell_tasks(&runtime); + close_shell_resources(&resources); return 0; } From 4b77de80d78e5487c58a97948519d2bf9db45bc8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 12 Apr 2026 16:02:51 -0400 Subject: [PATCH 22/35] Refactor ClientState into nested structs Group related ClientState fields into nested substructures (hosts, apps, shell, settings, persistence, navigation, requests) and update all usages accordingly. Moves host list into hosts.items and renames numerous fields (e.g. activeHost -> hosts.active, activeScreen -> shell.activeScreen, statusMessage -> shell.statusMessage, hostsDirty -> hosts.dirty, logViewer* and savedFiles into settings.*, pairingResetEndpoints into hosts.pairingResetEndpoints). Also adapts AppUpdate fields to new namespaces (navigation/requests/persistence) and updates related logic and tests to match the new layout. --- src/app/client_state.cpp | 630 +++++++++++++-------------- src/app/client_state.h | 270 +++++++++--- src/main.cpp | 22 +- src/ui/shell_screen.cpp | 422 +++++++++--------- src/ui/shell_view.cpp | 190 ++++---- src/ui/shell_view.h | 99 +++-- tests/unit/app/client_state_test.cpp | 368 ++++++++-------- tests/unit/ui/shell_view_test.cpp | 332 +++++++------- 8 files changed, 1274 insertions(+), 1059 deletions(-) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index 35d7c05..aa50959 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -125,8 +125,8 @@ namespace { return; } - if (std::find(state.pairingResetEndpoints.begin(), state.pairingResetEndpoints.end(), key) == state.pairingResetEndpoints.end()) { - state.pairingResetEndpoints.push_back(key); + if (std::find(state.hosts.pairingResetEndpoints.begin(), state.hosts.pairingResetEndpoints.end(), key) == state.hosts.pairingResetEndpoints.end()) { + state.hosts.pairingResetEndpoints.push_back(key); } } @@ -136,73 +136,73 @@ namespace { return; } - state.pairingResetEndpoints.erase( - std::remove(state.pairingResetEndpoints.begin(), state.pairingResetEndpoints.end(), key), - state.pairingResetEndpoints.end() + 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.selectedHostAddress = host.address; - state.selectedHostPort = host.port; + state.hosts.selectedAddress = host.address; + state.hosts.selectedPort = host.port; } void clear_active_host(app::ClientState &state) { - state.activeHost = {}; - state.activeHostLoaded = false; + state.hosts.active = {}; + state.hosts.activeLoaded = false; } void clear_active_host_app_list(app::ClientState &state) { - if (!state.activeHostLoaded) { + if (!state.hosts.activeLoaded) { return; } - state.activeHost.apps.clear(); - state.activeHost.appListState = app::HostAppListState::idle; - state.activeHost.appListStatusMessage.clear(); - state.activeHost.appListContentHash = 0U; - state.activeHost.lastAppListRefreshTick = 0U; - state.activeHost.runningGameId = 0U; - state.selectedAppIndex = 0U; - state.appsScrollPage = 0U; - state.showHiddenApps = false; + 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.activeHost = host; - state.activeHostLoaded = true; + state.hosts.active = host; + state.hosts.activeLoaded = true; remember_host_selection(state, host); } void unload_hosts_page_state(app::ClientState &state) { - if (!state.hostsLoaded) { + if (!state.hosts.loaded) { return; } - if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { - remember_host_selection(state, state.hosts[state.selectedHostIndex]); + if (!state.hosts.items.empty() && state.hosts.selectedHostIndex < state.hosts.items.size()) { + remember_host_selection(state, state.hosts.items[state.hosts.selectedHostIndex]); } - state.hosts.clear(); - state.hostsLoaded = false; - state.selectedHostIndex = 0U; - state.hostsFocusArea = app::HostsFocusArea::toolbar; + 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.activeHostLoaded) { - remember_host_selection(state, state.activeHost); + 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.savedFiles.clear(); - state.savedFilesDirty = true; - state.logViewerLines.clear(); - state.logViewerScrollOffset = 0U; + 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) { @@ -210,11 +210,11 @@ namespace { } void unload_screen_state(app::ClientState &state, app::ScreenId nextScreen) { - if (state.activeScreen == nextScreen) { + if (state.shell.activeScreen == nextScreen) { return; } - switch (state.activeScreen) { + 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) { @@ -238,7 +238,7 @@ namespace { void sync_selected_settings_category_from_menu(app::ClientState &state) { if (const ui::MenuItem *selectedItem = state.menu.selected_item(); selectedItem != nullptr) { - state.selectedSettingsCategory = settings_category_from_menu_id(selectedItem->id); + state.settings.selectedCategory = settings_category_from_menu_id(selectedItem->id); } } @@ -286,11 +286,11 @@ namespace { } 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, address, port); host != nullptr) { + if (app::HostRecord *host = find_host_by_endpoint(state.hosts.items, address, port); host != nullptr) { return host; } - if (state.activeHostLoaded && app::host_matches_endpoint(state.activeHost, address, port)) { - return &state.activeHost; + if (state.hosts.activeLoaded && app::host_matches_endpoint(state.hosts.active, address, port)) { + return &state.hosts.active; } return nullptr; } @@ -337,50 +337,50 @@ namespace { } void clamp_selected_host_index(app::ClientState &state) { - if (state.hosts.empty()) { - state.selectedHostIndex = 0U; - state.hostsFocusArea = app::HostsFocusArea::toolbar; - state.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX; + 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.selectedHostIndex >= state.hosts.size()) { - state.selectedHostIndex = state.hosts.size() - 1U; + 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.empty()) { - state.hostsFocusArea = app::HostsFocusArea::toolbar; - state.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX; - state.selectedHostIndex = 0U; + 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.hostsFocusArea = app::HostsFocusArea::grid; - state.selectedHostIndex = 0U; + 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.selectedAppIndex = 0U; + state.apps.selectedAppIndex = 0U; return; } - const std::vector indices = visible_app_indices(*host, state.showHiddenApps); + const std::vector indices = visible_app_indices(*host, state.apps.showHiddenApps); if (indices.empty()) { - state.selectedAppIndex = 0U; + state.apps.selectedAppIndex = 0U; return; } - if (state.selectedAppIndex >= indices.size()) { - state.selectedAppIndex = indices.size() - 1U; + if (state.apps.selectedAppIndex >= indices.size()) { + state.apps.selectedAppIndex = indices.size() - 1U; } } std::vector build_menu_for_state(const app::ClientState &state) { - switch (state.activeScreen) { + switch (state.shell.activeScreen) { case app::ScreenId::settings: return { {settings_category_menu_id(app::SettingsCategory::logging), "Logging", settings_category_description(app::SettingsCategory::logging), true}, @@ -411,16 +411,16 @@ namespace { } std::vector build_detail_menu_for_state(const app::ClientState &state) { - if (state.activeScreen != app::ScreenId::settings) { + if (state.shell.activeScreen != app::ScreenId::settings) { return {}; } - switch (state.selectedSettingsCategory) { + 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.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.xemuConsoleLoggingLevel), "Choose the minimum severity mirrored to xemu through DbgPrint() when you launch xemu with a serial console.", 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 { @@ -435,7 +435,7 @@ namespace { 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.savedFiles) { + 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; @@ -483,10 +483,10 @@ namespace { void set_screen(app::ClientState &state, app::ScreenId screen, const std::string &preferredItemId = {}) { unload_screen_state(state, screen); - state.activeScreen = screen; + state.shell.activeScreen = screen; if (screen == app::ScreenId::settings) { - state.savedFilesDirty = true; - state.settingsFocusArea = app::SettingsFocusArea::categories; + state.settings.savedFilesDirty = true; + state.settings.focusArea = app::SettingsFocusArea::categories; } close_modal(state); rebuild_menu(state, preferredItemId, false); @@ -504,32 +504,32 @@ namespace { } void cycle_log_viewer_placement(app::ClientState &state) { - switch (state.logViewerPlacement) { + switch (state.settings.logViewerPlacement) { case app::LogViewerPlacement::full: - state.logViewerPlacement = app::LogViewerPlacement::left; + state.settings.logViewerPlacement = app::LogViewerPlacement::left; return; case app::LogViewerPlacement::left: - state.logViewerPlacement = app::LogViewerPlacement::right; + state.settings.logViewerPlacement = app::LogViewerPlacement::right; return; case app::LogViewerPlacement::right: - state.logViewerPlacement = app::LogViewerPlacement::full; + state.settings.logViewerPlacement = app::LogViewerPlacement::full; return; } } void scroll_log_viewer(app::ClientState &state, bool towardOlderEntries, std::size_t step) { - if (state.logViewerLines.empty() || step == 0U) { - state.logViewerScrollOffset = 0U; + if (state.settings.logViewerLines.empty() || step == 0U) { + state.settings.logViewerScrollOffset = 0U; return; } - const std::size_t maxOffset = state.logViewerLines.size() > 1U ? state.logViewerLines.size() - 1U : 0U; + const std::size_t maxOffset = state.settings.logViewerLines.size() > 1U ? state.settings.logViewerLines.size() - 1U : 0U; if (towardOlderEntries) { - state.logViewerScrollOffset = std::min(maxOffset, state.logViewerScrollOffset + step); + state.settings.logViewerScrollOffset = std::min(maxOffset, state.settings.logViewerScrollOffset + step); return; } - state.logViewerScrollOffset = state.logViewerScrollOffset > step ? state.logViewerScrollOffset - step : 0U; + state.settings.logViewerScrollOffset = state.settings.logViewerScrollOffset > step ? state.settings.logViewerScrollOffset - step : 0U; } std::size_t modal_action_count(const app::ClientState &state) { @@ -566,7 +566,7 @@ namespace { 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.statusMessage = field == app::AddHostField::address ? "Editing host address" : "Editing host port"; + state.shell.statusMessage = field == app::AddHostField::address ? "Editing host address" : "Editing host port"; rebuild_menu(state, add_host_field_menu_id(field)); } @@ -579,10 +579,10 @@ namespace { void accept_add_host_keypad(app::ClientState &state) { if (state.addHostDraft.activeField == app::AddHostField::address) { state.addHostDraft.addressInput = state.addHostDraft.keypad.stagedInput; - state.statusMessage = "Updated host address"; + state.shell.statusMessage = "Updated host address"; } else { state.addHostDraft.portInput = state.addHostDraft.keypad.stagedInput; - state.statusMessage = state.addHostDraft.portInput.empty() ? "Using default Moonlight host port 47989" : "Updated host port"; + state.shell.statusMessage = state.addHostDraft.portInput.empty() ? "Using default Moonlight host port 47989" : "Updated host port"; } state.addHostDraft.validationMessage.clear(); @@ -591,7 +591,7 @@ namespace { } void cancel_add_host_keypad(app::ClientState &state) { - state.statusMessage = state.addHostDraft.activeField == app::AddHostField::address ? "Cancelled host address edit" : "Cancelled host port edit"; + state.shell.statusMessage = state.addHostDraft.activeField == app::AddHostField::address ? "Cancelled host address edit" : "Cancelled host port edit"; close_add_host_keypad(state); } @@ -699,8 +699,8 @@ namespace { } void move_toolbar_selection(app::ClientState &state, int direction) { - const std::size_t current = state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT; - state.selectedToolbarButtonIndex = direction < 0 ? (current + HOST_TOOLBAR_BUTTON_COUNT - 1U) % HOST_TOOLBAR_BUTTON_COUNT : (current + 1U) % HOST_TOOLBAR_BUTTON_COUNT; + 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) { @@ -791,44 +791,44 @@ namespace { } void move_host_grid_selection(app::ClientState &state, int rowDelta, int columnDelta) { - if (state.hosts.empty()) { - state.hostsFocusArea = app::HostsFocusArea::toolbar; + if (state.hosts.items.empty()) { + state.hosts.focusArea = app::HostsFocusArea::toolbar; return; } bool movedAboveFirstRow = false; - move_grid_selection(state.hosts.size(), HOST_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.selectedHostIndex, &movedAboveFirstRow); + move_grid_selection(state.hosts.items.size(), HOST_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.hosts.selectedHostIndex, &movedAboveFirstRow); if (movedAboveFirstRow) { - state.hostsFocusArea = app::HostsFocusArea::toolbar; + state.hosts.focusArea = app::HostsFocusArea::toolbar; return; } - state.hostsFocusArea = app::HostsFocusArea::grid; + 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.selectedAppIndex = 0U; + state.apps.selectedAppIndex = 0U; return; } - const std::vector indices = visible_app_indices(*host, state.showHiddenApps); + const std::vector indices = visible_app_indices(*host, state.apps.showHiddenApps); if (indices.empty()) { - state.selectedAppIndex = 0U; + state.apps.selectedAppIndex = 0U; return; } - move_grid_selection(indices.size(), APP_GRID_COLUMN_COUNT, rowDelta, columnDelta, &state.selectedAppIndex); + 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.activeScreen == app::ScreenId::add_host ? app::ScreenId::hosts : state.activeScreen); + 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.statusMessage = "Host is offline. Bring it online before pairing."; + state.shell.statusMessage = "Host is offline. Bring it online before pairing."; return false; } @@ -838,7 +838,7 @@ namespace { std::string pairingPin; if (std::string pinError; !network::generate_pairing_pin(&pairingPin, &pinError)) { - state.statusMessage = pinError.empty() ? "Failed to generate a secure pairing PIN." : std::move(pinError); + state.shell.statusMessage = pinError.empty() ? "Failed to generate a secure pairing PIN." : std::move(pinError); return false; } @@ -848,36 +848,36 @@ namespace { } bool enter_apps_screen(app::ClientState &state, bool showHiddenApps) { - const app::HostRecord *host = state.hosts.empty() ? nullptr : &state.hosts[state.selectedHostIndex]; + 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.statusMessage = "Host is offline. Bring it online before opening apps."; + state.shell.statusMessage = "Host is offline. Bring it online before opening apps."; return false; } if (host->pairingState != app::PairingState::paired) { - state.statusMessage = "This host is no longer paired. Pair it again before opening apps."; + 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.showHiddenApps = showHiddenApps; - state.selectedAppIndex = 0U; - state.appsScrollPage = 0U; - state.activeHost.appListState = app::HostAppListState::loading; - state.activeHost.appListStatusMessage = (state.activeHost.apps.empty() ? "Loading apps for " : "Refreshing apps for ") + state.activeHost.displayName + "..."; - state.statusMessage.clear(); + 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.size(); ++index) { - if (app::host_matches_endpoint(state.hosts[index], address, port)) { - state.selectedHostIndex = index; - state.hostsFocusArea = app::HostsFocusArea::grid; - remember_host_selection(state, state.hosts[index]); + 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; } } @@ -910,7 +910,7 @@ namespace { void close_modal_and_mark_closed(app::ClientState &state, app::AppUpdate *update) { close_modal(state); if (update != nullptr) { - update->modalClosed = true; + update->navigation.modalClosed = true; } } @@ -925,11 +925,11 @@ namespace { return; } - update->screenChanged = true; - update->pairingRequested = true; - update->pairingAddress = state.pairingDraft.targetAddress; - update->pairingPort = state.pairingDraft.targetPort; - update->pairingPin = state.pairingDraft.generatedPin; + 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; } /** @@ -962,10 +962,10 @@ namespace { if (appRecord.boxArtCacheKey.empty()) { continue; } - if (std::find(update->deletedHostCoverArtCacheKeys.begin(), update->deletedHostCoverArtCacheKeys.end(), appRecord.boxArtCacheKey) != update->deletedHostCoverArtCacheKeys.end()) { + if (std::find(update->persistence.deletedHostCoverArtCacheKeys.begin(), update->persistence.deletedHostCoverArtCacheKeys.end(), appRecord.boxArtCacheKey) != update->persistence.deletedHostCoverArtCacheKeys.end()) { continue; } - update->deletedHostCoverArtCacheKeys.push_back(appRecord.boxArtCacheKey); + update->persistence.deletedHostCoverArtCacheKeys.push_back(appRecord.boxArtCacheKey); } } @@ -976,23 +976,23 @@ namespace { * @param update Update structure that receives deletion work. */ void delete_selected_host(app::ClientState &state, app::AppUpdate *update) { - if (update == nullptr || state.selectedHostIndex >= state.hosts.size()) { + if (update == nullptr || state.hosts.selectedHostIndex >= state.hosts.items.size()) { return; } - const app::HostRecord deletedHost = state.hosts[state.selectedHostIndex]; + const app::HostRecord deletedHost = state.hosts.items[state.hosts.selectedHostIndex]; remember_deleted_host_pairing(state, deletedHost); - update->hostDeleteCleanupRequested = true; - update->deletedHostAddress = deletedHost.address; - update->deletedHostPort = deletedHost.port; - update->deletedHostWasPaired = deletedHost.pairingState == app::PairingState::paired; + 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.erase(state.hosts.begin() + static_cast(state.selectedHostIndex)); - state.hostsDirty = true; - update->hostsChanged = true; + 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.statusMessage = "Deleted saved host"; + state.shell.statusMessage = "Deleted saved host"; } /** @@ -1017,9 +1017,9 @@ namespace { case input::UiCommand::delete_character: case input::UiCommand::open_context_menu: cycle_log_viewer_placement(state); - state.settingsDirty = true; + state.settings.dirty = true; if (update != nullptr) { - update->settingsChanged = true; + update->persistence.settingsChanged = true; } return true; case input::UiCommand::previous_page: @@ -1059,19 +1059,19 @@ namespace { const std::string targetPath = state.confirmation.targetPath; close_modal_and_mark_closed(state, update); if (!confirmed) { - state.statusMessage = "Cancelled the pending reset action"; + state.shell.statusMessage = "Cancelled the pending reset action"; return true; } if (update == nullptr) { return true; } if (action == app::ConfirmationAction::delete_saved_file) { - update->savedFileDeleteRequested = true; - update->savedFileDeletePath = targetPath; + update->persistence.savedFileDeleteRequested = true; + update->persistence.savedFileDeletePath = targetPath; return true; } if (action == app::ConfirmationAction::factory_reset) { - update->factoryResetRequested = true; + update->persistence.factoryResetRequested = true; } return true; } @@ -1095,8 +1095,8 @@ namespace { close_modal_and_mark_closed(state, update); if (host->pairingState == app::PairingState::paired) { if (update != nullptr) { - update->appsBrowseRequested = true; - update->appsBrowseShowHidden = true; + update->requests.appsBrowseRequested = true; + update->requests.appsBrowseShowHidden = true; } return true; } @@ -1105,9 +1105,9 @@ namespace { case 1: close_modal_and_mark_closed(state, update); if (update != nullptr) { - update->connectionTestRequested = true; - update->connectionTestAddress = host->address; - update->connectionTestPort = app::effective_host_port(host->port); + update->requests.connectionTestRequested = true; + update->requests.connectionTestAddress = host->address; + update->requests.connectionTestPort = app::effective_host_port(host->port); } return true; case 2: @@ -1135,26 +1135,26 @@ namespace { return true; } - app::HostRecord *mutableHost = state.activeHostLoaded ? &state.activeHost : nullptr; + 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.showHiddenApps); + 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.selectedAppIndex]]; + app::HostAppRecord &appRecord = mutableHost->apps[indices[state.apps.selectedAppIndex]]; switch (state.modal.selectedActionIndex % 3U) { case 0: appRecord.hidden = !appRecord.hidden; - state.hostsDirty = true; + state.hosts.dirty = true; close_modal_and_mark_closed(state, update); if (update != nullptr) { - update->hostsChanged = true; + update->persistence.hostsChanged = true; } clamp_selected_app_index(state); return true; @@ -1163,10 +1163,10 @@ namespace { return true; case 2: appRecord.favorite = !appRecord.favorite; - state.hostsDirty = true; + state.hosts.dirty = true; close_modal_and_mark_closed(state, update); if (update != nullptr) { - update->hostsChanged = true; + update->persistence.hostsChanged = true; } return true; default: @@ -1239,35 +1239,35 @@ namespace app { ClientState create_initial_state() { ClientState state; - state.activeScreen = ScreenId::hosts; - state.overlayVisible = false; - state.shouldExit = false; - state.hostsDirty = false; - state.hostsLoaded = true; - state.overlayScrollOffset = 0U; - state.hostsFocusArea = HostsFocusArea::toolbar; - state.selectedToolbarButtonIndex = DEFAULT_EMPTY_HOSTS_TOOLBAR_INDEX; - state.selectedHostIndex = 0U; - state.selectedAppIndex = 0U; - state.appsScrollPage = 0U; - state.showHiddenApps = false; - state.activeHostLoaded = false; - state.selectedHostPort = 0; + 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.settingsFocusArea = SettingsFocusArea::categories; + state.settings.focusArea = SettingsFocusArea::categories; state.pairingDraft.targetPort = DEFAULT_HOST_PORT; state.pairingDraft.stage = PairingStage::idle; - state.selectedSettingsCategory = SettingsCategory::logging; - state.logViewerScrollOffset = 0U; - state.logViewerPlacement = LogViewerPlacement::full; - state.loggingLevel = logging::LogLevel::none; - state.xemuConsoleLoggingLevel = logging::LogLevel::none; - state.settingsDirty = false; - state.savedFilesDirty = true; + 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; } @@ -1290,16 +1290,16 @@ namespace app { } void replace_hosts(ClientState &state, std::vector hosts, std::string statusMessage) { - state.hosts = std::move(hosts); - state.hostsLoaded = true; - state.hostsDirty = false; - state.statusMessage = std::move(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.selectedHostAddress.empty()) { - for (std::size_t index = 0; index < state.hosts.size(); ++index) { - if (host_matches_endpoint(state.hosts[index], state.selectedHostAddress, state.selectedHostPort)) { - state.selectedHostIndex = index; - state.hostsFocusArea = HostsFocusArea::grid; + 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; } @@ -1310,19 +1310,19 @@ namespace app { } clamp_selected_host_index(state); clamp_selected_app_index(state); - if (state.activeScreen == ScreenId::hosts) { + if (state.shell.activeScreen == ScreenId::hosts) { clear_active_host(state); } - if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { + 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.savedFiles = std::move(savedFiles); - state.savedFilesDirty = false; - if (state.activeScreen == ScreenId::settings) { + 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 {}); } } @@ -1345,16 +1345,16 @@ namespace app { } void apply_connection_test_result(ClientState &state, bool success, std::string message) { - if (state.activeScreen == ScreenId::add_host) { + if (state.shell.activeScreen == ScreenId::add_host) { state.addHostDraft.connectionMessage = message; state.addHostDraft.lastConnectionSucceeded = success; } - if (!state.hosts.empty() && state.selectedHostIndex < state.hosts.size()) { - state.hosts[state.selectedHostIndex].reachability = success ? HostReachability::online : HostReachability::offline; - } else if (state.activeHostLoaded) { - state.activeHost.reachability = success ? HostReachability::online : HostReachability::offline; + 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.statusMessage = std::move(message); + state.shell.statusMessage = std::move(message); } bool apply_pairing_result(ClientState &state, const std::string &address, uint16_t port, bool success, std::string message) { @@ -1365,7 +1365,7 @@ namespace app { state.pairingDraft.generatedPin.clear(); } state.pairingDraft.statusMessage = message; - state.statusMessage = std::move(message); + state.shell.statusMessage = std::move(message); HostRecord *host = find_loaded_host_by_endpoint(state, address, port); if (host == nullptr) { @@ -1376,13 +1376,13 @@ namespace app { clear_deleted_host_pairing(state, address, port); host->pairingState = PairingState::paired; host->reachability = HostReachability::online; - if (state.hostsLoaded) { + if (state.hosts.loaded) { select_host_by_endpoint(state, address, port); } else { remember_host_selection(state, *host); } set_screen(state, ScreenId::hosts); - state.hostsDirty = true; + state.hosts.dirty = true; return true; } @@ -1399,11 +1399,11 @@ namespace app { * @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, address, port); host != nullptr) { + if (HostRecord *host = find_host_by_endpoint(state.hosts.items, address, port); host != nullptr) { return host; } - if (state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { - return &state.activeHost; + if (state.shell.activeScreen == ScreenId::apps && state.hosts.activeLoaded && host_matches_endpoint(state.hosts.active, address, port)) { + return &state.hosts.active; } return nullptr; } @@ -1416,7 +1416,7 @@ namespace app { * @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.activeScreen == ScreenId::apps && state.activeHostLoaded && host == &state.activeHost; + return host != nullptr && state.shell.activeScreen == ScreenId::apps && state.hosts.activeLoaded && host == &state.hosts.active; } /** @@ -1450,9 +1450,9 @@ namespace app { host->lastAppListRefreshTick = 0U; host->appListState = HostAppListState::failed; host->appListStatusMessage = message; - state.hostsDirty = state.hostsDirty || persistedAppCacheChanged; + state.hosts.dirty = state.hosts.dirty || persistedAppCacheChanged; if (hostIsActiveAppsScreenSelection) { - state.statusMessage = std::move(message); + state.shell.statusMessage = std::move(message); } refresh_running_flags(host); clamp_selected_app_index(state); @@ -1474,7 +1474,7 @@ namespace app { 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.statusMessage = std::move(message); + state.shell.statusMessage = std::move(message); } refresh_running_flags(host); clamp_selected_app_index(state); @@ -1514,9 +1514,9 @@ namespace app { */ 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.showHiddenApps, selectedAppId); + const std::size_t restoredIndex = visible_app_index_for_id(host, state.apps.showHiddenApps, selectedAppId); if (restoredIndex != static_cast(-1)) { - state.selectedAppIndex = restoredIndex; + state.apps.selectedAppIndex = restoredIndex; } } clamp_selected_app_index(state); @@ -1561,18 +1561,18 @@ namespace app { host->appListContentHash = appListContentHash; host->appListState = HostAppListState::ready; host->appListStatusMessage = message; - state.hostsDirty = state.hostsDirty || persistedAppCacheChanged; + state.hosts.dirty = state.hosts.dirty || persistedAppCacheChanged; if (hostIsActiveAppsScreenSelection) { - state.statusMessage.clear(); + 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, address, port); - if (host == nullptr && state.activeScreen == ScreenId::apps && state.activeHostLoaded && host_matches_endpoint(state.activeHost, address, port)) { - host = &state.activeHost; + 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; @@ -1584,36 +1584,36 @@ namespace app { return; } appRecord.boxArtCached = true; - state.hostsDirty = true; + state.hosts.dirty = true; return; } } } void set_log_file_path(ClientState &state, std::string logFilePath) { - state.logFilePath = std::move(logFilePath); + state.settings.logFilePath = std::move(logFilePath); } void apply_log_viewer_contents(ClientState &state, std::vector lines, std::string statusMessage) { - state.logViewerLines = std::move(lines); - state.logViewerScrollOffset = 0U; - state.statusMessage = std::move(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.pairingResetEndpoints.begin(), state.pairingResetEndpoints.end(), key) != state.pairingResetEndpoints.end(); + 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.activeScreen == ScreenId::apps || state.activeScreen == ScreenId::pair_host) && state.activeHostLoaded) { - return &state.activeHost; + if ((state.shell.activeScreen == ScreenId::apps || state.shell.activeScreen == ScreenId::pair_host) && state.hosts.activeLoaded) { + return &state.hosts.active; } - if (state.hosts.empty() || state.selectedHostIndex >= state.hosts.size()) { + if (state.hosts.items.empty() || state.hosts.selectedHostIndex >= state.hosts.items.size()) { return nullptr; } - return &state.hosts[state.selectedHostIndex]; + return &state.hosts.items[state.hosts.selectedHostIndex]; } const HostAppRecord *selected_app(const ClientState &state) { @@ -1621,19 +1621,19 @@ namespace app { if (host == nullptr) { return nullptr; } - const std::vector indices = visible_app_indices(*host, state.showHiddenApps); + const std::vector indices = visible_app_indices(*host, state.apps.showHiddenApps); if (indices.empty()) { return nullptr; } - const std::size_t visibleIndex = std::min(state.selectedAppIndex, indices.size() - 1U); + 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.activeScreen != ScreenId::apps || !state.activeHostLoaded) { + if (state.shell.activeScreen != ScreenId::apps || !state.hosts.activeLoaded) { return nullptr; } - return &state.activeHost; + return &state.hosts.active; } /** @@ -1646,39 +1646,39 @@ namespace app { */ bool handle_overlay_command(ClientState &state, input::UiCommand command, AppUpdate *update) { if (command == input::UiCommand::toggle_overlay) { - state.overlayVisible = !state.overlayVisible; - if (!state.overlayVisible) { - state.overlayScrollOffset = 0U; + state.shell.overlayVisible = !state.shell.overlayVisible; + if (!state.shell.overlayVisible) { + state.shell.overlayScrollOffset = 0U; } if (update != nullptr) { - update->overlayChanged = true; - update->overlayVisibilityChanged = true; + update->navigation.overlayChanged = true; + update->navigation.overlayVisibilityChanged = true; } return true; } - if (!state.overlayVisible || update == nullptr) { + if (!state.shell.overlayVisible || update == nullptr) { return false; } switch (command) { case input::UiCommand::previous_page: - state.overlayScrollOffset += OVERLAY_SCROLL_STEP; - update->overlayChanged = true; + state.shell.overlayScrollOffset += OVERLAY_SCROLL_STEP; + update->navigation.overlayChanged = true; return true; case input::UiCommand::next_page: - state.overlayScrollOffset = state.overlayScrollOffset > OVERLAY_SCROLL_STEP ? state.overlayScrollOffset - OVERLAY_SCROLL_STEP : 0U; - update->overlayChanged = true; + 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.overlayScrollOffset += OVERLAY_SCROLL_STEP * 3U; - update->overlayChanged = true; + 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.overlayScrollOffset = state.overlayScrollOffset > fastStep ? state.overlayScrollOffset - fastStep : 0U; - update->overlayChanged = true; + state.shell.overlayScrollOffset = state.shell.overlayScrollOffset > fastStep ? state.shell.overlayScrollOffset - fastStep : 0U; + update->navigation.overlayChanged = true; return true; } case input::UiCommand::move_up: @@ -1706,7 +1706,7 @@ namespace app { * @return True when the command was consumed by the keypad. */ bool handle_add_host_keypad_command(ClientState &state, input::UiCommand command) { - if (state.activeScreen != ScreenId::add_host || !state.addHostDraft.keypad.visible) { + if (state.shell.activeScreen != ScreenId::add_host || !state.addHostDraft.keypad.visible) { return false; } @@ -1764,24 +1764,24 @@ namespace app { return; } - update->activatedItemId = detailUpdate.activatedItemId; + update->navigation.activatedItemId = detailUpdate.activatedItemId; if (detailUpdate.activatedItemId == "view-log-file") { - update->logViewRequested = true; + update->requests.logViewRequested = true; return; } if (detailUpdate.activatedItemId == "cycle-log-level") { - state.loggingLevel = next_logging_level(state.loggingLevel); - state.settingsDirty = true; - update->settingsChanged = true; - state.statusMessage = std::string("Logging level set to ") + logging::to_string(state.loggingLevel); + 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.xemuConsoleLoggingLevel = next_logging_level(state.xemuConsoleLoggingLevel); - state.settingsDirty = true; - update->settingsChanged = true; - state.statusMessage = std::string("xemu console logging level set to ") + logging::to_string(state.xemuConsoleLoggingLevel); + 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; } @@ -1795,7 +1795,7 @@ namespace app { "This removes hosts, settings, logs, pairing identity, and cached cover art.", } ); - update->modalOpened = true; + update->navigation.modalOpened = true; return; } if (starts_with(detailUpdate.activatedItemId, DELETE_SAVED_FILE_MENU_ID_PREFIX)) { @@ -1810,10 +1810,10 @@ namespace app { }, targetPath ); - update->modalOpened = true; + update->navigation.modalOpened = true; return; } - state.statusMessage = detailUpdate.activatedItemId + " is not implemented yet"; + state.shell.statusMessage = detailUpdate.activatedItemId + " is not implemented yet"; } /** @@ -1825,24 +1825,24 @@ namespace app { * @return True when the settings screen consumed the command. */ bool handle_settings_screen_command(ClientState &state, input::UiCommand command, AppUpdate *update) { - if (state.activeScreen != ScreenId::settings || update == nullptr) { + if (state.shell.activeScreen != ScreenId::settings || update == nullptr) { return false; } - if (command == input::UiCommand::move_left && state.settingsFocusArea == SettingsFocusArea::options) { - state.settingsFocusArea = SettingsFocusArea::categories; + 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.settingsFocusArea == SettingsFocusArea::categories && !state.detailMenu.items().empty()) { - state.settingsFocusArea = SettingsFocusArea::options; + if (command == input::UiCommand::move_right && state.settings.focusArea == SettingsFocusArea::categories && !state.detailMenu.items().empty()) { + state.settings.focusArea = SettingsFocusArea::options; return true; } - if (state.settingsFocusArea == SettingsFocusArea::categories) { + if (state.settings.focusArea == SettingsFocusArea::categories) { const ui::MenuUpdate categoryUpdate = state.menu.handle_command(command); if (categoryUpdate.backRequested) { set_screen(state, ScreenId::hosts); - update->screenChanged = true; + update->navigation.screenChanged = true; return true; } if (categoryUpdate.selectionChanged) { @@ -1854,18 +1854,18 @@ namespace app { return true; } - update->activatedItemId = categoryUpdate.activatedItemId; + update->navigation.activatedItemId = categoryUpdate.activatedItemId; sync_selected_settings_category_from_menu(state); rebuild_menu(state, categoryUpdate.activatedItemId); if (!state.detailMenu.items().empty()) { - state.settingsFocusArea = SettingsFocusArea::options; + state.settings.focusArea = SettingsFocusArea::options; } return true; } const ui::MenuUpdate detailUpdate = state.detailMenu.handle_command(command); if (detailUpdate.backRequested) { - state.settingsFocusArea = SettingsFocusArea::categories; + state.settings.focusArea = SettingsFocusArea::categories; return true; } if (!detailUpdate.activationRequested) { @@ -1888,10 +1888,10 @@ namespace app { return; } - update->activatedItemId = "select-host"; + update->navigation.activatedItemId = "select-host"; if (host->pairingState == PairingState::paired) { - update->appsBrowseRequested = true; - update->appsBrowseShowHidden = false; + update->requests.appsBrowseRequested = true; + update->requests.appsBrowseShowHidden = false; return; } @@ -1909,23 +1909,23 @@ namespace app { return; } - const std::size_t toolbarIndex = state.selectedToolbarButtonIndex % HOST_TOOLBAR_BUTTON_COUNT; + 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->screenChanged = true; - update->activatedItemId = "settings-button"; + update->navigation.screenChanged = true; + update->navigation.activatedItemId = "settings-button"; return; } if (toolbarIndex == 1U) { open_modal(state, ModalId::support); - update->modalOpened = true; - update->activatedItemId = "support-button"; + update->navigation.modalOpened = true; + update->navigation.activatedItemId = "support-button"; return; } enter_add_host_screen(state); - update->screenChanged = true; - update->activatedItemId = "add-host-button"; + update->navigation.screenChanged = true; + update->navigation.activatedItemId = "add-host-button"; } /** @@ -1937,48 +1937,48 @@ namespace app { * @return True when the hosts screen consumed the command. */ bool handle_hosts_screen_command(ClientState &state, input::UiCommand command, AppUpdate *update) { - if (state.activeScreen != ScreenId::hosts || update == nullptr) { + if (state.shell.activeScreen != ScreenId::hosts || update == nullptr) { return false; } switch (command) { case input::UiCommand::move_left: - if (state.hostsFocusArea == HostsFocusArea::toolbar) { + 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.hostsFocusArea == HostsFocusArea::toolbar) { + 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.hostsFocusArea == HostsFocusArea::toolbar) { - if (!state.hosts.empty()) { - state.hostsFocusArea = HostsFocusArea::grid; + 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.hostsFocusArea == HostsFocusArea::grid) { + if (state.hosts.focusArea == HostsFocusArea::grid) { move_host_grid_selection(state, -1, 0); } return true; case input::UiCommand::open_context_menu: - if (state.hostsFocusArea == HostsFocusArea::grid && selected_host(state) != nullptr) { + if (state.hosts.focusArea == HostsFocusArea::grid && selected_host(state) != nullptr) { open_modal(state, ModalId::host_actions); - update->modalOpened = true; + update->navigation.modalOpened = true; } return true; case input::UiCommand::activate: case input::UiCommand::confirm: - if (state.hostsFocusArea == HostsFocusArea::toolbar) { + if (state.hosts.focusArea == HostsFocusArea::toolbar) { activate_hosts_toolbar(state, update); return true; } @@ -2007,7 +2007,7 @@ namespace app { * @return True when the apps screen consumed the command. */ bool handle_apps_screen_command(ClientState &state, input::UiCommand command, AppUpdate *update) { - if (state.activeScreen != ScreenId::apps || update == nullptr) { + if (state.shell.activeScreen != ScreenId::apps || update == nullptr) { return false; } @@ -2027,20 +2027,20 @@ namespace app { case input::UiCommand::open_context_menu: if (selected_app(state) != nullptr) { open_modal(state, ModalId::app_actions); - update->modalOpened = true; + update->navigation.modalOpened = true; } return true; case input::UiCommand::activate: case input::UiCommand::confirm: if (const HostAppRecord *appRecord = selected_app(state); appRecord != nullptr) { - state.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; - update->activatedItemId = "launch-app"; + state.shell.statusMessage = "Launching " + appRecord->name + " is not implemented yet"; + update->navigation.activatedItemId = "launch-app"; } return true; case input::UiCommand::back: - state.statusMessage.clear(); + state.shell.statusMessage.clear(); set_screen(state, ScreenId::hosts); - update->screenChanged = true; + update->navigation.screenChanged = true; return true; case input::UiCommand::delete_character: case input::UiCommand::previous_page: @@ -2063,7 +2063,7 @@ namespace app { */ void apply_add_host_validation_error(ClientState &state, std::string_view validationError) { state.addHostDraft.validationMessage = validationError; - state.statusMessage = validationError; + state.shell.statusMessage = validationError; } /** @@ -2098,10 +2098,10 @@ namespace app { } state.addHostDraft.validationMessage.clear(); state.addHostDraft.connectionMessage = "Testing connection to " + normalizedAddress + (parsedPort == 0 ? std::string {} : ":" + std::to_string(parsedPort)) + "..."; - state.statusMessage = state.addHostDraft.connectionMessage; - update->connectionTestRequested = true; - update->connectionTestAddress = normalizedAddress; - update->connectionTestPort = effective_host_port(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") { @@ -2109,21 +2109,21 @@ namespace app { apply_add_host_validation_error(state, validationError); return; } - if (find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort) != nullptr) { + 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.push_back(make_host_record(normalizedAddress, parsedPort)); - state.selectedHostIndex = state.hosts.size() - 1U; - state.hostsFocusArea = HostsFocusArea::grid; - state.hostsDirty = true; - update->hostsChanged = true; + 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.statusMessage = "Saved host " + normalizedAddress; + state.shell.statusMessage = "Saved host " + normalizedAddress; set_screen(state, ScreenId::hosts); - update->screenChanged = true; + update->navigation.screenChanged = true; return; } if (activatedItemId == "start-pairing") { @@ -2132,13 +2132,13 @@ namespace app { return; } - const HostRecord *host = find_host_by_endpoint(state.hosts, normalizedAddress, parsedPort); + const HostRecord *host = find_host_by_endpoint(state.hosts.items, normalizedAddress, parsedPort); if (host == nullptr) { - state.hosts.push_back(make_host_record(normalizedAddress, parsedPort)); - state.selectedHostIndex = state.hosts.size() - 1U; - state.hostsDirty = true; - update->hostsChanged = true; - host = &state.hosts.back(); + 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); @@ -2148,7 +2148,7 @@ namespace app { state.addHostDraft.validationMessage.clear(); state.addHostDraft.connectionMessage.clear(); set_screen(state, ScreenId::hosts); - update->screenChanged = true; + update->navigation.screenChanged = true; } } @@ -2167,8 +2167,8 @@ namespace app { return update; } - if (command == input::UiCommand::delete_character && !state.statusMessage.empty()) { - state.statusMessage.clear(); + if (command == input::UiCommand::delete_character && !state.shell.statusMessage.empty()) { + state.shell.statusMessage.clear(); return update; } @@ -2186,20 +2186,20 @@ namespace app { const ui::MenuUpdate menuUpdate = state.menu.handle_command(command); if (menuUpdate.overlayToggleRequested) { - state.overlayVisible = !state.overlayVisible; - update.overlayChanged = true; - update.overlayVisibilityChanged = true; + state.shell.overlayVisible = !state.shell.overlayVisible; + update.navigation.overlayChanged = true; + update.navigation.overlayVisibilityChanged = true; return update; } if (menuUpdate.backRequested) { - if (state.activeScreen == ScreenId::settings || state.activeScreen == ScreenId::add_host || state.activeScreen == ScreenId::pair_host) { - if (state.activeScreen == ScreenId::pair_host) { - update.pairingCancelledRequested = true; + 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.statusMessage = state.activeScreen == ScreenId::apps ? std::string {} : state.statusMessage; + state.shell.statusMessage = state.shell.activeScreen == ScreenId::apps ? std::string {} : state.shell.statusMessage; set_screen(state, ScreenId::hosts); - update.screenChanged = true; + update.navigation.screenChanged = true; } return update; } @@ -2208,18 +2208,18 @@ namespace app { return update; } - update.activatedItemId = menuUpdate.activatedItemId; + update.navigation.activatedItemId = menuUpdate.activatedItemId; - if (state.activeScreen == ScreenId::pair_host) { + if (state.shell.activeScreen == ScreenId::pair_host) { if (menuUpdate.activatedItemId == "cancel-pairing") { - update.pairingCancelledRequested = true; + update.requests.pairingCancelledRequested = true; set_screen(state, ScreenId::hosts); - update.screenChanged = true; + update.navigation.screenChanged = true; } return update; } - if (state.activeScreen != ScreenId::add_host) { + if (state.shell.activeScreen != ScreenId::add_host) { return update; } diff --git a/src/app/client_state.h b/src/app/client_state.h index 9d6bb5e..19c7257 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -144,81 +144,247 @@ namespace app { }; /** - * @brief Serializable app state for the menu-driven client shell. + * @brief Shell-wide state that is not owned by a specific workflow screen. */ - struct ClientState { ///< NOSONAR(cpp:S1820) app shell state intentionally keeps the full workflow snapshot together - ScreenId activeScreen; ///< Screen currently shown by the shell. - bool overlayVisible; ///< True when the diagnostics overlay is visible. - bool shouldExit; ///< True when the application should terminate. - bool hostsDirty; ///< True when the host list changed and should be saved. - bool hostsLoaded; ///< True when the hosts page list is currently loaded in memory. - std::size_t overlayScrollOffset; ///< Scroll offset used by long overlay content. - HostsFocusArea hostsFocusArea; ///< Focused region on the hosts page. - std::size_t selectedToolbarButtonIndex; ///< Zero-based selection inside the hosts toolbar. - std::size_t selectedHostIndex; ///< Zero-based selection inside the saved host list. - std::size_t selectedAppIndex; ///< Zero-based selection inside the visible app list. - std::size_t appsScrollPage; ///< Horizontal page offset for paged app browsing. - bool showHiddenApps; ///< True when hidden apps should remain visible in the apps screen. - ui::MenuModel menu; ///< Primary vertical menu model for the active screen. - ui::MenuModel detailMenu; ///< Secondary detail or actions menu. - std::vector hosts; ///< Saved hosts currently tracked by the shell. - HostRecord activeHost; ///< Host snapshot kept for host-specific non-host screens after unloading the hosts page. - bool activeHostLoaded = false; ///< True when activeHost contains a valid host snapshot. - std::string selectedHostAddress; ///< Last selected host address used to restore hosts-page selection after reload. - uint16_t selectedHostPort = 0; ///< Last selected host port override used to restore hosts-page selection after reload. - 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. - SettingsFocusArea settingsFocusArea = SettingsFocusArea::categories; ///< Focused pane within the settings screen. - SettingsCategory selectedSettingsCategory = SettingsCategory::logging; ///< Settings category selected in the left pane. - ConfirmationDialogState confirmation; ///< Confirmation dialog content for destructive actions. + 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 settingsDirty = false; ///< True when persisted TOML-backed settings changed and should be saved. + 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. - std::vector pairingResetEndpoints; ///< Endpoints whose pairing material should be cleared during reset. }; /** - * @brief Result of updating the client shell with a UI command. + * @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 AppUpdate { ///< NOSONAR(cpp:S1820) command results intentionally bundle all one-frame side effects - bool screenChanged; ///< True when the active screen changed. - bool overlayChanged; ///< True when overlay content changed. - bool overlayVisibilityChanged; ///< True when overlay visibility toggled. - bool exitRequested; ///< True when the shell requested application exit. - bool hostsChanged; ///< True when the host list changed and should be persisted. - bool settingsChanged; ///< True when persisted TOML-backed settings changed. - bool connectionTestRequested; ///< True when a manual host connection test should run. - bool pairingRequested; ///< True when manual pairing should begin. - bool pairingCancelledRequested; ///< True when an in-progress pairing request should be cancelled. - bool appsBrowseRequested; ///< True when app browsing for the selected host should begin. - bool appsBrowseShowHidden; ///< Hidden-app visibility requested for the app browse action. - bool logViewRequested; ///< True when the log viewer should be refreshed from disk. - bool savedFileDeleteRequested; ///< True when one managed file should be deleted. - bool factoryResetRequested; ///< True when a full saved-data reset should run. - bool hostDeleteCleanupRequested; ///< True when host deletion follow-up cleanup should run. - bool modalOpened; ///< True when a modal became active during the update. - bool modalClosed; ///< True when the active modal was dismissed during the update. - bool deletedHostWasPaired; ///< True when the deleted host previously had pairing credentials. + 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; ///< Host port 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; ///< Host port 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; ///< Port 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. * diff --git a/src/main.cpp b/src/main.cpp index 8bd2251..9f6966c 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -32,10 +32,10 @@ namespace { }; void apply_persisted_settings(app::ClientState &state, const app::AppSettings &settings) { - state.loggingLevel = settings.loggingLevel; - state.xemuConsoleLoggingLevel = settings.xemuConsoleLoggingLevel; - state.logViewerPlacement = settings.logViewerPlacement; - state.settingsDirty = false; + 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) { @@ -142,7 +142,7 @@ namespace { logging::log(task->runtimeNetworkStatus.ready ? logging::LogLevel::info : logging::LogLevel::warning, "network", line); } if (!task->runtimeNetworkStatus.ready) { - clientState.statusMessage = task->runtimeNetworkStatus.summary; + clientState.shell.statusMessage = task->runtimeNetworkStatus.summary; } } @@ -155,8 +155,8 @@ int main() { app::ClientState clientState = app::create_initial_state(); load_persisted_settings(clientState); - logging::set_file_minimum_level(clientState.loggingLevel); - logging::set_debugger_console_minimum_level(clientState.xemuConsoleLoggingLevel); + 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); @@ -174,7 +174,7 @@ int main() { runtimeLogFile.consume(entry, &ignoredError); }); - logging::info("app", std::string("Initial screen: ") + app::to_string(clientState.activeScreen)); + 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()); @@ -236,11 +236,11 @@ int main() { logging::info("app", "Starting interactive shell"); const int exitCode = ui::run_shell(window, bestVideoMode, clientState); - if (clientState.hostsDirty) { - const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(clientState.hosts); + 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.hostsDirty = false; + clientState.hosts.dirty = false; } else { logging::error("hosts", saveResult.errorMessage); } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 898f540..de042b2 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -858,22 +858,22 @@ namespace { int modalInnerWidth, KeypadModalLayoutCache *cache ) { - if (cache != nullptr && cache->modalInnerWidth == modalInnerWidth && cache->lines == viewModel.keypadModalLines) { + if (cache != nullptr && cache->modalInnerWidth == modalInnerWidth && cache->lines == viewModel.keypad.lines) { return cache->modalTextHeight; } if (cache != nullptr) { - if (cache->lineTextures.size() > viewModel.keypadModalLines.size()) { - for (std::size_t index = viewModel.keypadModalLines.size(); index < cache->lineTextures.size(); ++index) { + 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.keypadModalLines.size()); + cache->lineTextures.resize(viewModel.keypad.lines.size()); } int modalTextHeight = 0; - for (std::size_t index = 0; index < viewModel.keypadModalLines.size(); ++index) { - const std::string &line = viewModel.keypadModalLines[index]; + 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; @@ -889,7 +889,7 @@ namespace { if (cache != nullptr) { cache->modalInnerWidth = modalInnerWidth; cache->modalTextHeight = modalTextHeight; - cache->lines = viewModel.keypadModalLines; + cache->lines = viewModel.keypad.lines; } return modalTextHeight; @@ -1065,7 +1065,7 @@ namespace { const int dockedWidth = std::max(420, (screenWidth - (outerMargin * 3)) / 2); const int height = screenHeight - (outerMargin * 2); - switch (viewModel.logViewerPlacement) { + switch (viewModel.logViewer.placement) { case app::LogViewerPlacement::left: return {outerMargin, outerMargin, dockedWidth, height}; case app::LogViewerPlacement::right: @@ -1084,21 +1084,21 @@ namespace { LogViewerLayout build_log_viewer_layout(const ui::ShellViewModel &viewModel, TTF_Font *font, int availableWidth, int availableHeight, std::size_t clampedOffset) { LogViewerLayout layout {}; - if (viewModel.logViewerLines.empty()) { + if (viewModel.logViewer.lines.empty()) { layout.visibleLines.push_back(nullptr); return layout; } int usedHeight = 0; - std::size_t endIndex = viewModel.logViewerLines.size() > clampedOffset ? viewModel.logViewerLines.size() - clampedOffset : 0U; + 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.logViewerLines[endIndex - 1U], LOG_VIEWER_MAX_RENDER_CHARACTERS); + 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.logViewerLines[endIndex - 1U]); + layout.visibleLines.push_back(&viewModel.logViewer.lines[endIndex - 1U]); usedHeight += lineHeight; --endIndex; } @@ -1130,7 +1130,7 @@ namespace { } } - if (viewModel.logViewerScrollOffset > 0U) { + 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; @@ -1172,12 +1172,12 @@ namespace { 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.modalTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 18, modalRect.y + 16, modalRect.w - 36)) { + 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.logViewerPath, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, modalRect.x + 18, modalRect.y + 56, modalRect.w - 36, &pathHeight)) { + 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; } @@ -1204,13 +1204,13 @@ namespace { }; fill_rect(renderer, contentRect, BACKGROUND_RED, BACKGROUND_GREEN, BACKGROUND_BLUE, 0x70); - const std::size_t maxOffset = viewModel.logViewerLines.size() > 1U ? viewModel.logViewerLines.size() - 1U : 0U; - const std::size_t clampedOffset = std::min(viewModel.logViewerScrollOffset, maxOffset); + 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.logViewerLines.empty() && viewModel.logViewerLines.size() > logViewerLayout.visibleLines.size(); + 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); } @@ -1229,7 +1229,7 @@ namespace { render_vertical_scrollbar( renderer, {contentRect.x + contentRect.w - logViewerScrollbarWidth, contentRect.y, logViewerScrollbarWidth, contentRect.h}, - static_cast(viewModel.logViewerLines.size()), + static_cast(viewModel.logViewer.lines.size()), static_cast(std::max(1U, logViewerLayout.visibleLines.size())), static_cast(logViewerLayout.firstVisibleIndex) ); @@ -1890,16 +1890,16 @@ namespace { } void log_app_update(const app::ClientState &state, const app::AppUpdate &update) { - if (!update.activatedItemId.empty()) { - logging::info("ui", "Activated menu item: " + update.activatedItemId); + if (!update.navigation.activatedItemId.empty()) { + logging::info("ui", "Activated menu item: " + update.navigation.activatedItemId); } - if (update.screenChanged) { - logging::info("ui", std::string("Switched screen to ") + app::to_string(state.activeScreen)); + if (update.navigation.screenChanged) { + logging::info("ui", std::string("Switched screen to ") + app::to_string(state.shell.activeScreen)); } - if (update.overlayVisibilityChanged) { - logging::info("overlay", state.overlayVisible ? "Overlay enabled" : "Overlay disabled"); + if (update.navigation.overlayVisibilityChanged) { + logging::info("overlay", state.shell.overlayVisible ? "Overlay enabled" : "Overlay disabled"); } - if (update.exitRequested) { + if (update.navigation.exitRequested) { logging::info("app", "Exit requested from shell"); } } @@ -1939,7 +1939,7 @@ namespace { } bool ensure_hosts_loaded_for_active_screen(app::ClientState &state) { - if (state.activeScreen != app::ScreenId::hosts || state.hostsLoaded) { + if (state.shell.activeScreen != app::ScreenId::hosts || state.hosts.loaded) { return true; } @@ -1947,32 +1947,32 @@ namespace { for (const std::string &warning : loadedHosts.warnings) { logging::warn("hosts", warning); } - app::replace_hosts(state, loadedHosts.hosts, state.statusMessage); + app::replace_hosts(state, loadedHosts.hosts, state.shell.statusMessage); return true; } bool persist_hosts(app::ClientState &state) { std::vector hostsToSave; - if (state.hostsLoaded) { - hostsToSave = state.hosts; - } else if (state.activeHostLoaded) { + 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.activeHost.address, state.activeHost.port); host != nullptr) { - merge_host_for_persistence(host, state.activeHost); + 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.activeHost); + hostsToSave.push_back(state.hosts.active); } } else { - hostsToSave = state.hosts; + hostsToSave = state.hosts.items; } const startup::SaveSavedHostsResult saveResult = startup::save_saved_hosts(hostsToSave); if (saveResult.success) { - state.hostsDirty = false; + state.hosts.dirty = false; logging::info("hosts", "Saved host records"); return true; } @@ -1982,7 +1982,7 @@ namespace { } void persist_hosts_if_needed(app::ClientState &state, const app::AppUpdate &update) { - if (!update.hostsChanged) { + if (!update.persistence.hostsChanged) { return; } @@ -1991,20 +1991,20 @@ namespace { app::AppSettings persistent_settings_from_state(const app::ClientState &state) { return { - state.loggingLevel, - state.xemuConsoleLoggingLevel, - state.logViewerPlacement, + state.settings.loggingLevel, + state.settings.xemuConsoleLoggingLevel, + state.settings.logViewerPlacement, }; } void persist_settings_if_needed(app::ClientState &state, const app::AppUpdate &update) { - if (!update.settingsChanged || !state.settingsDirty) { + 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.settingsDirty = false; + state.settings.dirty = false; logging::info("settings", "Saved Moonlight settings"); return; } @@ -2061,9 +2061,9 @@ namespace { 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.selectedAppIndex = 0U; - if (state.activeScreen == app::ScreenId::apps && state.activeHostLoaded && host == &state.activeHost) { - state.statusMessage = host->appListStatusMessage; + 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; } @@ -2072,10 +2072,10 @@ namespace { 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.hostsDirty = state.hostsDirty || persistedMetadataChanged; + state.hosts.dirty = state.hosts.dirty || persistedMetadataChanged; }; - for (app::HostRecord &host : state.hosts) { + for (app::HostRecord &host : state.hosts.items) { if (!app::host_matches_endpoint(host, address, port)) { continue; } @@ -2083,13 +2083,13 @@ namespace { return; } - if (state.activeHostLoaded && app::host_matches_endpoint(state.activeHost, address, port)) { - apply_to_host(state.activeHost); + 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.savedFiles) { + for (const startup::SavedFileEntry &savedFile : state.settings.savedFiles) { if (savedFile.path == path) { return savedFile.displayName; } @@ -2118,7 +2118,7 @@ namespace { return; } - for (app::HostRecord &host : state.hosts) { + for (app::HostRecord &host : state.hosts.items) { for (app::HostAppRecord &appRecord : host.apps) { if (appRecord.boxArtCacheKey == cacheKey) { appRecord.boxArtCached = false; @@ -2128,7 +2128,7 @@ namespace { } void refresh_saved_files_if_needed(app::ClientState &state) { - if (state.activeScreen != app::ScreenId::settings || !state.savedFilesDirty) { + if (state.shell.activeScreen != app::ScreenId::settings || !state.settings.savedFilesDirty) { return; } @@ -2149,32 +2149,32 @@ namespace { } void delete_saved_file_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { - if (!update.savedFileDeleteRequested) { + if (!update.persistence.savedFileDeleteRequested) { return; } - if (std::string errorMessage; !startup::delete_saved_file(update.savedFileDeletePath, &errorMessage)) { - state.statusMessage = errorMessage; + 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.savedFileDeletePath); - const std::string deletedCoverArtCacheKey = cover_art_cache_key_from_path(update.savedFileDeletePath); + 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.savedFilesDirty = true; - state.statusMessage = "Deleted saved file " + deletedDisplayName; - logging::info("storage", state.statusMessage); + 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.hostDeleteCleanupRequested) { + if (!update.persistence.hostDeleteCleanupRequested) { return; } std::size_t deletedCoverArtCount = 0U; - for (const std::string &cacheKey : update.deletedHostCoverArtCacheKeys) { + for (const std::string &cacheKey : update.persistence.deletedHostCoverArtCacheKeys) { if (std::string errorMessage; !startup::delete_cover_art(cacheKey, &errorMessage)) { logging::warn("storage", errorMessage); } else { @@ -2184,7 +2184,7 @@ namespace { } bool deletedClientIdentity = false; - if (update.deletedHostWasPaired) { + 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; }); @@ -2200,35 +2200,35 @@ namespace { } } - state.statusMessage = "Deleted saved host"; + state.shell.statusMessage = "Deleted saved host"; if (deletedCoverArtCount > 0U) { - state.statusMessage += " and cleared " + std::to_string(deletedCoverArtCount) + " cached asset" + (deletedCoverArtCount == 1U ? std::string {} : "s"); + state.shell.statusMessage += " and cleared " + std::to_string(deletedCoverArtCount) + " cached asset" + (deletedCoverArtCount == 1U ? std::string {} : "s"); } if (deletedClientIdentity) { - state.statusMessage += " and reset local pairing identity"; + state.shell.statusMessage += " and reset local pairing identity"; } - logging::info("storage", state.statusMessage); + logging::info("storage", state.shell.statusMessage); } void factory_reset_if_requested(app::ClientState &state, const app::AppUpdate &update, CoverArtTextureCache *coverArtTextureCache) { - if (!update.factoryResetRequested) { + if (!update.persistence.factoryResetRequested) { return; } if (std::string errorMessage; !startup::delete_all_saved_files(&errorMessage)) { - state.statusMessage = errorMessage; + state.shell.statusMessage = errorMessage; logging::warn("storage", errorMessage); return; } state.hosts.clear(); state = app::create_initial_state(); - state.savedFiles.clear(); - state.savedFilesDirty = true; - state.statusMessage = "Factory reset completed"; + 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.statusMessage); + logging::info("storage", state.shell.statusMessage); } bool try_load_saved_pairing_identity(network::PairingIdentity *identity) { @@ -2585,28 +2585,28 @@ namespace { } void cancel_pairing_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { - if (task == nullptr || !update.pairingCancelledRequested || task->activeAttempt == nullptr || task->activeAttempt->thread == nullptr) { + 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.statusMessage.clear(); + 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.connectionTestRequested) { + if (!update.requests.connectionTestRequested) { return; } - const std::string address = update.connectionTestAddress; - const uint16_t port = update.connectionTestPort == 0 ? app::DEFAULT_HOST_PORT : update.connectionTestPort; + 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.statusMessage); + logging::warn("hosts", state.shell.statusMessage); return; } @@ -2619,7 +2619,7 @@ namespace { if (success) { apply_server_info_to_host(state, address, port, serverInfo); } else { - for (app::HostRecord &host : state.hosts) { + 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; @@ -2629,13 +2629,13 @@ namespace { } app::apply_connection_test_result(state, success, resultMessage); logging::log(success ? logging::LogLevel::info : logging::LogLevel::warning, "hosts", resultMessage); - if (state.hostsDirty) { + if (state.hosts.dirty) { persist_hosts(state); } } void browse_host_apps_if_requested(app::ClientState &state, const app::AppUpdate &update) { - if (!update.appsBrowseRequested) { + if (!update.requests.appsBrowseRequested) { return; } @@ -2652,36 +2652,36 @@ namespace { std::string resultMessage; network::HostPairingServerInfo serverInfo {}; if (!test_tcp_host_connection(address, port, clientIdentityPointer, &resultMessage, &serverInfo)) { - for (app::HostRecord &mutableHost : state.hosts) { + 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.statusMessage = resultMessage; + state.shell.statusMessage = resultMessage; logging::warn("apps", resultMessage); return; } apply_server_info_to_host(state, address, port, serverInfo); - if (state.hostsDirty) { + if (state.hosts.dirty) { persist_hosts(state); } host = app::selected_host(state); if (host == nullptr || host->pairingState != app::PairingState::paired) { - state.statusMessage = host != nullptr && !host->appListStatusMessage.empty() ? host->appListStatusMessage : "This host is no longer paired. Pair it again before opening apps."; - logging::warn("apps", state.statusMessage); + 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.appsBrowseShowHidden)) { + 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.statusMessage.empty() ? "Failed to enter the apps screen" : state.statusMessage); + 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 @@ -2712,15 +2712,15 @@ namespace { const std::vector results = ui::drain_host_probe_results(&task->resultQueue); for (const ui::HostProbeResult &result : results) { if (result.success) { - if (state.activeScreen == app::ScreenId::hosts && state.hostsLoaded) { + 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.hostsDirty; + task->metadataChanged = task->metadataChanged || state.hosts.dirty; } ++task->onlineCount; continue; } - if (state.activeScreen == app::ScreenId::hosts && state.hostsLoaded) { + if (state.shell.activeScreen == app::ScreenId::hosts && state.hosts.loaded) { mark_host_offline(state, result.address, result.port); } ++task->offlineCount; @@ -2769,7 +2769,7 @@ namespace { } 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.activeScreen != app::ScreenId::hosts || !state.hostsLoaded || !network::runtime_network_ready()) { + 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) { @@ -2778,8 +2778,8 @@ namespace { reset_host_probe_task(task); task->clientIdentityAvailable = try_load_saved_pairing_identity(&task->clientIdentity); - ui::begin_host_probe_result_round(&task->resultQueue, state.hosts.size()); - for (const app::HostRecord &host : state.hosts) { + 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); @@ -2805,7 +2805,7 @@ namespace { } void pair_host_if_requested(app::ClientState &state, const app::AppUpdate &update, PairingTaskState *task) { - if (!update.pairingRequested || task == nullptr) { + if (!update.requests.pairingRequested || task == nullptr) { return; } @@ -2819,37 +2819,37 @@ namespace { std::string reachabilityMessage; network::HostPairingServerInfo serverInfo {}; network::PairingIdentity clientIdentity {}; - if (const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; !test_tcp_host_connection(update.pairingAddress, update.pairingPort, clientIdentityPointer, &reachabilityMessage, &serverInfo)) { - for (app::HostRecord &host : state.hosts) { - if (host.address == update.pairingAddress && app::effective_host_port(host.port) == app::effective_host_port(update.pairingPort)) { + if (const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; !test_tcp_host_connection(update.requests.pairingAddress, update.requests.pairingPort, clientIdentityPointer, &reachabilityMessage, &serverInfo)) { + for (app::HostRecord &host : state.hosts.items) { + if (host.address == update.requests.pairingAddress && app::effective_host_port(host.port) == app::effective_host_port(update.requests.pairingPort)) { host.reachability = app::HostReachability::offline; - host.manualAddress = update.pairingAddress; + host.manualAddress = update.requests.pairingAddress; break; } } - if (state.activeHostLoaded && app::host_matches_endpoint(state.activeHost, update.pairingAddress, update.pairingPort)) { - state.activeHost.reachability = app::HostReachability::offline; - state.activeHost.manualAddress = update.pairingAddress; + if (state.hosts.activeLoaded && app::host_matches_endpoint(state.hosts.active, update.requests.pairingAddress, update.requests.pairingPort)) { + state.hosts.active.reachability = app::HostReachability::offline; + state.hosts.active.manualAddress = update.requests.pairingAddress; } state.pairingDraft.stage = app::PairingStage::failed; state.pairingDraft.generatedPin.clear(); state.pairingDraft.statusMessage = reachabilityMessage.empty() ? "The host could not be reached for pairing." : reachabilityMessage; - state.statusMessage = state.pairingDraft.statusMessage; + state.shell.statusMessage = state.pairingDraft.statusMessage; logging::warn("pairing", state.pairingDraft.statusMessage); return; } - apply_server_info_to_host(state, update.pairingAddress, update.pairingPort, serverInfo); - if (state.hostsDirty) { + apply_server_info_to_host(state, update.requests.pairingAddress, update.requests.pairingPort, serverInfo); + if (state.hosts.dirty) { persist_hosts(state); } auto attempt = std::make_unique(); reset_pairing_attempt(attempt.get()); attempt->request = { - update.pairingAddress, - update.pairingPort, - update.pairingPin, + update.requests.pairingAddress, + update.requests.pairingPort, + update.requests.pairingPin, "MoonlightXboxOG", {}, }; @@ -2858,7 +2858,7 @@ namespace { 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.pairingAddress, update.pairingPort, false, createThreadError); + app::apply_pairing_result(state, update.requests.pairingAddress, update.requests.pairingPort, false, createThreadError); state.pairingDraft.generatedPin.clear(); logging::error("pairing", createThreadError); return; @@ -2868,8 +2868,8 @@ namespace { state.pairingDraft.stage = app::PairingStage::in_progress; state.pairingDraft.statusMessage = "The host is reachable. Enter the code shown below on the host and keep this screen open for the result."; - state.statusMessage.clear(); - logging::info("pairing", "Started background pairing with " + update.pairingAddress + ":" + std::to_string(update.pairingPort)); + 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 @@ -2949,7 +2949,7 @@ namespace { 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.hostsDirty) { + if (state.hosts.dirty) { persist_hosts(state); } return; @@ -2960,7 +2960,7 @@ namespace { } void start_app_list_task_if_needed(app::ClientState &state, AppListTaskState *task, Uint32 now) { - if (task == nullptr || app_list_task_is_active(*task) || state.activeScreen != app::ScreenId::apps) { + if (task == nullptr || app_list_task_is_active(*task) || state.shell.activeScreen != app::ScreenId::apps) { return; } @@ -2974,11 +2974,11 @@ namespace { return; } - if (state.activeHostLoaded) { - app::HostRecord &mutableHost = state.activeHost; + 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.statusMessage.clear(); + state.shell.statusMessage.clear(); } } @@ -2989,17 +2989,17 @@ namespace { 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.activeHostLoaded) { - state.activeHost.appListState = app::HostAppListState::failed; - state.activeHost.appListStatusMessage = errorMessage; - state.statusMessage = 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.activeHostLoaded) { - state.activeHost.lastAppListRefreshTick = now; + if (state.hosts.activeLoaded) { + state.hosts.active.lastAppListRefreshTick = now; } } @@ -3073,7 +3073,7 @@ namespace { } void start_app_art_task_if_needed(const app::ClientState &state, AppArtTaskState *task) { - if (task == nullptr || app_art_task_is_active(*task) || state.activeScreen != app::ScreenId::apps) { + if (task == nullptr || app_art_task_is_active(*task) || state.shell.activeScreen != app::ScreenId::apps) { return; } @@ -3101,11 +3101,11 @@ namespace { } void show_log_file_if_requested(app::ClientState &state, const app::AppUpdate &update) { - if (!update.logViewRequested) { + if (!update.requests.logViewRequested) { return; } - const std::string filePath = state.logFilePath.empty() ? logging::default_log_file_path() : state.logFilePath; + 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()) { @@ -3192,7 +3192,7 @@ namespace { if (!render_action_rows( renderer, bodyFont, - viewModel.detailMenuRows, + viewModel.content.detailMenuRows, optionsRect, std::max(34, TTF_FontLineSkip(bodyFont) + 12) )) { @@ -3208,15 +3208,15 @@ namespace { } int descriptionY = descriptionRect.y + descriptionHeaderHeight + 10; - if (!viewModel.selectedMenuRowLabel.empty()) { + if (!viewModel.content.selectedMenuRowLabel.empty()) { int drawnHeight = 0; - if (!render_text_line(renderer, bodyFont, viewModel.selectedMenuRowLabel, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, descriptionRect.x + 10, descriptionY, descriptionRect.w - 20, &drawnHeight)) { + 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.selectedMenuRowDescription.empty() ? std::string("No description is available for the selected setting.") : viewModel.selectedMenuRowDescription; + 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); } @@ -3228,29 +3228,29 @@ namespace { CoverArtTextureCache *textureCache, const AssetTextureCache *assetCache ) { - const int columnCount = std::max(1, static_cast(viewModel.appColumnCount)); + 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.appTiles.size(), viewModel.appColumnCount, selected_app_tile_index(viewModel.appTiles), std::max(1, gridRect.h - (gridPadding * 2)), 220, tileGap); + 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.appColumnCount; - const std::size_t endIndex = std::min(viewModel.appTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.appColumnCount); + 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.appColumnCount) - viewport.startRow; - const auto column = static_cast(index % viewModel.appColumnCount); + 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.appTiles[index], tileRect, textureCache, assetCache)) { + if (!render_app_cover(renderer, smallFont, viewModel.content.appTiles[index], tileRect, textureCache, assetCache)) { return false; } } @@ -3267,11 +3267,11 @@ namespace { 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.bodyLines, gridRect.w - 48, lineGap); + const int textHeight = measure_body_lines_height(smallFont, viewModel.content.bodyLines, gridRect.w - 48, lineGap); return render_body_lines( renderer, smallFont, - viewModel.bodyLines, + 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} ); } @@ -3349,21 +3349,21 @@ namespace { } } - if (!render_text_line(renderer, titleFont, viewModel.title, {TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, titleTextX, titleTextY, titleTextWidth)) { + 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.screen == app::ScreenId::apps ? render_text_line_simple(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3) : render_text_line(renderer, bodyFont, viewModel.pageTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, pageTitleX, pageTitleY, headerRect.w / 3); !viewModel.pageTitle.empty() && !renderedPageTitle) { + 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.screen == app::ScreenId::hosts) { + 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.toolbarButtons.size())); - for (const ui::ShellToolbarButton &button : viewModel.toolbarButtons) { + 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; } @@ -3372,8 +3372,8 @@ namespace { } int infoY = contentRect.y + 16; - if (viewModel.screen == app::ScreenId::hosts) { - for (const std::string &line : viewModel.bodyLines) { + 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; @@ -3382,29 +3382,29 @@ namespace { } } - if (viewModel.screen == app::ScreenId::hosts) { + 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.hostColumnCount)); + 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.hostTiles.size(), viewModel.hostColumnCount, selected_host_tile_index(viewModel.hostTiles), gridRect.h, 188, tileGap); + 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.hostColumnCount; - const std::size_t endIndex = std::min(viewModel.hostTiles.size(), static_cast(viewport.startRow + viewport.visibleRowCount) * viewModel.hostColumnCount); + 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.hostColumnCount) - viewport.startRow; - const auto column = static_cast(index % viewModel.hostColumnCount); + 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.hostTiles[index]; + 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); @@ -3443,7 +3443,7 @@ namespace { if (viewport.scrollbarWidth > 0) { render_grid_scrollbar(renderer, {gridRect.x + gridRect.w - viewport.scrollbarWidth, gridRect.y, viewport.scrollbarWidth, gridRect.h}, viewport); } - } else if (viewModel.screen == app::ScreenId::apps) { + } else if (viewModel.frame.screen == app::ScreenId::apps) { const SDL_Rect gridRect { contentRect.x + 16, contentRect.y + 16, @@ -3451,16 +3451,16 @@ namespace { contentRect.h - 28, }; - if (!viewModel.appTiles.empty()) { + if (!viewModel.content.appTiles.empty()) { if (!render_app_tiles_grid(renderer, smallFont, viewModel, gridRect, textureCache, assetCache)) { return false; } - } else if (!viewModel.bodyLines.empty() && !render_apps_empty_state(renderer, smallFont, viewModel, gridRect)) { + } else if (!viewModel.content.bodyLines.empty() && !render_apps_empty_state(renderer, smallFont, viewModel, gridRect)) { return false; } } else { - const bool settingsScreen = viewModel.screen == app::ScreenId::settings; - const bool hasDetailMenu = settingsScreen && !viewModel.detailMenuRows.empty(); + 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 { @@ -3477,18 +3477,18 @@ namespace { draw_rect( renderer, menuPanel, - viewModel.leftPanelActive ? ACCENT_RED : TEXT_RED, - viewModel.leftPanelActive ? ACCENT_GREEN : TEXT_GREEN, - viewModel.leftPanelActive ? ACCENT_BLUE : TEXT_BLUE, - viewModel.leftPanelActive ? 0xD8 : 0x48 + 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.rightPanelActive ? ACCENT_RED : TEXT_RED, - viewModel.rightPanelActive ? ACCENT_GREEN : TEXT_GREEN, - viewModel.rightPanelActive ? ACCENT_BLUE : TEXT_BLUE, - viewModel.rightPanelActive ? 0xD8 : 0x48 + 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)}; @@ -3500,7 +3500,7 @@ namespace { if (!render_action_rows( renderer, bodyFont, - viewModel.menuRows, + 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) )) { @@ -3515,7 +3515,7 @@ namespace { if (!render_body_lines( renderer, bodyFont, - viewModel.bodyLines, + viewModel.content.bodyLines, {{TEXT_RED, TEXT_GREEN, TEXT_BLUE, 0xFF}, bodyPanel.x + panelPadding, bodyPanel.y + panelPadding, bodyPanel.w - (panelPadding * 2), 8} )) { return false; @@ -3523,15 +3523,15 @@ namespace { } } - if (!render_footer_actions(renderer, smallFont, assetCache, viewModel.footerActions, footerRect)) { + if (!render_footer_actions(renderer, smallFont, assetCache, viewModel.frame.footerActions, footerRect)) { return false; } - if (viewModel.notificationVisible && !viewModel.notification.message.empty() && !render_notification(renderer, bodyFont, smallFont, assetCache, viewModel.notification, screenWidth, footerRect.y, outerMargin)) { + 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.overlayVisible) { + if (viewModel.overlay.visible) { const int overlayX = (screenWidth / 2) + (panelGap / 2); const SDL_Rect overlayRect { overlayX, @@ -3543,12 +3543,12 @@ namespace { 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.overlayTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, overlayRect.x + 16, overlayRect.y + 16, overlayRect.w - 32)) { + 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.overlayLines) { + 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; @@ -3557,7 +3557,7 @@ namespace { } } - if (viewModel.modalVisible && viewModel.logViewerVisible) { + 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); @@ -3573,7 +3573,7 @@ namespace { 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.modalVisible) { + } 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 { @@ -3584,12 +3584,12 @@ namespace { }; 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.modalTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.x + 16, modalRect.y + 16, modalRect.w - 32)) { + 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.modalLines) { + 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; @@ -3597,27 +3597,27 @@ namespace { modalY += drawnHeight + 6; } - if (!viewModel.modalActions.empty()) { - if (!render_action_rows(renderer, bodyFont, viewModel.modalActions, {modalRect.x + 16, modalY + 8, modalRect.w - 32, modalRect.h - (modalY - modalRect.y) - 24}, std::max(34, TTF_FontLineSkip(bodyFont) + 12))) { + 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.modalFooterActions.empty()) { + } 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.modalFooterActions, modalFooterRect)) { + if (!render_footer_actions(renderer, smallFont, assetCache, viewModel.modal.footerActions, modalFooterRect)) { return false; } } } - if (viewModel.keypadModalVisible) { + 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.keypadModalColumnCount)); - const int buttonRowCount = std::max(1, static_cast((viewModel.keypadModalButtons.size() + viewModel.keypadModalColumnCount - 1) / viewModel.keypadModalColumnCount)); + 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); @@ -3635,15 +3635,15 @@ namespace { draw_rect(renderer, modalRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE); if ( - !ensure_wrapped_text_texture(renderer, bodyFont, viewModel.keypadModalTitle, {ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0xFF}, modalRect.w - 32, &keypadModalLayoutCache->titleTexture) || + !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.keypadModalLines.size()); - for (std::size_t index = 0; index < viewModel.keypadModalLines.size(); ++index) { + 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; @@ -3656,23 +3656,23 @@ namespace { 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.keypadModalButtons.size()) { - for (std::size_t index = viewModel.keypadModalButtons.size(); index < keypadModalLayoutCache->buttonLabelTextures.size(); ++index) { + 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.keypadModalButtons.size()); + keypadModalLayoutCache->buttonLabelTextures.resize(viewModel.keypad.buttons.size()); - for (std::size_t index = 0; index < viewModel.keypadModalButtons.size(); ++index) { - const auto row = static_cast(index / viewModel.keypadModalColumnCount); - const auto column = static_cast(index % viewModel.keypadModalColumnCount); + 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.keypadModalButtons[index]; + const ui::ShellModalButton &button = viewModel.keypad.buttons[index]; if (button.selected) { fill_rect(renderer, buttonRect, ACCENT_RED, ACCENT_GREEN, ACCENT_BLUE, 0x55); @@ -3711,7 +3711,7 @@ namespace { } bool hosts_screen_exit_combo_allowed(const app::ClientState &state) { - return state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts; + 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) { @@ -3787,7 +3787,7 @@ namespace { } inputState->controllerExitComboTriggered = true; - state.shouldExit = true; + state.shell.shouldExit = true; logging::info("app", "Exit requested from held Start+Back on the hosts screen"); } @@ -3933,7 +3933,7 @@ namespace { ) { switch (event.type) { case SDL_QUIT: - state.shouldExit = true; + state.shell.shouldExit = true; return input::UiCommand::none; case SDL_CONTROLLERDEVICEADDED: handle_controller_device_added(controller, event.cdevice); @@ -4230,8 +4230,8 @@ namespace { 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.loggingLevel); - logging::set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); + logging::set_file_minimum_level(state.settings.loggingLevel); + logging::set_debugger_console_minimum_level(state.settings.xemuConsoleLoggingLevel); logging::info("app", "Entered interactive shell"); } @@ -4258,7 +4258,7 @@ namespace { report_shell_failure("render", std::string("Shell render failed: ") + SDL_GetError()); runtime->running = false; - state.shouldExit = true; + state.shell.shouldExit = true; return false; } @@ -4319,10 +4319,10 @@ namespace { runtime->keypadRedrawRequested = true; - const app::ScreenId previousScreen = state.activeScreen; + const app::ScreenId previousScreen = state.shell.activeScreen; const app::AppUpdate update = app::handle_command(state, command); - logging::set_file_minimum_level(state.loggingLevel); - logging::set_debugger_console_minimum_level(state.xemuConsoleLoggingLevel); + 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); @@ -4336,14 +4336,14 @@ namespace { persist_settings_if_needed(state, update); persist_hosts_if_needed(state, update); - if (previousScreen != state.activeScreen) { - release_page_resources_for_screen(previousScreen, state.activeScreen, &resources->coverArtTextureCache, &resources->keypadModalLayoutCache); + 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.activeScreen || update.screenChanged) && !draw_current_shell_frame(videoMode, state, resources, runtime)) { + if ((previousScreen != state.shell.activeScreen || update.navigation.screenChanged) && !draw_current_shell_frame(videoMode, state, resources, runtime)) { return; } - if (state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { + if (state.shell.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible) { clear_keypad_modal_layout_cache(&resources->keypadModalLayoutCache); } } @@ -4381,12 +4381,12 @@ namespace { finish_shell_background_tasks(state, resources, runtime); start_shell_background_tasks_if_needed(state, runtime, SDL_GetTicks()); - if ((state.activeScreen != app::ScreenId::add_host || !state.addHostDraft.keypad.visible || runtime->keypadRedrawRequested) && + 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.shouldExit; + return runtime->running && !state.shell.shouldExit; } /** @@ -4445,7 +4445,7 @@ namespace ui { ShellRuntimeState runtime {}; initialize_shell_runtime(state, &runtime); - while (runtime.running && !state.shouldExit) { + while (runtime.running && !state.shell.shouldExit) { if (!run_shell_frame(videoMode, state, &resources, &runtime)) { break; } diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index 688d4bc..cb80016 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -13,7 +13,7 @@ namespace { } bool screen_supports_notifications(const app::ClientState &state) { - return state.activeScreen == app::ScreenId::home || state.activeScreen == app::ScreenId::hosts || state.activeScreen == app::ScreenId::apps || state.activeScreen == app::ScreenId::settings; + 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) { @@ -27,17 +27,17 @@ namespace { } bool is_minor_status_message(const app::ClientState &state) { - if (state.statusMessage.empty()) { + if (state.shell.statusMessage.empty()) { return true; } - if (starts_with(state.statusMessage, "Loaded recent log file lines") || starts_with(state.statusMessage, "No log file has been written yet") || starts_with(state.statusMessage, "Testing connection to ") || starts_with(state.statusMessage, "Editing host ") || starts_with(state.statusMessage, "Updated host ") || starts_with(state.statusMessage, "Cancelled host ") || starts_with(state.statusMessage, "Using default Moonlight host port") || starts_with(state.statusMessage, "Loading apps for ") || starts_with(state.statusMessage, "Pairing is preparing the client identity")) { + 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.activeScreen == app::ScreenId::apps) { + 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.statusMessage == host->appListStatusMessage; + return host->appListState == app::HostAppListState::loading && state.shell.statusMessage == host->appListStatusMessage; } } @@ -45,7 +45,7 @@ namespace { } std::string page_title(const app::ClientState &state) { - switch (state.activeScreen) { + switch (state.shell.activeScreen) { case app::ScreenId::home: case app::ScreenId::hosts: return {}; @@ -90,17 +90,17 @@ namespace { std::vector toolbar_buttons(const app::ClientState &state) { return { - {"settings", "Settings", "G", "icons\\gear.svg", state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::toolbar && state.selectedToolbarButtonIndex % 3U == 0U}, - {"support", "Support", "?", "icons\\support.svg", state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::toolbar && state.selectedToolbarButtonIndex % 3U == 1U}, - {"add-host", "Add Host", "+", "icons\\add-host.svg", state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::toolbar && state.selectedToolbarButtonIndex % 3U == 2U}, + {"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.size()); - for (std::size_t index = 0; index < state.hosts.size(); ++index) { - const app::HostRecord &host = state.hosts[index]; + 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, @@ -108,7 +108,7 @@ namespace { host_tile_icon(host), host.pairingState, host.reachability, - state.activeScreen == app::ScreenId::hosts && state.hostsFocusArea == app::HostsFocusArea::grid && index == state.selectedHostIndex, + state.shell.activeScreen == app::ScreenId::hosts && state.hosts.focusArea == app::HostsFocusArea::grid && index == state.hosts.selectedHostIndex, }); } return tiles; @@ -123,7 +123,7 @@ namespace { for (std::size_t index = 0, visibleIndex = 0; index < host->apps.size(); ++index) { const app::HostAppRecord &appRecord = host->apps[index]; - if (!state.showHiddenApps && appRecord.hidden) { + if (!state.apps.showHiddenApps && appRecord.hidden) { continue; } @@ -148,7 +148,7 @@ namespace { appRecord.favorite, appRecord.boxArtCached, appRecord.running, - state.activeScreen == app::ScreenId::apps && visibleIndex == state.selectedAppIndex, + state.shell.activeScreen == app::ScreenId::apps && visibleIndex == state.apps.selectedAppIndex, }); ++visibleIndex; } @@ -222,7 +222,7 @@ namespace { } std::vector hosts_body_lines(const app::ClientState &state) { - if (state.hosts.empty()) { + if (state.hosts.items.empty()) { return { "No PCs have been added yet.", "Use Add Host to save a host manually.", @@ -297,30 +297,30 @@ namespace { std::vector settings_body_lines(const app::ClientState &state) { std::vector lines = { - std::string("Category: ") + settings_category_label(state.selectedSettingsCategory), + std::string("Category: ") + settings_category_label(state.settings.selectedCategory), }; - if (state.selectedSettingsCategory == app::SettingsCategory::logging) { + 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.logFilePath.empty() ? "not configured" : state.logFilePath)); - lines.push_back(std::string("File logging level: ") + logging::to_string(state.loggingLevel)); - lines.push_back(std::string("xemu console logging level: ") + logging::to_string(state.xemuConsoleLoggingLevel)); + 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.selectedSettingsCategory == app::SettingsCategory::reset) { - if (state.savedFiles.empty()) { + 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.savedFiles) { + for (const startup::SavedFileEntry &savedFile : state.settings.savedFiles) { lines.push_back("- " + savedFile.displayName + " (" + format_file_size(savedFile.sizeBytes) + ")"); } return lines; } - if (state.selectedSettingsCategory == app::SettingsCategory::display) { + if (state.settings.selectedCategory == app::SettingsCategory::display) { lines.emplace_back("Display options will be added here."); return lines; } @@ -330,7 +330,7 @@ namespace { } std::vector body_lines(const app::ClientState &state) { - switch (state.activeScreen) { + switch (state.shell.activeScreen) { case app::ScreenId::home: case app::ScreenId::hosts: return hosts_body_lines(state); @@ -370,7 +370,7 @@ namespace { item.id, item.label, item.enabled, - state.settingsFocusArea == app::SettingsFocusArea::options && selectedItem != nullptr && selectedItem->id == item.id, + state.settings.focusArea == app::SettingsFocusArea::options && selectedItem != nullptr && selectedItem->id == item.id, false, }); } @@ -380,7 +380,7 @@ namespace { ui::ShellNotification notification(const app::ClientState &state) { return { "Notification", - state.statusMessage, + state.shell.statusMessage, { {"dismiss-notification", "Dismiss", "icons\\button-x.svg", {}, false}, }, @@ -388,21 +388,21 @@ namespace { } void fill_support_modal_view(ui::ShellViewModel *viewModel) { - viewModel->modalTitle = "Support"; - viewModel->modalLines = { + 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->modalFooterActions = { + 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->modalTitle = "Host Actions"; - viewModel->modalActions = { + 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}, @@ -411,7 +411,7 @@ namespace { } void fill_host_details_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { - viewModel->modalTitle = "Host Details"; + viewModel->modal.title = "Host Details"; const app::HostRecord *host = app::selected_host(state); if (host == nullptr) { return; @@ -424,7 +424,7 @@ namespace { reachabilityLabel = "OFFLINE"; } - viewModel->modalLines = { + viewModel->modal.lines = { "Name: " + host->displayName, std::string("State: ") + reachabilityLabel, std::string("Active Address: ") + (host->activeAddress.empty() ? "NULL" : host->activeAddress), @@ -442,8 +442,8 @@ namespace { 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->modalTitle = appRecord->name; - viewModel->modalActions = { + 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}, @@ -453,8 +453,8 @@ namespace { 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->modalTitle = "App Details"; - viewModel->modalLines = { + 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), @@ -463,13 +463,13 @@ namespace { } void fill_log_viewer_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { - viewModel->modalTitle = "Log File"; - viewModel->logViewerVisible = true; - viewModel->logViewerPath = state.logFilePath.empty() ? "not configured" : state.logFilePath; - viewModel->logViewerLines = state.logViewerLines; - viewModel->logViewerScrollOffset = state.logViewerScrollOffset; - viewModel->logViewerPlacement = state.logViewerPlacement; - viewModel->modalFooterActions = { + 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}, @@ -480,9 +480,9 @@ namespace { } void fill_confirmation_modal_view(const app::ClientState &state, ui::ShellViewModel *viewModel) { - viewModel->modalTitle = state.confirmation.title; - viewModel->modalLines = state.confirmation.lines; - viewModel->modalFooterActions = { + 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}, }; @@ -493,7 +493,7 @@ namespace { return; } - viewModel->modalVisible = true; + viewModel->modal.visible = true; switch (state.modal.id) { case app::ModalId::support: fill_support_modal_view(viewModel); @@ -522,12 +522,12 @@ namespace { } std::vector footer_actions(const app::ClientState &state) { - switch (state.activeScreen) { + switch (state.shell.activeScreen) { case app::ScreenId::home: case app::ScreenId::hosts: { std::string openLabel = "Pair"; - if (state.hostsFocusArea == app::HostsFocusArea::toolbar) { + 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"; @@ -535,7 +535,7 @@ namespace { std::vector actions = { {"open", std::move(openLabel), "icons\\button-a.svg", {}, true}, }; - if (state.hostsFocusArea == app::HostsFocusArea::grid && app::selected_host(state) != nullptr) { + 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}); @@ -571,7 +571,7 @@ namespace { case app::ScreenId::settings: return { {"select", "Select", "icons\\button-a.svg", {}, true}, - {"back", state.settingsFocusArea == app::SettingsFocusArea::options ? "Categories" : "Back", "icons\\button-b.svg", {}, false}, + {"back", state.settings.focusArea == app::SettingsFocusArea::options ? "Categories" : "Back", "icons\\button-b.svg", {}, false}, }; } @@ -587,54 +587,54 @@ namespace ui { } void fill_view_model_panel_state(const app::ClientState &state, ShellViewModel *viewModel) { - if (!screen_uses_split_menu_layout(state.activeScreen)) { + if (!screen_uses_split_menu_layout(state.shell.activeScreen)) { return; } - viewModel->leftPanelActive = state.activeScreen != app::ScreenId::settings || state.settingsFocusArea == app::SettingsFocusArea::categories; - viewModel->rightPanelActive = state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options; + 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; } void fill_view_model_selected_menu_details(const app::ClientState &state, ShellViewModel *viewModel) { - if (!screen_uses_split_menu_layout(state.activeScreen)) { + if (!screen_uses_split_menu_layout(state.shell.activeScreen)) { return; } - if (state.activeScreen == app::ScreenId::settings && state.settingsFocusArea == app::SettingsFocusArea::options && state.detailMenu.selected_item() != nullptr) { - viewModel->selectedMenuRowLabel = state.detailMenu.selected_item()->label; - viewModel->selectedMenuRowDescription = state.detailMenu.selected_item()->description; + 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->selectedMenuRowLabel = state.menu.selected_item()->label; - viewModel->selectedMenuRowDescription = state.menu.selected_item()->description; + viewModel->content.selectedMenuRowLabel = state.menu.selected_item()->label; + viewModel->content.selectedMenuRowDescription = state.menu.selected_item()->description; } } void fill_view_model_overlay(const app::ClientState &state, const std::vector &logEntries, const std::vector &statsLines, ShellViewModel *viewModel) { - if (!viewModel->overlayVisible) { + if (!viewModel->overlay.visible) { return; } if (!statsLines.empty()) { - viewModel->overlayLines.insert(viewModel->overlayLines.end(), statsLines.begin(), statsLines.end()); + viewModel->overlay.lines.insert(viewModel->overlay.lines.end(), statsLines.begin(), statsLines.end()); } else { - viewModel->overlayLines.emplace_back("No active stream"); + 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.overlayScrollOffset, maxOffset); + 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->overlayLines.push_back(logging::format_entry(logEntries[index])); + viewModel->overlay.lines.push_back(logging::format_entry(logEntries[index])); } if (clampedOffset > 0U) { - viewModel->overlayLines.emplace(viewModel->overlayLines.begin(), "Showing earlier log entries"); + viewModel->overlay.lines.emplace(viewModel->overlay.lines.begin(), "Showing earlier log entries"); } } @@ -644,34 +644,34 @@ namespace ui { const std::vector &statsLines ) { ShellViewModel viewModel {}; - viewModel.screen = state.activeScreen; - viewModel.title = "Moonlight"; - viewModel.pageTitle = page_title(state); - viewModel.statusMessage = state.statusMessage; - viewModel.notificationVisible = screen_supports_notifications(state) && !state.statusMessage.empty() && !is_minor_status_message(state) && !state.modal.active() && !(state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible); - if (viewModel.notificationVisible) { - viewModel.notification = notification(state); - } - viewModel.hostColumnCount = 3U; - viewModel.appColumnCount = 4U; - viewModel.toolbarButtons = toolbar_buttons(state); - viewModel.hostTiles = host_tiles(state); - viewModel.appTiles = app_tiles(state); - viewModel.bodyLines = body_lines(state); - viewModel.menuRows = menu_rows(state); - viewModel.detailMenuRows = detail_menu_rows(state); + 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.footerActions = footer_actions(state); - viewModel.overlayVisible = state.overlayVisible; - viewModel.overlayTitle = "Diagnostics"; - viewModel.keypadModalVisible = state.activeScreen == app::ScreenId::add_host && state.addHostDraft.keypad.visible; - viewModel.keypadModalTitle = state.addHostDraft.activeField == app::AddHostField::address ? "Address Keypad" : "Port Keypad"; - viewModel.keypadModalColumnCount = 3; - - if (viewModel.keypadModalVisible) { - viewModel.keypadModalLines = keypad_modal_lines(state); - viewModel.keypadModalButtons = keypad_buttons(state); + 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); diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h index 68be6d7..6d71d28 100644 --- a/src/ui/shell_view.h +++ b/src/ui/shell_view.h @@ -92,20 +92,25 @@ namespace ui { }; /** - * @brief Render-ready shell state derived from the app model. + * @brief Shell-wide frame metadata shared across all render paths. */ - struct ShellViewModel { ///< NOSONAR(cpp:S1820) single-frame render snapshot intentionally groups all shell sections + 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. - bool notificationVisible = false; ///< True when a transient notification should be rendered. - ShellNotification notification; ///< Notification content when visible. + 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 = 3; ///< Number of columns used to lay out host tiles. + 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 = 4; ///< Number of columns used to lay out app tiles. + 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. @@ -113,25 +118,69 @@ namespace ui { 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. - std::vector footerActions; ///< Footer actions shown for the current screen. - bool overlayVisible = false; ///< True when the diagnostics overlay should be rendered. - std::string overlayTitle; ///< Diagnostics overlay title. - std::vector overlayLines; ///< Diagnostics overlay body lines. - bool modalVisible = false; ///< True when a modal dialog should be rendered. - std::string modalTitle; ///< Modal dialog title. - std::vector modalLines; ///< Modal dialog body lines. - std::vector modalActions; ///< Modal action rows. - std::vector modalFooterActions; ///< Footer actions displayed while a modal is open. - bool logViewerVisible = false; ///< True when the log viewer should be rendered. - std::string logViewerPath; ///< Path of the currently loaded log file. - std::vector logViewerLines; ///< Loaded log lines shown in the viewer. - std::size_t logViewerScrollOffset = 0U; ///< Vertical scroll offset inside the log viewer. - app::LogViewerPlacement logViewerPlacement = app::LogViewerPlacement::full; ///< Placement of the log viewer pane. - bool keypadModalVisible = false; ///< True when the keypad modal should be rendered. - std::string keypadModalTitle; ///< Title shown at the top of the keypad modal. - std::vector keypadModalLines; ///< Instruction and draft lines shown in the keypad modal. - std::vector keypadModalButtons; ///< Buttons rendered inside the keypad modal. - std::size_t keypadModalColumnCount = 0; ///< Number of columns used to lay out keypad buttons. + }; + + /** + * @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. }; /** diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index 0262e5a..b331ada 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -13,13 +13,13 @@ namespace { TEST(ClientStateTest, StartsOnTheHostsScreenWithTheToolbarSelected) { const app::ClientState state = app::create_initial_state(); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - EXPECT_EQ(state.hostsFocusArea, app::HostsFocusArea::toolbar); - EXPECT_EQ(state.selectedToolbarButtonIndex, 2U); - EXPECT_FALSE(state.overlayVisible); - EXPECT_FALSE(state.shouldExit); - EXPECT_FALSE(state.hostsDirty); - EXPECT_EQ(state.loggingLevel, logging::LogLevel::none); + 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) { @@ -32,10 +32,10 @@ namespace { "Loaded 2 saved host(s)"); ASSERT_EQ(state.hosts.size(), 2U); - EXPECT_FALSE(state.hostsDirty); - EXPECT_EQ(state.statusMessage, "Loaded 2 saved host(s)"); - EXPECT_EQ(state.hostsFocusArea, app::HostsFocusArea::grid); - EXPECT_EQ(state.selectedHostIndex, 0U); + 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) { @@ -45,18 +45,18 @@ namespace { app::handle_command(state, input::UiCommand::move_left); app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(update.activatedItemId, "settings-button"); - EXPECT_EQ(state.activeScreen, app::ScreenId::settings); + 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.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(update.activatedItemId, "add-host-button"); - EXPECT_EQ(state.activeScreen, app::ScreenId::add_host); + 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) { @@ -66,46 +66,46 @@ namespace { app::handle_command(state, input::UiCommand::move_left); app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - ASSERT_EQ(state.activeScreen, app::ScreenId::settings); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); update = app::handle_command(state, input::UiCommand::activate); - EXPECT_EQ(state.selectedSettingsCategory, app::SettingsCategory::logging); - EXPECT_EQ(state.settingsFocusArea, app::SettingsFocusArea::options); + 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.logViewRequested); - EXPECT_EQ(update.activatedItemId, "view-log-file"); + 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.logViewRequested); - EXPECT_TRUE(update.settingsChanged); - EXPECT_EQ(state.loggingLevel, logging::LogLevel::error); - EXPECT_EQ(state.statusMessage, "Logging level set to ERROR"); + 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.settingsChanged); - EXPECT_EQ(state.xemuConsoleLoggingLevel, logging::LogLevel::error); - EXPECT_EQ(state.statusMessage, "xemu console logging level set to ERROR"); + 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.overlayChanged); - EXPECT_TRUE(update.overlayVisibilityChanged); - EXPECT_TRUE(state.overlayVisible); - EXPECT_EQ(state.overlayScrollOffset, 0U); + 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.overlayChanged); - EXPECT_GT(state.overlayScrollOffset, 0U); + EXPECT_TRUE(update.navigation.overlayChanged); + EXPECT_GT(state.shell.overlayScrollOffset, 0U); update = app::handle_command(state, input::UiCommand::next_page); - EXPECT_TRUE(update.overlayChanged); - EXPECT_EQ(state.overlayScrollOffset, 0U); + EXPECT_TRUE(update.navigation.overlayChanged); + EXPECT_EQ(state.shell.overlayScrollOffset, 0U); } TEST(ClientStateTest, BackFromHostsDoesNotRequestShutdown) { @@ -113,63 +113,63 @@ namespace { const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); - EXPECT_FALSE(update.exitRequested); - EXPECT_FALSE(state.shouldExit); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + 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.hostsFocusArea = app::HostsFocusArea::toolbar; - state.selectedToolbarButtonIndex = 2U; + state.hosts.focusArea = app::HostsFocusArea::toolbar; + state.hosts.selectedToolbarButtonIndex = 2U; app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); + 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.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + 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.selectedHostIndex, 0U); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); } TEST(ClientStateTest, RejectsDuplicateHostEntriesAndAllowsCancellationBackToHosts) { app::ClientState state = app::create_initial_state(); - state.hostsFocusArea = app::HostsFocusArea::toolbar; - state.selectedToolbarButtonIndex = 2U; + 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.activeScreen, app::ScreenId::hosts); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::hosts); - state.hostsFocusArea = app::HostsFocusArea::toolbar; - state.selectedToolbarButtonIndex = 2U; + state.hosts.focusArea = app::HostsFocusArea::toolbar; + state.hosts.selectedToolbarButtonIndex = 2U; app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.screenChanged); - ASSERT_EQ(state.activeScreen, app::ScreenId::add_host); + 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.screenChanged); + 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.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); } TEST(ClientStateTest, SelectingAnUnpairedHostStartsPairing) { @@ -181,15 +181,15 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.screenChanged); - EXPECT_TRUE(update.pairingRequested); - EXPECT_EQ(update.pairingAddress, test_support::kTestIpv4Addresses[test_support::kIpLivingRoom]); - EXPECT_EQ(update.pairingPort, app::DEFAULT_HOST_PORT); - EXPECT_TRUE(app::is_valid_pairing_pin(update.pairingPin)); - EXPECT_EQ(state.activeScreen, app::ScreenId::pair_host); - EXPECT_FALSE(state.hostsLoaded); + 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_FALSE(state.hosts.loaded); EXPECT_TRUE(state.hosts.empty()); - EXPECT_TRUE(state.activeHostLoaded); + EXPECT_TRUE(state.hosts.activeLoaded); } TEST(ClientStateTest, SelectingAnOfflineUnpairedHostDoesNotOpenThePairingScreen) { @@ -201,10 +201,10 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_FALSE(update.screenChanged); - EXPECT_FALSE(update.pairingRequested); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - EXPECT_EQ(state.statusMessage, "Host is offline. Bring it online before pairing."); + 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) { @@ -215,14 +215,14 @@ namespace { app::handle_command(state, input::UiCommand::move_down); app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - ASSERT_EQ(state.activeScreen, app::ScreenId::pair_host); - ASSERT_TRUE(update.pairingRequested); + 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.screenChanged); - EXPECT_TRUE(update.pairingCancelledRequested); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + EXPECT_TRUE(update.navigation.screenChanged); + EXPECT_TRUE(update.requests.pairingCancelledRequested); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); } TEST(ClientStateTest, HostGridNavigationMatchesTheRenderedThreeColumnLayout) { @@ -234,13 +234,13 @@ namespace { {"Host D", test_support::kTestIpv4Addresses[test_support::kIpHostGridD], 0, app::PairingState::paired, app::HostReachability::online}, }); - EXPECT_EQ(state.selectedHostIndex, 0U); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); app::handle_command(state, input::UiCommand::move_right); - EXPECT_EQ(state.selectedHostIndex, 1U); + EXPECT_EQ(state.hosts.selectedHostIndex, 1U); app::handle_command(state, input::UiCommand::move_right); - EXPECT_EQ(state.selectedHostIndex, 2U); + EXPECT_EQ(state.hosts.selectedHostIndex, 2U); app::handle_command(state, input::UiCommand::move_down); - EXPECT_EQ(state.selectedHostIndex, 3U); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); } TEST(ClientStateTest, HostGridCanMoveDownIntoAPartialNextRowFromAnyColumn) { @@ -253,13 +253,13 @@ namespace { }); app::handle_command(state, input::UiCommand::move_right); - EXPECT_EQ(state.selectedHostIndex, 1U); + EXPECT_EQ(state.hosts.selectedHostIndex, 1U); app::handle_command(state, input::UiCommand::move_down); - EXPECT_EQ(state.selectedHostIndex, 3U); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); - state.selectedHostIndex = 2U; + state.hosts.selectedHostIndex = 2U; app::handle_command(state, input::UiCommand::move_down); - EXPECT_EQ(state.selectedHostIndex, 3U); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); } TEST(ClientStateTest, HostGridWrapsRightToTheNextRowAndLeftToThePreviousRow) { @@ -272,12 +272,12 @@ namespace { {"Host E", test_support::kTestIpv4Addresses[test_support::kIpHostGridE], 0, app::PairingState::paired, app::HostReachability::online}, }); - state.selectedHostIndex = 2U; + state.hosts.selectedHostIndex = 2U; app::handle_command(state, input::UiCommand::move_right); - EXPECT_EQ(state.selectedHostIndex, 3U); + EXPECT_EQ(state.hosts.selectedHostIndex, 3U); app::handle_command(state, input::UiCommand::move_left); - EXPECT_EQ(state.selectedHostIndex, 2U); + EXPECT_EQ(state.hosts.selectedHostIndex, 2U); } TEST(ClientStateTest, SelectingAPairedHostOpensTheAppsScreen) { @@ -289,10 +289,10 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_FALSE(update.screenChanged); - EXPECT_TRUE(update.appsBrowseRequested); - EXPECT_FALSE(update.appsBrowseShowHidden); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); + 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) { @@ -303,11 +303,11 @@ namespace { ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - EXPECT_EQ(state.activeScreen, app::ScreenId::apps); - EXPECT_FALSE(state.hostsLoaded); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::apps); + EXPECT_FALSE(state.hosts.loaded); EXPECT_TRUE(state.hosts.empty()); - EXPECT_TRUE(state.activeHostLoaded); - EXPECT_EQ(state.activeHost.address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); + EXPECT_TRUE(state.hosts.activeLoaded); + EXPECT_EQ(state.hosts.active.address, test_support::kTestIpv4Addresses[test_support::kIpOffice]); } TEST(ClientStateTest, SelectingAnOfflinePairedHostDoesNotOpenTheAppsScreen) { @@ -319,10 +319,10 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_FALSE(update.screenChanged); - EXPECT_TRUE(update.appsBrowseRequested); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - EXPECT_TRUE(state.statusMessage.empty()); + 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) { @@ -332,8 +332,8 @@ namespace { }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - state.activeHost.runningGameId = 101; - state.activeHost.apps = { + state.hosts.active.runningGameId = 101; + state.hosts.active.apps = { {"Steam", 101, false, true, true, "cached-steam", true, false}, }; @@ -345,14 +345,14 @@ namespace { true, "Loaded 2 app(s)"); - ASSERT_EQ(state.activeHost.apps.size(), 2U); - EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::ready); - EXPECT_TRUE(state.activeHost.apps[0].hidden); - EXPECT_TRUE(state.activeHost.apps[0].favorite); - EXPECT_TRUE(state.activeHost.apps[0].boxArtCached); - EXPECT_TRUE(state.activeHost.apps[0].running); - EXPECT_TRUE(state.activeHost.apps[1].boxArtCached); - EXPECT_TRUE(state.statusMessage.empty()); + 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) { @@ -371,10 +371,10 @@ namespace { true, "Loaded 1 app(s)"); - ASSERT_EQ(state.activeHost.apps.size(), 1U); - EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::ready); - EXPECT_EQ(state.activeHost.apps.front().name, "Steam"); - EXPECT_TRUE(state.statusMessage.empty()); + 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) { @@ -384,15 +384,15 @@ namespace { }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - state.activeHost.httpsPort = test_support::kTestPorts[test_support::kPortResolvedHttps]; - state.activeHost.apps = { + 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.activeHost.apps.size(), 1U); - EXPECT_TRUE(state.activeHost.apps.front().boxArtCached); + ASSERT_EQ(state.hosts.active.apps.size(), 1U); + EXPECT_TRUE(state.hosts.active.apps.front().boxArtCached); } TEST(ClientStateTest, SuccessfulAppListRefreshMarksHostsDirtyForPersistence) { @@ -401,7 +401,7 @@ namespace { {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, }); - ASSERT_FALSE(state.hostsDirty); + 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], { @@ -411,9 +411,9 @@ namespace { true, "Loaded 1 app(s)"); - EXPECT_TRUE(state.hostsDirty); - ASSERT_EQ(state.activeHost.apps.size(), 1U); - EXPECT_EQ(state.activeHost.appListContentHash, 0xACEDU); + EXPECT_TRUE(state.hosts.dirty); + ASSERT_EQ(state.hosts.active.apps.size(), 1U); + EXPECT_EQ(state.hosts.active.appListContentHash, 0xACEDU); } TEST(ClientStateTest, FailedRefreshKeepsCachedAppsAvailable) { @@ -423,17 +423,17 @@ namespace { }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - state.activeHost.apps = { + state.hosts.active.apps = { {"Steam", 101, false, false, false, "cached-steam", true, false}, }; - state.activeHost.appListContentHash = 0x1234U; + 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.activeHost.appListState, app::HostAppListState::ready); - ASSERT_EQ(state.activeHost.apps.size(), 1U); - EXPECT_EQ(state.activeHost.apps.front().name, "Steam"); - EXPECT_EQ(state.statusMessage, "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) { @@ -443,17 +443,17 @@ namespace { }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - state.activeHost.apps = { + state.hosts.active.apps = { {"Steam", 101, false, false, false, "cached-steam", true, false}, }; - state.activeHost.appListState = app::HostAppListState::ready; + state.hosts.active.appListState = app::HostAppListState::ready; const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); - EXPECT_TRUE(update.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - EXPECT_TRUE(state.activeHost.apps.empty()); - EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::idle); + 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) { @@ -539,16 +539,16 @@ namespace { true, "Loaded 5 app(s)"); - state.selectedAppIndex = 3U; + state.apps.selectedAppIndex = 3U; app::handle_command(state, input::UiCommand::move_right); - EXPECT_EQ(state.selectedAppIndex, 4U); + EXPECT_EQ(state.apps.selectedAppIndex, 4U); app::handle_command(state, input::UiCommand::move_left); - EXPECT_EQ(state.selectedAppIndex, 3U); + EXPECT_EQ(state.apps.selectedAppIndex, 3U); - state.selectedAppIndex = 2U; + state.apps.selectedAppIndex = 2U; app::handle_command(state, input::UiCommand::move_down); - EXPECT_EQ(state.selectedAppIndex, 4U); + EXPECT_EQ(state.apps.selectedAppIndex, 4U); } TEST(ClientStateTest, LogViewerCanScrollAndCyclePlacement) { @@ -556,27 +556,27 @@ namespace { 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.statusMessage, "Loaded log file preview"); - EXPECT_EQ(state.logViewerPlacement, app::LogViewerPlacement::full); - EXPECT_EQ(state.logViewerScrollOffset, 0U); + 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.logViewerScrollOffset, 1U); + EXPECT_EQ(state.settings.logViewerScrollOffset, 1U); app::handle_command(state, input::UiCommand::fast_previous_page); - EXPECT_EQ(state.logViewerScrollOffset, 3U); + EXPECT_EQ(state.settings.logViewerScrollOffset, 3U); app::handle_command(state, input::UiCommand::next_page); - EXPECT_EQ(state.logViewerScrollOffset, 2U); + EXPECT_EQ(state.settings.logViewerScrollOffset, 2U); app::handle_command(state, input::UiCommand::delete_character); - EXPECT_EQ(state.logViewerPlacement, app::LogViewerPlacement::left); + EXPECT_EQ(state.settings.logViewerPlacement, app::LogViewerPlacement::left); app::handle_command(state, input::UiCommand::open_context_menu); - EXPECT_EQ(state.logViewerPlacement, app::LogViewerPlacement::right); + EXPECT_EQ(state.settings.logViewerPlacement, app::LogViewerPlacement::right); const app::AppUpdate update = app::handle_command(state, input::UiCommand::back); - EXPECT_TRUE(update.modalClosed); + EXPECT_TRUE(update.navigation.modalClosed); EXPECT_EQ(state.modal.id, app::ModalId::none); } @@ -587,13 +587,13 @@ namespace { }); ASSERT_TRUE(app::begin_selected_host_app_browse(state, false)); - ASSERT_EQ(state.activeScreen, app::ScreenId::apps); - EXPECT_TRUE(state.statusMessage.empty()); + 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.screenChanged); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - EXPECT_TRUE(state.statusMessage.empty()); + 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}, @@ -602,10 +602,10 @@ namespace { false, "The host applist response did not contain any app entries"); - EXPECT_EQ(state.activeScreen, app::ScreenId::hosts); - EXPECT_TRUE(state.statusMessage.empty()); - EXPECT_EQ(state.activeHost.appListState, app::HostAppListState::idle); - EXPECT_TRUE(state.activeHost.appListStatusMessage.empty()); + 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) { @@ -613,7 +613,7 @@ namespace { 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.activeScreen, app::ScreenId::settings); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); app::replace_saved_files(state, { {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 128U}, @@ -622,19 +622,19 @@ namespace { 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.selectedSettingsCategory, app::SettingsCategory::reset); - ASSERT_EQ(state.settingsFocusArea, app::SettingsFocusArea::options); + 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.modalOpened); + EXPECT_TRUE(update.navigation.modalOpened); EXPECT_EQ(state.modal.id, app::ModalId::confirmation); update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.savedFileDeleteRequested); - EXPECT_EQ(update.savedFileDeletePath, "E:\\UDATA\\12345678\\moonlight.log"); + EXPECT_TRUE(update.persistence.savedFileDeleteRequested); + EXPECT_EQ(update.persistence.savedFileDeletePath, "E:\\UDATA\\12345678\\moonlight.log"); } TEST(ClientStateTest, SettingsFactoryResetUsesAConfirmationDialog) { @@ -642,21 +642,21 @@ namespace { 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.activeScreen, app::ScreenId::settings); + 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.selectedSettingsCategory, app::SettingsCategory::reset); - ASSERT_EQ(state.settingsFocusArea, app::SettingsFocusArea::options); + 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.modalOpened); + 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.factoryResetRequested); + EXPECT_TRUE(confirmUpdate.persistence.factoryResetRequested); } TEST(ClientStateTest, HostContextMenuCanDeleteTheSelectedHost) { @@ -672,7 +672,7 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.hostsChanged); + EXPECT_TRUE(update.persistence.hostsChanged); ASSERT_EQ(state.hosts.size(), 1U); EXPECT_EQ(state.hosts.front().displayName, "Office PC"); } @@ -695,14 +695,14 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.hostsChanged); - EXPECT_TRUE(update.hostDeleteCleanupRequested); - EXPECT_TRUE(update.deletedHostWasPaired); - EXPECT_EQ(update.deletedHostAddress, test_support::kTestIpv4Addresses[test_support::kIpOffice]); - EXPECT_EQ(update.deletedHostPort, test_support::kTestPorts[test_support::kPortDefaultHost]); - ASSERT_EQ(update.deletedHostCoverArtCacheKeys.size(), 2U); - EXPECT_EQ(update.deletedHostCoverArtCacheKeys[0], "steam-cover"); - EXPECT_EQ(update.deletedHostCoverArtCacheKeys[1], "desktop-cover"); + 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])); } @@ -719,7 +719,7 @@ namespace { app::handle_command(state, input::UiCommand::move_down); const app::AppUpdate deleteUpdate = app::handle_command(state, input::UiCommand::activate); - ASSERT_TRUE(deleteUpdate.hostDeleteCleanupRequested); + 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, { @@ -741,10 +741,10 @@ namespace { const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_TRUE(update.connectionTestRequested); - EXPECT_EQ(update.connectionTestAddress, test_support::kTestIpv4Addresses[test_support::kIpHostGridA]); - EXPECT_EQ(update.connectionTestPort, test_support::kTestPorts[test_support::kPortDefaultHost]); - EXPECT_EQ(state.statusMessage, "Testing connection to " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA]) + ":48000..."); + 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) { @@ -754,7 +754,7 @@ namespace { state.addHostDraft.addressInput = test_support::kTestIpv4Addresses[test_support::kIpHostGridA]; app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); - EXPECT_FALSE(update.screenChanged); + 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]); @@ -829,11 +829,11 @@ namespace { {"Office PC", test_support::kTestIpv4Addresses[test_support::kIpOffice], test_support::kTestPorts[test_support::kPortDefaultHost], app::PairingState::paired, app::HostReachability::online}, }); - state.selectedHostIndex = 1U; + 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.activeScreen, app::ScreenId::hosts); - EXPECT_EQ(state.selectedHostIndex, 0U); + EXPECT_EQ(state.shell.activeScreen, app::ScreenId::hosts); + EXPECT_EQ(state.hosts.selectedHostIndex, 0U); EXPECT_EQ(state.hosts.front().pairingState, app::PairingState::paired); } diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 6d60909..615df35 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -18,21 +18,21 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_EQ(viewModel.title, "Moonlight"); - EXPECT_TRUE(viewModel.pageTitle.empty()); - ASSERT_EQ(viewModel.toolbarButtons.size(), 3U); - EXPECT_TRUE(viewModel.toolbarButtons[2].selected); - EXPECT_EQ(viewModel.toolbarButtons[2].label, "Add Host"); - EXPECT_EQ(viewModel.toolbarButtons[2].iconAssetPath, "icons\\add-host.svg"); - ASSERT_FALSE(viewModel.bodyLines.empty()); - EXPECT_EQ(viewModel.bodyLines.front(), "No PCs have been added yet."); - ASSERT_EQ(viewModel.footerActions.size(), 2U); - EXPECT_EQ(viewModel.footerActions[0].label, "Select"); - EXPECT_EQ(viewModel.footerActions[0].iconAssetPath, "icons\\button-a.svg"); - EXPECT_EQ(viewModel.footerActions[1].label, "Exit"); - EXPECT_EQ(viewModel.footerActions[1].iconAssetPath, "icons\\button-select.svg"); - EXPECT_EQ(viewModel.footerActions[1].secondaryIconAssetPath, "icons\\button-start.svg"); - EXPECT_FALSE(viewModel.overlayVisible); + 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) { @@ -46,18 +46,18 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.pageTitle.empty()); - EXPECT_EQ(viewModel.hostColumnCount, 3U); - ASSERT_EQ(viewModel.hostTiles.size(), 2U); - EXPECT_EQ(viewModel.hostTiles[0].displayName, "Living Room PC"); - EXPECT_EQ(viewModel.hostTiles[0].statusLabel, "Offline"); - EXPECT_EQ(viewModel.hostTiles[0].iconAssetPath, "icons\\host-monitor-offline.svg"); - EXPECT_EQ(viewModel.hostTiles[1].displayName, "Office PC"); - EXPECT_EQ(viewModel.hostTiles[1].statusLabel, "Online"); - EXPECT_EQ(viewModel.hostTiles[1].iconAssetPath, "icons\\host-monitor-online.svg"); - EXPECT_TRUE(viewModel.hostTiles[1].selected); - ASSERT_GE(viewModel.bodyLines.size(), 2U); - EXPECT_EQ(viewModel.bodyLines[1], "Press Y on a controller, or I on a keyboard, for host actions."); + 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) { @@ -65,13 +65,13 @@ namespace { 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.hostsFocusArea = app::HostsFocusArea::toolbar; + state.hosts.focusArea = app::HostsFocusArea::toolbar; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - ASSERT_EQ(viewModel.footerActions.size(), 2U); - EXPECT_EQ(viewModel.footerActions[0].label, "Select"); - EXPECT_EQ(viewModel.footerActions[1].label, "Exit"); + 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) { @@ -86,42 +86,42 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_EQ(viewModel.pageTitle, "Add Host"); - ASSERT_GE(viewModel.bodyLines.size(), 4U); - EXPECT_EQ(viewModel.bodyLines[0], "Manual host entry with a keypad modal."); - EXPECT_EQ(viewModel.bodyLines[1], "Address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); - EXPECT_EQ(viewModel.bodyLines[2], "Port: 48000"); - EXPECT_EQ(viewModel.bodyLines[3], "Press A to edit either field with the keypad modal."); - ASSERT_EQ(viewModel.footerActions.size(), 2U); - EXPECT_EQ(viewModel.footerActions[0].label, "Select"); - EXPECT_EQ(viewModel.footerActions[1].label, "Back"); + EXPECT_EQ(viewModel.frame.pageTitle, "Add Host"); + ASSERT_GE(viewModel.content.bodyLines.size(), 4U); + 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.loggingLevel = logging::LogLevel::none; - state.xemuConsoleLoggingLevel = logging::LogLevel::warning; + 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.activeScreen, app::ScreenId::settings); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - ASSERT_GE(viewModel.bodyLines.size(), 7U); - EXPECT_EQ(viewModel.bodyLines[0], "Category: Logging"); - EXPECT_EQ(viewModel.bodyLines[1], "Runtime log file: reset on every startup"); - EXPECT_EQ(viewModel.bodyLines[2], "Log file path: E:\\UDATA\\12345678\\moonlight.log"); - EXPECT_EQ(viewModel.bodyLines[3], "File logging level: NONE"); - EXPECT_EQ(viewModel.bodyLines[4], "xemu console logging level: WARN"); - ASSERT_EQ(viewModel.detailMenuRows.size(), 3U); - EXPECT_EQ(viewModel.detailMenuRows[0].label, "View Log File"); - EXPECT_EQ(viewModel.detailMenuRows[1].label, "File Logging Level: NONE"); - EXPECT_EQ(viewModel.detailMenuRows[2].label, "xemu Console Level: WARN"); - EXPECT_EQ(viewModel.selectedMenuRowDescription, "Control the runtime log file, the in-app log viewer, and xemu debugger output verbosity."); - EXPECT_TRUE(viewModel.leftPanelActive); - EXPECT_FALSE(viewModel.rightPanelActive); + 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) { @@ -129,19 +129,19 @@ namespace { 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.activeScreen, app::ScreenId::settings); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); - state.selectedSettingsCategory = app::SettingsCategory::reset; + state.settings.selectedCategory = app::SettingsCategory::reset; app::replace_saved_files(state, { {"E:\\UDATA\\12345678\\pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", "pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", 1536U}, }); - state.settingsFocusArea = app::SettingsFocusArea::options; + 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.selectedMenuRowLabel, "Delete pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin"); - EXPECT_EQ(viewModel.selectedMenuRowDescription, "Delete only this saved file from disk while leaving the rest of the Moonlight data intact."); + 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) { @@ -149,15 +149,15 @@ namespace { 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.activeScreen, app::ScreenId::settings); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); - state.settingsFocusArea = app::SettingsFocusArea::options; + 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.selectedMenuRowLabel, "xemu Console Level: NONE"); - EXPECT_EQ(viewModel.selectedMenuRowDescription, "Choose the minimum severity mirrored to xemu through DbgPrint() when you launch xemu with a serial console."); + 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) { @@ -165,25 +165,25 @@ namespace { 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.activeScreen, app::ScreenId::settings); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); - state.settingsFocusArea = app::SettingsFocusArea::options; + state.settings.focusArea = app::SettingsFocusArea::options; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_FALSE(viewModel.leftPanelActive); - EXPECT_TRUE(viewModel.rightPanelActive); + 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.activeScreen, app::ScreenId::add_host); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::add_host); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.leftPanelActive); - EXPECT_FALSE(viewModel.rightPanelActive); + EXPECT_TRUE(viewModel.content.leftPanelActive); + EXPECT_FALSE(viewModel.content.rightPanelActive); } TEST(ShellViewTest, BuildsTheAddHostKeypadModalAsANumberPad) { @@ -194,16 +194,16 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.keypadModalVisible); - EXPECT_EQ(viewModel.keypadModalTitle, "Address Keypad"); - ASSERT_GE(viewModel.keypadModalLines.size(), 3U); - EXPECT_EQ(viewModel.keypadModalLines[0], "Editing field: Address"); - ASSERT_EQ(viewModel.keypadModalButtons.size(), 11U); - EXPECT_EQ(viewModel.keypadModalColumnCount, 3U); - EXPECT_EQ(viewModel.keypadModalButtons[0].label, "1"); - EXPECT_TRUE(viewModel.keypadModalButtons[0].selected); - EXPECT_EQ(viewModel.keypadModalButtons[9].label, "."); - EXPECT_EQ(viewModel.keypadModalButtons[10].label, "0"); + 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) { @@ -212,7 +212,7 @@ namespace { {"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.activeHost.runningGameId = 101; + 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}, @@ -223,44 +223,44 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_EQ(viewModel.screen, app::ScreenId::apps); - EXPECT_EQ(viewModel.pageTitle, "Office PC"); - ASSERT_FALSE(viewModel.appTiles.empty()); - EXPECT_EQ(viewModel.appTiles[0].name, "Steam"); - EXPECT_TRUE(viewModel.appTiles[0].selected); - EXPECT_TRUE(viewModel.bodyLines.empty()); - EXPECT_EQ(viewModel.appTiles[0].detail, "Running now"); - EXPECT_EQ(viewModel.appTiles[0].badgeLabel, "HDR"); - EXPECT_TRUE(viewModel.appTiles[0].boxArtCached); - EXPECT_TRUE(viewModel.appTiles[1].detail.empty()); - ASSERT_EQ(viewModel.footerActions.size(), 3U); - EXPECT_EQ(viewModel.footerActions[0].label, "Launch"); - EXPECT_EQ(viewModel.footerActions[1].label, "App Menu"); - EXPECT_EQ(viewModel.footerActions[2].label, "Back"); + 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.activeScreen = app::ScreenId::apps; - state.activeHostLoaded = true; - state.activeHost = { + 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.activeHost.apps = { + state.hosts.active.apps = { {"Steam", 101, false, false, false, "steam-cover", true, false}, }; - state.activeHost.appListState = app::HostAppListState::failed; - state.activeHost.appListStatusMessage = "The host reports that this client is no longer paired. Pair the host again."; + 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.appTiles.empty()); - ASSERT_FALSE(viewModel.bodyLines.empty()); - EXPECT_EQ(viewModel.bodyLines.front(), "This host is not paired yet. Return and select it to begin pairing."); + 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) { @@ -269,12 +269,12 @@ namespace { {"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.statusMessage = "Loading apps for Office PC..."; + state.shell.statusMessage = "Loading apps for Office PC..."; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.bodyLines.empty()); - EXPECT_FALSE(viewModel.notificationVisible); + EXPECT_TRUE(viewModel.content.bodyLines.empty()); + EXPECT_FALSE(viewModel.notification.visible); } TEST(ShellViewTest, ShowsOnlyBackOnAppsScreenWhenNoVisibleAppIsSelected) { @@ -286,8 +286,8 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - ASSERT_EQ(viewModel.footerActions.size(), 1U); - EXPECT_EQ(viewModel.footerActions[0].label, "Back"); + ASSERT_EQ(viewModel.frame.footerActions.size(), 1U); + EXPECT_EQ(viewModel.frame.footerActions[0].label, "Back"); } TEST(ShellViewTest, BuildsHostDetailsModalContent) { @@ -304,38 +304,38 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.modalVisible); - EXPECT_EQ(viewModel.modalTitle, "Host Details"); - ASSERT_GE(viewModel.modalLines.size(), 5U); - EXPECT_EQ(viewModel.modalLines[0], "Name: Living Room PC"); - EXPECT_EQ(viewModel.modalLines[1], "State: ONLINE"); - EXPECT_EQ(viewModel.modalLines[2], "Active Address: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); + 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, "E:\\UDATA\\12345678\\moonlight.log"); - state.logViewerPlacement = app::LogViewerPlacement::right; + 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.logViewerScrollOffset = 1; + state.settings.logViewerScrollOffset = 1; const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.modalVisible); - EXPECT_TRUE(viewModel.logViewerVisible); - EXPECT_EQ(viewModel.modalTitle, "Log File"); - EXPECT_EQ(viewModel.logViewerPath, "E:\\UDATA\\12345678\\moonlight.log"); - EXPECT_EQ(viewModel.logViewerPlacement, app::LogViewerPlacement::right); - EXPECT_EQ(viewModel.logViewerScrollOffset, 1U); - ASSERT_EQ(viewModel.logViewerLines.size(), 2U); - EXPECT_EQ(viewModel.logViewerLines[0], "[000001] [INFO] app: Entered shell"); - ASSERT_EQ(viewModel.modalFooterActions.size(), 6U); - EXPECT_EQ(viewModel.modalFooterActions[0].iconAssetPath, "icons\\button-lb.svg"); - EXPECT_EQ(viewModel.modalFooterActions[5].iconAssetPath, "icons\\button-b.svg"); + 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, BuildsSupportModalFooterActionsWithControllerIcons) { @@ -344,17 +344,17 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.modalVisible); - EXPECT_EQ(viewModel.modalTitle, "Support"); - ASSERT_EQ(viewModel.modalFooterActions.size(), 2U); - EXPECT_EQ(viewModel.modalFooterActions[0].iconAssetPath, "icons\\button-a.svg"); - EXPECT_EQ(viewModel.modalFooterActions[0].secondaryIconAssetPath, "icons\\button-start.svg"); - EXPECT_EQ(viewModel.modalFooterActions[1].iconAssetPath, "icons\\button-b.svg"); + 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.activeScreen = app::ScreenId::settings; + state.shell.activeScreen = app::ScreenId::settings; state.modal.id = app::ModalId::confirmation; state.confirmation.action = app::ConfirmationAction::factory_reset; state.confirmation.title = "Factory Reset"; @@ -365,29 +365,29 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - EXPECT_TRUE(viewModel.modalVisible); - EXPECT_EQ(viewModel.modalTitle, "Factory Reset"); - ASSERT_EQ(viewModel.modalFooterActions.size(), 2U); - EXPECT_EQ(viewModel.modalFooterActions[0].label, "OK"); - EXPECT_EQ(viewModel.modalFooterActions[0].iconAssetPath, "icons\\button-a.svg"); - EXPECT_EQ(viewModel.modalFooterActions[1].label, "Cancel"); - EXPECT_EQ(viewModel.modalFooterActions[1].iconAssetPath, "icons\\button-b.svg"); + 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.activeScreen = app::ScreenId::settings; - state.statusMessage = "Deleted saved file moonlight.log"; + 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.notificationVisible); - EXPECT_EQ(viewModel.notification.message, "Deleted saved file moonlight.log"); + EXPECT_TRUE(viewModel.notification.visible); + EXPECT_EQ(viewModel.notification.content.message, "Deleted saved file moonlight.log"); } TEST(ShellViewTest, HidesThePairingPinUntilReachabilityHasBeenConfirmed) { app::ClientState state = app::create_initial_state(); - state.activeScreen = app::ScreenId::pair_host; + state.shell.activeScreen = app::ScreenId::pair_host; state.pairingDraft = { test_support::kTestIpv4Addresses[test_support::kIpHostGridA], test_support::kTestPorts[test_support::kPortPairing], @@ -398,18 +398,18 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); - ASSERT_GE(viewModel.bodyLines.size(), 2U); - EXPECT_EQ(viewModel.bodyLines[0], "Target host: " + std::string(test_support::kTestIpv4Addresses[test_support::kIpHostGridA])); - EXPECT_EQ(viewModel.bodyLines[1], "Checking whether the host is reachable before showing a PIN."); - for (const std::string &line : viewModel.bodyLines) { + ASSERT_GE(viewModel.content.bodyLines.size(), 2U); + 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."); + for (const std::string &line : viewModel.content.bodyLines) { EXPECT_EQ(line.find("PIN:"), std::string::npos); } - EXPECT_FALSE(viewModel.notificationVisible); + EXPECT_FALSE(viewModel.notification.visible); } TEST(ShellViewTest, AddsStatsAndRecentLogsToTheOverlayWhenVisible) { app::ClientState state = app::create_initial_state(); - state.overlayVisible = true; + state.shell.overlayVisible = true; const std::vector entries = { {1, logging::LogLevel::info, "app", "Entered shell"}, @@ -422,19 +422,19 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, entries, statsLines); - EXPECT_TRUE(viewModel.overlayVisible); - EXPECT_EQ(viewModel.overlayTitle, "Diagnostics"); - ASSERT_GE(viewModel.overlayLines.size(), 4U); - EXPECT_EQ(viewModel.overlayLines[0], "Stream: 1280x720 @ 60 FPS"); - EXPECT_EQ(viewModel.overlayLines[1], "Connection: Okay"); - EXPECT_EQ(viewModel.overlayLines[2], "[INFO] app: Entered shell"); - EXPECT_EQ(viewModel.overlayLines[3], "[WARN] network: No active stream"); + 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.overlayVisible = true; - state.overlayScrollOffset = 2; + state.shell.overlayVisible = true; + state.shell.overlayScrollOffset = 2; std::vector entries; entries.reserve(14); @@ -444,8 +444,8 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, entries); - ASSERT_FALSE(viewModel.overlayLines.empty()); - EXPECT_EQ(viewModel.overlayLines.front(), "Showing earlier log entries"); + ASSERT_FALSE(viewModel.overlay.lines.empty()); + EXPECT_EQ(viewModel.overlay.lines.front(), "Showing earlier log entries"); } } // namespace From a290364951a2728d03e6e15b10a087c6a26865a7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sun, 12 Apr 2026 19:07:10 -0400 Subject: [PATCH 23/35] style: sonar fixes --- src/logging/logger.cpp | 12 ++++-------- src/logging/logger.h | 12 ++++++++++++ src/ui/shell_screen.cpp | 5 ++--- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index 63859fe..6c2c22a 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -21,16 +21,12 @@ namespace { - inline logging::Logger *globalLogger = nullptr; - - inline bool startupConsoleEnabled = true; - bool is_enabled(logging::LogLevel candidateLevel, logging::LogLevel minimumLevel) { return static_cast(candidateLevel) >= static_cast(minimumLevel); } logging::Logger *registered_logger() { - return globalLogger; + return logging::detail::GlobalLoggingState::registeredLogger; } logging::LogLevel startup_console_level(logging::LogLevel level) { @@ -189,7 +185,7 @@ namespace logging { } void set_global_logger(Logger *logger) { - globalLogger = logger; + detail::GlobalLoggingState::registeredLogger = logger; } bool has_global_logger() { @@ -273,11 +269,11 @@ namespace logging { } void set_startup_console_enabled(bool enabled) { - startupConsoleEnabled = enabled; + detail::GlobalLoggingState::startupConsoleEnabled = enabled; } bool startup_console_enabled() { - return startupConsoleEnabled; + return detail::GlobalLoggingState::startupConsoleEnabled; } void print_startup_console_line(LogLevel level, std::string_view category, std::string_view message) { diff --git a/src/logging/logger.h b/src/logging/logger.h index 46754f7..81864cb 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -96,6 +96,18 @@ namespace logging { 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. * diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index de042b2..2f3747d 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -4220,7 +4220,7 @@ namespace { * @param state Client state whose logging configuration should be applied. * @param runtime Runtime state to prepare. */ - void initialize_shell_runtime(app::ClientState &state, ShellRuntimeState *runtime) { + void initialize_shell_runtime(const app::ClientState &state, ShellRuntimeState *runtime) { if (runtime == nullptr) { return; } @@ -4437,8 +4437,7 @@ namespace ui { } ShellResources resources {}; - ShellInitializationFailure initializationFailure {}; - if (!initialize_shell_resources(window, videoMode, &resources, &initializationFailure)) { + if (ShellInitializationFailure initializationFailure {}; !initialize_shell_resources(window, videoMode, &resources, &initializationFailure)) { return report_shell_failure(initializationFailure.category, initializationFailure.message); } From dc89a1fb3b4823a585c81f33ae4a9a704feea9bc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Mon, 13 Apr 2026 21:42:14 -0400 Subject: [PATCH 24/35] SVG rasterization and size-aware asset cache Integrate nanosvg to rasterize SVG assets to requested pixel bounds and make asset texture caching size-aware. Adds helpers: build_asset_texture_cache_key, calculate_fitted_dimensions and load_svg_surface_from_asset; load_texture_from_asset now optionally rasterizes SVGs when max dimensions are provided. The asset cache keys include raster dimensions and failed-key tracking was updated accordingly; load_cached_asset_texture and render_asset_icon were updated to pass requested bounds. Removed the dedicated titleLogoTexture field and its load/destroy calls in favor of rendering the logo via the asset cache. Also updates several SVG icon assets with revised vector art. --- src/ui/shell_screen.cpp | 211 ++++++++++++++++++---- xbe/assets/icons/button-a.svg | 6 +- xbe/assets/icons/button-b.svg | 7 +- xbe/assets/icons/button-lt.svg | 4 +- xbe/assets/icons/button-rt.svg | 5 +- xbe/assets/icons/button-select.svg | 10 +- xbe/assets/icons/button-start.svg | 10 +- xbe/assets/icons/button-x.svg | 5 +- xbe/assets/icons/button-y.svg | 5 +- xbe/assets/icons/host-monitor-offline.svg | 4 +- 10 files changed, 207 insertions(+), 60 deletions(-) diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index 2f3747d..e47c792 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -16,6 +16,8 @@ // nxdk includes #include +#include +#include #include #include #include @@ -97,6 +99,59 @@ namespace { 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; @@ -121,6 +176,96 @@ namespace { 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; @@ -466,13 +611,25 @@ namespace { return texture; } - SDL_Texture *load_texture_from_asset(SDL_Renderer *renderer, const char *relativePath) { + /** + * @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; } - const std::string assetPath = build_asset_path(relativePath); - SDL_Surface *surface = IMG_Load(assetPath.c_str()); + 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; } @@ -487,25 +644,27 @@ namespace { return texture; } - SDL_Texture *load_cached_asset_texture(SDL_Renderer *renderer, AssetTextureCache *cache, const std::string &relativePath) { + 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; } - if (const auto existingTexture = cache->textures.find(relativePath); existingTexture != cache->textures.end()) { + 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(relativePath) != cache->failedKeys.end()) { + if (cache->failedKeys.find(cacheKey) != cache->failedKeys.end()) { return nullptr; } - SDL_Texture *texture = load_texture_from_asset(renderer, relativePath.c_str()); + SDL_Texture *texture = load_texture_from_asset(renderer, relativePath.c_str(), maxWidth, maxHeight); if (texture == nullptr) { - cache->failedKeys[relativePath] = true; + cache->failedKeys[cacheKey] = true; return nullptr; } - cache->textures.try_emplace(relativePath, texture); + cache->textures.try_emplace(cacheKey, texture); return texture; } @@ -961,7 +1120,7 @@ namespace { } 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); + SDL_Texture *texture = load_cached_asset_texture(renderer, cache, relativePath, rect.w, rect.h); if (texture == nullptr) { return false; } @@ -3280,7 +3439,6 @@ namespace { SDL_Renderer *renderer, const VIDEO_MODE &videoMode, unsigned long encoderSettings, - SDL_Texture *titleLogoTexture, TTF_Font *titleFont, TTF_Font *bodyFont, TTF_Font *smallFont, @@ -3328,22 +3486,15 @@ namespace { const int titleTextY = headerRect.y + 12; int titleTextWidth = headerRect.w - 32; - if (titleLogoTexture != nullptr) { - int logoWidth = 0; - int logoHeight = 0; - if (SDL_QueryTexture(titleLogoTexture, nullptr, nullptr, &logoWidth, &logoHeight) == 0 && logoWidth > 0 && logoHeight > 0) { - const int targetLogoHeight = std::max(32, TTF_FontLineSkip(titleFont)); - const int targetLogoWidth = std::max(32, (logoWidth * targetLogoHeight) / logoHeight); - const SDL_Rect logoRect { - headerRect.x + 16, - headerRect.y + 10, - targetLogoWidth, - targetLogoHeight, - }; - if (SDL_RenderCopy(renderer, titleLogoTexture, nullptr, &logoRect) != 0) { - return false; - } - + 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); } @@ -4041,7 +4192,6 @@ namespace { */ struct ShellResources { SDL_Renderer *renderer = nullptr; ///< Renderer used for all shell drawing. - SDL_Texture *titleLogoTexture = nullptr; ///< Cached Moonlight title logo texture. 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. @@ -4129,8 +4279,6 @@ namespace { clear_cover_art_texture_cache(&resources->coverArtTextureCache); clear_asset_texture_cache(&resources->assetTextureCache); clear_keypad_modal_layout_cache(&resources->keypadModalLayoutCache); - destroy_texture(resources->titleLogoTexture); - resources->titleLogoTexture = nullptr; if (resources->smallFont != nullptr) { TTF_CloseFont(resources->smallFont); @@ -4208,7 +4356,6 @@ namespace { return false; } - resources->titleLogoTexture = load_texture_from_asset(resources->renderer, "moonlight-logo.svg"); resources->controller = open_primary_controller(); resources->encoderSettings = XVideoGetEncoderSettings(); return true; @@ -4251,7 +4398,7 @@ namespace { 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->titleLogoTexture, resources->titleFont, resources->bodyFont, resources->smallFont, viewModel, &resources->coverArtTextureCache, &resources->assetTextureCache, &resources->keypadModalLayoutCache)) { + 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; } diff --git a/xbe/assets/icons/button-a.svg b/xbe/assets/icons/button-a.svg index 1da5175..09fd52e 100644 --- a/xbe/assets/icons/button-a.svg +++ b/xbe/assets/icons/button-a.svg @@ -1,4 +1,4 @@ - - - + + + diff --git a/xbe/assets/icons/button-b.svg b/xbe/assets/icons/button-b.svg index bd942dd..c0c941a 100644 --- a/xbe/assets/icons/button-b.svg +++ b/xbe/assets/icons/button-b.svg @@ -1,4 +1,5 @@ - - - + + + + diff --git a/xbe/assets/icons/button-lt.svg b/xbe/assets/icons/button-lt.svg index e8e798e..3acf5ff 100644 --- a/xbe/assets/icons/button-lt.svg +++ b/xbe/assets/icons/button-lt.svg @@ -1,3 +1,3 @@ - - + + diff --git a/xbe/assets/icons/button-rt.svg b/xbe/assets/icons/button-rt.svg index db688f8..99183a6 100644 --- a/xbe/assets/icons/button-rt.svg +++ b/xbe/assets/icons/button-rt.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/xbe/assets/icons/button-select.svg b/xbe/assets/icons/button-select.svg index 3e672db..f886b0c 100644 --- a/xbe/assets/icons/button-select.svg +++ b/xbe/assets/icons/button-select.svg @@ -1,7 +1,7 @@ - - - - - + diff --git a/xbe/assets/icons/button-start.svg b/xbe/assets/icons/button-start.svg index 426392a..6eda3a3 100644 --- a/xbe/assets/icons/button-start.svg +++ b/xbe/assets/icons/button-start.svg @@ -1,7 +1,7 @@ - - - - - + diff --git a/xbe/assets/icons/button-x.svg b/xbe/assets/icons/button-x.svg index a9eaceb..6c1f577 100644 --- a/xbe/assets/icons/button-x.svg +++ b/xbe/assets/icons/button-x.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/xbe/assets/icons/button-y.svg b/xbe/assets/icons/button-y.svg index ac12e75..36a58f5 100644 --- a/xbe/assets/icons/button-y.svg +++ b/xbe/assets/icons/button-y.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/xbe/assets/icons/host-monitor-offline.svg b/xbe/assets/icons/host-monitor-offline.svg index 9acce86..d7c8537 100644 --- a/xbe/assets/icons/host-monitor-offline.svg +++ b/xbe/assets/icons/host-monitor-offline.svg @@ -3,7 +3,7 @@ - - + + From f7a7b41f341cfd0ac9b9f15dd5db888ed125fa1f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:44:20 -0400 Subject: [PATCH 25/35] Add file-level documentation comments Add Doxygen-style @file and @brief header comments to numerous source, header and test files across the codebase to improve documentation and readability. These are non-functional changes intended to aid tooling (e.g. Doxygen) and developer comprehension; no logic or API behavior was modified. --- src/_nxdk_compat/openssl_compat.h | 4 ++++ src/_nxdk_compat/poll_compat.cpp | 4 ++++ src/_nxdk_compat/stat_compat.cpp | 4 ++++ src/app/client_state.cpp | 4 ++++ src/app/client_state.h | 4 ++++ src/app/host_records.cpp | 4 ++++ src/app/host_records.h | 4 ++++ src/app/pairing_flow.cpp | 4 ++++ src/app/pairing_flow.h | 4 ++++ src/app/settings_storage.cpp | 4 ++++ src/app/settings_storage.h | 4 ++++ src/input/navigation_input.cpp | 4 ++++ src/input/navigation_input.h | 4 ++++ src/logging/log_file.cpp | 4 ++++ src/logging/log_file.h | 4 ++++ src/logging/logger.cpp | 4 ++++ src/logging/logger.h | 4 ++++ src/main.cpp | 4 ++++ src/network/host_pairing.cpp | 4 ++++ src/network/host_pairing.h | 4 ++++ src/network/runtime_network.cpp | 4 ++++ src/network/runtime_network.h | 4 ++++ src/os.h | 4 ++++ src/platform/error_utils.h | 4 ++++ src/platform/filesystem_utils.cpp | 4 ++++ src/platform/filesystem_utils.h | 4 ++++ src/splash/splash_layout.cpp | 4 ++++ src/splash/splash_layout.h | 4 ++++ src/splash/splash_screen.cpp | 4 ++++ src/splash/splash_screen.h | 4 ++++ src/startup/client_identity_storage.cpp | 4 ++++ src/startup/client_identity_storage.h | 4 ++++ src/startup/cover_art_cache.cpp | 4 ++++ src/startup/cover_art_cache.h | 4 ++++ src/startup/host_storage.cpp | 4 ++++ src/startup/host_storage.h | 4 ++++ src/startup/memory_stats.cpp | 4 ++++ src/startup/memory_stats.h | 4 ++++ src/startup/saved_files.cpp | 4 ++++ src/startup/saved_files.h | 4 ++++ src/startup/storage_paths.cpp | 4 ++++ src/startup/storage_paths.h | 4 ++++ src/startup/video_mode.cpp | 4 ++++ src/startup/video_mode.h | 4 ++++ src/streaming/stats_overlay.cpp | 4 ++++ src/streaming/stats_overlay.h | 4 ++++ src/ui/host_probe_result_queue.cpp | 4 ++++ src/ui/host_probe_result_queue.h | 4 ++++ src/ui/menu_model.cpp | 4 ++++ src/ui/menu_model.h | 4 ++++ src/ui/shell_screen.cpp | 4 ++++ src/ui/shell_screen.h | 4 ++++ src/ui/shell_view.cpp | 4 ++++ src/ui/shell_view.h | 4 ++++ tests/support/filesystem_test_utils.h | 4 ++++ tests/support/hal/debug.h | 4 ++++ tests/support/hal/video.h | 4 ++++ tests/support/network_test_constants.h | 4 ++++ tests/unit/app/client_state_test.cpp | 4 ++++ tests/unit/app/host_records_test.cpp | 4 ++++ tests/unit/app/pairing_flow_test.cpp | 4 ++++ tests/unit/app/settings_storage_test.cpp | 4 ++++ tests/unit/input/navigation_input_test.cpp | 4 ++++ tests/unit/logging/log_file_test.cpp | 4 ++++ tests/unit/logging/logger_test.cpp | 4 ++++ tests/unit/logging/startup_debug_test.cpp | 4 ++++ tests/unit/network/host_pairing_test.cpp | 4 ++++ tests/unit/network/runtime_network_test.cpp | 4 ++++ tests/unit/splash/splash_layout_test.cpp | 4 ++++ tests/unit/startup/client_identity_storage_test.cpp | 4 ++++ tests/unit/startup/cover_art_cache_test.cpp | 4 ++++ tests/unit/startup/host_storage_test.cpp | 4 ++++ tests/unit/startup/saved_files_test.cpp | 4 ++++ tests/unit/startup/video_mode_test.cpp | 4 ++++ tests/unit/streaming/stats_overlay_test.cpp | 4 ++++ tests/unit/ui/host_probe_result_queue_test.cpp | 4 ++++ tests/unit/ui/menu_model_test.cpp | 4 ++++ tests/unit/ui/shell_view_test.cpp | 4 ++++ 78 files changed, 312 insertions(+) diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h index 86000f2..7d452d5 100644 --- a/src/_nxdk_compat/openssl_compat.h +++ b/src/_nxdk_compat/openssl_compat.h @@ -1,3 +1,7 @@ +/** + * @file src/_nxdk_compat/openssl_compat.h + * @brief Declares OpenSSL compatibility shims for nxdk. + */ #pragma once #ifndef __STDC_WANT_LIB_EXT1__ diff --git a/src/_nxdk_compat/poll_compat.cpp b/src/_nxdk_compat/poll_compat.cpp index f4f07c4..81c73f7 100644 --- a/src/_nxdk_compat/poll_compat.cpp +++ b/src/_nxdk_compat/poll_compat.cpp @@ -1,3 +1,7 @@ +/** + * @file src/_nxdk_compat/poll_compat.cpp + * @brief Implements poll compatibility shims for nxdk. + */ #ifdef NXDK #include diff --git a/src/_nxdk_compat/stat_compat.cpp b/src/_nxdk_compat/stat_compat.cpp index 2562b1f..1bc2317 100644 --- a/src/_nxdk_compat/stat_compat.cpp +++ b/src/_nxdk_compat/stat_compat.cpp @@ -1,3 +1,7 @@ +/** + * @file src/_nxdk_compat/stat_compat.cpp + * @brief Implements stat compatibility shims for nxdk. + */ #ifdef NXDK #include diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index aa50959..b8aee00 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -1,3 +1,7 @@ +/** + * @file src/app/client_state.cpp + * @brief Implements client state models and transitions. + */ // class header include #include "src/app/client_state.h" diff --git a/src/app/client_state.h b/src/app/client_state.h index 19c7257..d8edb0a 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -1,3 +1,7 @@ +/** + * @file src/app/client_state.h + * @brief Declares client state models and transitions. + */ #pragma once // standard includes diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index 1122203..a33a806 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -1,3 +1,7 @@ +/** + * @file src/app/host_records.cpp + * @brief Implements host record models and utilities. + */ // class header include #include "src/app/host_records.h" diff --git a/src/app/host_records.h b/src/app/host_records.h index 7f03c2a..78b3c3c 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -1,3 +1,7 @@ +/** + * @file src/app/host_records.h + * @brief Declares host record models and utilities. + */ #pragma once // standard includes diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp index 72f57cb..4bfc161 100644 --- a/src/app/pairing_flow.cpp +++ b/src/app/pairing_flow.cpp @@ -1,3 +1,7 @@ +/** + * @file src/app/pairing_flow.cpp + * @brief Implements the host pairing flow. + */ // class header include #include "src/app/pairing_flow.h" diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h index 392a81c..15650bd 100644 --- a/src/app/pairing_flow.h +++ b/src/app/pairing_flow.h @@ -1,3 +1,7 @@ +/** + * @file src/app/pairing_flow.h + * @brief Declares the host pairing flow. + */ #pragma once // standard includes diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index c9db7f6..b53c223 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -1,3 +1,7 @@ +/** + * @file src/app/settings_storage.cpp + * @brief Implements application settings persistence. + */ // class header include #include "src/app/settings_storage.h" diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index 506b4b0..21d8fe2 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -1,3 +1,7 @@ +/** + * @file src/app/settings_storage.h + * @brief Declares application settings persistence. + */ #pragma once // standard includes diff --git a/src/input/navigation_input.cpp b/src/input/navigation_input.cpp index 4f61ff9..f60bd59 100644 --- a/src/input/navigation_input.cpp +++ b/src/input/navigation_input.cpp @@ -1,3 +1,7 @@ +/** + * @file src/input/navigation_input.cpp + * @brief Implements controller navigation input handling. + */ // class header include #include "src/input/navigation_input.h" diff --git a/src/input/navigation_input.h b/src/input/navigation_input.h index 9795a7d..faa9112 100644 --- a/src/input/navigation_input.h +++ b/src/input/navigation_input.h @@ -1,3 +1,7 @@ +/** + * @file src/input/navigation_input.h + * @brief Declares controller navigation input handling. + */ #pragma once namespace input { diff --git a/src/logging/log_file.cpp b/src/logging/log_file.cpp index 28fe46e..6dd2cab 100644 --- a/src/logging/log_file.cpp +++ b/src/logging/log_file.cpp @@ -1,3 +1,7 @@ +/** + * @file src/logging/log_file.cpp + * @brief Implements log file lifecycle helpers. + */ // class header include #include "src/logging/log_file.h" diff --git a/src/logging/log_file.h b/src/logging/log_file.h index f429f44..5fd985d 100644 --- a/src/logging/log_file.h +++ b/src/logging/log_file.h @@ -1,3 +1,7 @@ +/** + * @file src/logging/log_file.h + * @brief Declares log file lifecycle helpers. + */ #pragma once // standard includes diff --git a/src/logging/logger.cpp b/src/logging/logger.cpp index 6c2c22a..ffe3cb7 100644 --- a/src/logging/logger.cpp +++ b/src/logging/logger.cpp @@ -1,3 +1,7 @@ +/** + * @file src/logging/logger.cpp + * @brief Implements logging configuration and output. + */ // class header include #include "src/logging/logger.h" diff --git a/src/logging/logger.h b/src/logging/logger.h index 81864cb..95a30fc 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -1,3 +1,7 @@ +/** + * @file src/logging/logger.h + * @brief Declares logging configuration and output. + */ #pragma once // standard includes diff --git a/src/main.cpp b/src/main.cpp index 9f6966c..569db45 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,3 +1,7 @@ +/** + * @file src/main.cpp + * @brief Runs the Moonlight Xbox startup sequence and main loop. + */ // nxdk includes #include #include diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 63f3bf1..c08b97d 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -1,3 +1,7 @@ +/** + * @file src/network/host_pairing.cpp + * @brief Implements host pairing helpers. + */ // class header include #include "src/network/host_pairing.h" diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index 0b60e4a..35002ad 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -1,3 +1,7 @@ +/** + * @file src/network/host_pairing.h + * @brief Declares host pairing helpers. + */ #pragma once // standard includes diff --git a/src/network/runtime_network.cpp b/src/network/runtime_network.cpp index c767a61..1177153 100644 --- a/src/network/runtime_network.cpp +++ b/src/network/runtime_network.cpp @@ -1,3 +1,7 @@ +/** + * @file src/network/runtime_network.cpp + * @brief Implements runtime network status management. + */ // class header include #include "src/network/runtime_network.h" diff --git a/src/network/runtime_network.h b/src/network/runtime_network.h index 8f72cde..f8d97d2 100644 --- a/src/network/runtime_network.h +++ b/src/network/runtime_network.h @@ -1,3 +1,7 @@ +/** + * @file src/network/runtime_network.h + * @brief Declares runtime network status management. + */ #pragma once // standard includes diff --git a/src/os.h b/src/os.h index 4531ced..9e86cc6 100644 --- a/src/os.h +++ b/src/os.h @@ -1,3 +1,7 @@ +/** + * @file src/os.h + * @brief Declares platform path constants for the Xbox target. + */ #pragma once inline constexpr auto PATH_SEP = "\\"; diff --git a/src/platform/error_utils.h b/src/platform/error_utils.h index 6a137fd..917158b 100644 --- a/src/platform/error_utils.h +++ b/src/platform/error_utils.h @@ -1,3 +1,7 @@ +/** + * @file src/platform/error_utils.h + * @brief Declares error formatting helpers. + */ #pragma once // standard includes diff --git a/src/platform/filesystem_utils.cpp b/src/platform/filesystem_utils.cpp index ec6d9d3..a4a5f70 100644 --- a/src/platform/filesystem_utils.cpp +++ b/src/platform/filesystem_utils.cpp @@ -1,3 +1,7 @@ +/** + * @file src/platform/filesystem_utils.cpp + * @brief Implements filesystem utility helpers. + */ // class header include #include "src/platform/filesystem_utils.h" diff --git a/src/platform/filesystem_utils.h b/src/platform/filesystem_utils.h index 668d0e4..994a85b 100644 --- a/src/platform/filesystem_utils.h +++ b/src/platform/filesystem_utils.h @@ -1,3 +1,7 @@ +/** + * @file src/platform/filesystem_utils.h + * @brief Declares filesystem utility helpers. + */ #pragma once // standard includes diff --git a/src/splash/splash_layout.cpp b/src/splash/splash_layout.cpp index e9c5989..b9e1cef 100644 --- a/src/splash/splash_layout.cpp +++ b/src/splash/splash_layout.cpp @@ -1,3 +1,7 @@ +/** + * @file src/splash/splash_layout.cpp + * @brief Implements splash screen layout calculations. + */ // class header include #include "src/splash/splash_layout.h" diff --git a/src/splash/splash_layout.h b/src/splash/splash_layout.h index 4c482b0..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 diff --git a/src/splash/splash_screen.cpp b/src/splash/splash_screen.cpp index 4196a03..0d7e9ec 100644 --- a/src/splash/splash_screen.cpp +++ b/src/splash/splash_screen.cpp @@ -1,3 +1,7 @@ +/** + * @file src/splash/splash_screen.cpp + * @brief Implements the splash screen workflow. + */ // class header include #include "src/splash/splash_screen.h" diff --git a/src/splash/splash_screen.h b/src/splash/splash_screen.h index 08ea95e..99c5adc 100644 --- a/src/splash/splash_screen.h +++ b/src/splash/splash_screen.h @@ -1,3 +1,7 @@ +/** + * @file src/splash/splash_screen.h + * @brief Declares the splash screen workflow. + */ #pragma once // standard includes diff --git a/src/startup/client_identity_storage.cpp b/src/startup/client_identity_storage.cpp index bcc0b43..f52f5b2 100644 --- a/src/startup/client_identity_storage.cpp +++ b/src/startup/client_identity_storage.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/client_identity_storage.cpp + * @brief Implements client identity persistence. + */ // class header include #include "src/startup/client_identity_storage.h" diff --git a/src/startup/client_identity_storage.h b/src/startup/client_identity_storage.h index db8e395..cdf62da 100644 --- a/src/startup/client_identity_storage.h +++ b/src/startup/client_identity_storage.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/client_identity_storage.h + * @brief Declares client identity persistence. + */ #pragma once // standard includes diff --git a/src/startup/cover_art_cache.cpp b/src/startup/cover_art_cache.cpp index 159095c..2816566 100644 --- a/src/startup/cover_art_cache.cpp +++ b/src/startup/cover_art_cache.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/cover_art_cache.cpp + * @brief Implements cover art cache persistence. + */ // class header include #include "src/startup/cover_art_cache.h" diff --git a/src/startup/cover_art_cache.h b/src/startup/cover_art_cache.h index 6c84c86..66bf1ab 100644 --- a/src/startup/cover_art_cache.h +++ b/src/startup/cover_art_cache.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/cover_art_cache.h + * @brief Declares cover art cache persistence. + */ #pragma once // standard includes diff --git a/src/startup/host_storage.cpp b/src/startup/host_storage.cpp index 1af5fe2..bbedefa 100644 --- a/src/startup/host_storage.cpp +++ b/src/startup/host_storage.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/host_storage.cpp + * @brief Implements saved host persistence. + */ // class header include #include "src/startup/host_storage.h" diff --git a/src/startup/host_storage.h b/src/startup/host_storage.h index 4e95635..7ead631 100644 --- a/src/startup/host_storage.h +++ b/src/startup/host_storage.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/host_storage.h + * @brief Declares saved host persistence. + */ #pragma once // standard includes diff --git a/src/startup/memory_stats.cpp b/src/startup/memory_stats.cpp index fbe4d3d..8ff15ff 100644 --- a/src/startup/memory_stats.cpp +++ b/src/startup/memory_stats.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/memory_stats.cpp + * @brief Implements memory statistics helpers. + */ // class header include #include "src/startup/memory_stats.h" diff --git a/src/startup/memory_stats.h b/src/startup/memory_stats.h index ac48e09..cb86456 100644 --- a/src/startup/memory_stats.h +++ b/src/startup/memory_stats.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/memory_stats.h + * @brief Declares memory statistics helpers. + */ #pragma once // standard includes diff --git a/src/startup/saved_files.cpp b/src/startup/saved_files.cpp index f49719d..7b9e30e 100644 --- a/src/startup/saved_files.cpp +++ b/src/startup/saved_files.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/saved_files.cpp + * @brief Implements saved file loading and cleanup helpers. + */ // class header include #include "src/startup/saved_files.h" diff --git a/src/startup/saved_files.h b/src/startup/saved_files.h index f014be7..633c580 100644 --- a/src/startup/saved_files.h +++ b/src/startup/saved_files.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/saved_files.h + * @brief Declares saved file loading and cleanup helpers. + */ #pragma once // standard includes diff --git a/src/startup/storage_paths.cpp b/src/startup/storage_paths.cpp index 80e255a..aa12c7d 100644 --- a/src/startup/storage_paths.cpp +++ b/src/startup/storage_paths.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/storage_paths.cpp + * @brief Implements persistent storage path helpers. + */ // class header include #include "src/startup/storage_paths.h" diff --git a/src/startup/storage_paths.h b/src/startup/storage_paths.h index 660c5b7..bb3acc6 100644 --- a/src/startup/storage_paths.h +++ b/src/startup/storage_paths.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/storage_paths.h + * @brief Declares persistent storage path helpers. + */ #pragma once // standard includes diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index f086324..a5c664b 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -1,3 +1,7 @@ +/** + * @file src/startup/video_mode.cpp + * @brief Implements video mode selection helpers. + */ // class header include #include "src/startup/video_mode.h" diff --git a/src/startup/video_mode.h b/src/startup/video_mode.h index 7a43a41..0716f7b 100644 --- a/src/startup/video_mode.h +++ b/src/startup/video_mode.h @@ -1,3 +1,7 @@ +/** + * @file src/startup/video_mode.h + * @brief Declares video mode selection helpers. + */ #pragma once // standard includes diff --git a/src/streaming/stats_overlay.cpp b/src/streaming/stats_overlay.cpp index b31f4ed..c016bfa 100644 --- a/src/streaming/stats_overlay.cpp +++ b/src/streaming/stats_overlay.cpp @@ -1,3 +1,7 @@ +/** + * @file src/streaming/stats_overlay.cpp + * @brief Implements the streaming statistics overlay. + */ // class header include #include "src/streaming/stats_overlay.h" diff --git a/src/streaming/stats_overlay.h b/src/streaming/stats_overlay.h index 885fcc8..cc2dcd8 100644 --- a/src/streaming/stats_overlay.h +++ b/src/streaming/stats_overlay.h @@ -1,3 +1,7 @@ +/** + * @file src/streaming/stats_overlay.h + * @brief Declares the streaming statistics overlay. + */ #pragma once // standard includes diff --git a/src/ui/host_probe_result_queue.cpp b/src/ui/host_probe_result_queue.cpp index 047a21f..9cee8db 100644 --- a/src/ui/host_probe_result_queue.cpp +++ b/src/ui/host_probe_result_queue.cpp @@ -1,3 +1,7 @@ +/** + * @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" diff --git a/src/ui/host_probe_result_queue.h b/src/ui/host_probe_result_queue.h index de37d87..be344b7 100644 --- a/src/ui/host_probe_result_queue.h +++ b/src/ui/host_probe_result_queue.h @@ -1,3 +1,7 @@ +/** + * @file src/ui/host_probe_result_queue.h + * @brief Declares queued host probe results. + */ #pragma once // standard includes diff --git a/src/ui/menu_model.cpp b/src/ui/menu_model.cpp index f531f61..2f37670 100644 --- a/src/ui/menu_model.cpp +++ b/src/ui/menu_model.cpp @@ -1,3 +1,7 @@ +/** + * @file src/ui/menu_model.cpp + * @brief Implements menu model structures and helpers. + */ // class header include #include "src/ui/menu_model.h" diff --git a/src/ui/menu_model.h b/src/ui/menu_model.h index 844b8ce..3544bc3 100644 --- a/src/ui/menu_model.h +++ b/src/ui/menu_model.h @@ -1,3 +1,7 @@ +/** + * @file src/ui/menu_model.h + * @brief Declares menu model structures and helpers. + */ #pragma once // standard includes diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index e47c792..b58d99e 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -1,3 +1,7 @@ +/** + * @file src/ui/shell_screen.cpp + * @brief Implements the shell screen controller. + */ // class header include #include "src/ui/shell_screen.h" diff --git a/src/ui/shell_screen.h b/src/ui/shell_screen.h index d215ebc..658d32e 100644 --- a/src/ui/shell_screen.h +++ b/src/ui/shell_screen.h @@ -1,3 +1,7 @@ +/** + * @file src/ui/shell_screen.h + * @brief Declares the shell screen controller. + */ #pragma once // nxdk includes diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index cb80016..7c46860 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -1,3 +1,7 @@ +/** + * @file src/ui/shell_view.cpp + * @brief Implements the shell view renderer and layout helpers. + */ // class header include #include "src/ui/shell_view.h" diff --git a/src/ui/shell_view.h b/src/ui/shell_view.h index 6d71d28..ae9fd5c 100644 --- a/src/ui/shell_view.h +++ b/src/ui/shell_view.h @@ -1,3 +1,7 @@ +/** + * @file src/ui/shell_view.h + * @brief Declares the shell view renderer and layout helpers. + */ #pragma once // standard includes diff --git a/tests/support/filesystem_test_utils.h b/tests/support/filesystem_test_utils.h index 9b857fe..c6f5963 100644 --- a/tests/support/filesystem_test_utils.h +++ b/tests/support/filesystem_test_utils.h @@ -1,3 +1,7 @@ +/** + * @file tests/support/filesystem_test_utils.h + * @brief Provides test support for filesystem test utility helpers. + */ #pragma once // standard includes diff --git a/tests/support/hal/debug.h b/tests/support/hal/debug.h index 2d8d15f..d13e147 100644 --- a/tests/support/hal/debug.h +++ b/tests/support/hal/debug.h @@ -1,3 +1,7 @@ +/** + * @file tests/support/hal/debug.h + * @brief Provides test support for a host-side nxdk debug shim. + */ #pragma once // standard includes 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 index 9f16c21..4e3f320 100644 --- a/tests/support/network_test_constants.h +++ b/tests/support/network_test_constants.h @@ -1,3 +1,7 @@ +/** + * @file tests/support/network_test_constants.h + * @brief Provides test support for shared network test constants. + */ #pragma once // standard includes diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index b331ada..c8cb068 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/app/client_state_test.cpp + * @brief Verifies client state models and transitions. + */ // class header include #include "src/app/client_state.h" diff --git a/tests/unit/app/host_records_test.cpp b/tests/unit/app/host_records_test.cpp index 8f57d92..8a85088 100644 --- a/tests/unit/app/host_records_test.cpp +++ b/tests/unit/app/host_records_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/app/host_records_test.cpp + * @brief Verifies host record models and utilities. + */ // class header include #include "src/app/host_records.h" diff --git a/tests/unit/app/pairing_flow_test.cpp b/tests/unit/app/pairing_flow_test.cpp index 466a6aa..548ec60 100644 --- a/tests/unit/app/pairing_flow_test.cpp +++ b/tests/unit/app/pairing_flow_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/app/pairing_flow_test.cpp + * @brief Verifies the host pairing flow. + */ // class header include #include "src/app/pairing_flow.h" diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index 67c9c71..3938ce7 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/app/settings_storage_test.cpp + * @brief Verifies application settings persistence. + */ // test header include #include "src/app/settings_storage.h" diff --git a/tests/unit/input/navigation_input_test.cpp b/tests/unit/input/navigation_input_test.cpp index 2eede67..9d0c6ea 100644 --- a/tests/unit/input/navigation_input_test.cpp +++ b/tests/unit/input/navigation_input_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/input/navigation_input_test.cpp + * @brief Verifies controller navigation input handling. + */ // class header include #include "src/input/navigation_input.h" diff --git a/tests/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp index 75ccabc..7a5dbbe 100644 --- a/tests/unit/logging/log_file_test.cpp +++ b/tests/unit/logging/log_file_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/logging/log_file_test.cpp + * @brief Verifies log file lifecycle helpers. + */ // test header include #include "src/logging/log_file.h" diff --git a/tests/unit/logging/logger_test.cpp b/tests/unit/logging/logger_test.cpp index 7b9af87..bd7536a 100644 --- a/tests/unit/logging/logger_test.cpp +++ b/tests/unit/logging/logger_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/logging/logger_test.cpp + * @brief Verifies logging configuration and output. + */ // test header include #include "src/logging/logger.h" diff --git a/tests/unit/logging/startup_debug_test.cpp b/tests/unit/logging/startup_debug_test.cpp index ad4b8a8..0db9a75 100644 --- a/tests/unit/logging/startup_debug_test.cpp +++ b/tests/unit/logging/startup_debug_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/logging/startup_debug_test.cpp + * @brief Verifies startup debug logging. + */ // test header include #include "src/logging/logger.h" diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index 157b9d1..6bd8cc9 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/network/host_pairing_test.cpp + * @brief Verifies host pairing helpers. + */ // test header include #include "src/network/host_pairing.h" diff --git a/tests/unit/network/runtime_network_test.cpp b/tests/unit/network/runtime_network_test.cpp index f8b0419..a9048d8 100644 --- a/tests/unit/network/runtime_network_test.cpp +++ b/tests/unit/network/runtime_network_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/network/runtime_network_test.cpp + * @brief Verifies runtime network status management. + */ // test header include #include "src/network/runtime_network.h" diff --git a/tests/unit/splash/splash_layout_test.cpp b/tests/unit/splash/splash_layout_test.cpp index 52a7096..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" diff --git a/tests/unit/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp index c10e0a6..1956352 100644 --- a/tests/unit/startup/client_identity_storage_test.cpp +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/startup/client_identity_storage_test.cpp + * @brief Verifies client identity persistence. + */ // test header include #include "src/startup/client_identity_storage.h" diff --git a/tests/unit/startup/cover_art_cache_test.cpp b/tests/unit/startup/cover_art_cache_test.cpp index 531bb0f..7416c6e 100644 --- a/tests/unit/startup/cover_art_cache_test.cpp +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -1,3 +1,7 @@ +/** + * @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" diff --git a/tests/unit/startup/host_storage_test.cpp b/tests/unit/startup/host_storage_test.cpp index b7e4e71..d665bd2 100644 --- a/tests/unit/startup/host_storage_test.cpp +++ b/tests/unit/startup/host_storage_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/startup/host_storage_test.cpp + * @brief Verifies saved host persistence. + */ // test header include #include "src/startup/host_storage.h" diff --git a/tests/unit/startup/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp index 4decb83..b4636d5 100644 --- a/tests/unit/startup/saved_files_test.cpp +++ b/tests/unit/startup/saved_files_test.cpp @@ -1,3 +1,7 @@ +/** + * @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" diff --git a/tests/unit/startup/video_mode_test.cpp b/tests/unit/startup/video_mode_test.cpp index 36510a5..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" diff --git a/tests/unit/streaming/stats_overlay_test.cpp b/tests/unit/streaming/stats_overlay_test.cpp index f9f2450..fa82b56 100644 --- a/tests/unit/streaming/stats_overlay_test.cpp +++ b/tests/unit/streaming/stats_overlay_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/streaming/stats_overlay_test.cpp + * @brief Verifies the streaming statistics overlay. + */ #include "src/streaming/stats_overlay.h" #include diff --git a/tests/unit/ui/host_probe_result_queue_test.cpp b/tests/unit/ui/host_probe_result_queue_test.cpp index 2231eb1..022c9fb 100644 --- a/tests/unit/ui/host_probe_result_queue_test.cpp +++ b/tests/unit/ui/host_probe_result_queue_test.cpp @@ -1,3 +1,7 @@ +/** + * @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" diff --git a/tests/unit/ui/menu_model_test.cpp b/tests/unit/ui/menu_model_test.cpp index a3d091b..96fcbc5 100644 --- a/tests/unit/ui/menu_model_test.cpp +++ b/tests/unit/ui/menu_model_test.cpp @@ -1,3 +1,7 @@ +/** + * @file tests/unit/ui/menu_model_test.cpp + * @brief Verifies menu model structures and helpers. + */ #include "src/ui/menu_model.h" #include diff --git a/tests/unit/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 615df35..52d037d 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -1,3 +1,7 @@ +/** + * @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" From c81df484d798837398696956f4e2086a506f3acc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:10:48 -0400 Subject: [PATCH 26/35] Improve doxygen documentation --- src/_nxdk_compat/openssl_compat.h | 140 ++++++++++++++++++++++++++++++ src/_nxdk_compat/poll_compat.cpp | 8 ++ src/_nxdk_compat/stat_compat.cpp | 28 ++++++ src/app/client_state.h | 60 ++++++------- src/app/host_records.cpp | 7 ++ src/app/host_records.h | 20 ++--- src/app/pairing_flow.h | 10 +-- src/app/settings_storage.cpp | 16 +++- src/input/navigation_input.h | 92 ++++++++++---------- src/logging/logger.h | 12 +-- src/main.cpp | 5 ++ src/network/host_pairing.cpp | 29 ++++++- src/network/runtime_network.cpp | 4 + src/os.h | 4 +- src/ui/shell_view.cpp | 26 ++++++ 15 files changed, 359 insertions(+), 102 deletions(-) diff --git a/src/_nxdk_compat/openssl_compat.h b/src/_nxdk_compat/openssl_compat.h index 7d452d5..26a042f 100644 --- a/src/_nxdk_compat/openssl_compat.h +++ b/src/_nxdk_compat/openssl_compat.h @@ -5,6 +5,9 @@ #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 @@ -20,15 +23,51 @@ 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 @@ -40,134 +79,235 @@ extern "C" { 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 diff --git a/src/_nxdk_compat/poll_compat.cpp b/src/_nxdk_compat/poll_compat.cpp index 81c73f7..1fdfe26 100644 --- a/src/_nxdk_compat/poll_compat.cpp +++ b/src/_nxdk_compat/poll_compat.cpp @@ -10,6 +10,14 @@ #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; diff --git a/src/_nxdk_compat/stat_compat.cpp b/src/_nxdk_compat/stat_compat.cpp index 1bc2317..06fc150 100644 --- a/src/_nxdk_compat/stat_compat.cpp +++ b/src/_nxdk_compat/stat_compat.cpp @@ -9,6 +9,13 @@ 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; @@ -19,6 +26,13 @@ extern "C" { 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; @@ -29,10 +43,24 @@ extern "C" { 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); } diff --git a/src/app/client_state.h b/src/app/client_state.h index d8edb0a..532142a 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -23,78 +23,78 @@ namespace app { * @brief Top-level screens used by the Moonlight client shell. */ enum class ScreenId { - home, - hosts, - apps, - add_host, - pair_host, - settings, + 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, - grid, + 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, - support, - host_actions, - host_details, - app_actions, - app_details, - confirmation, - log_viewer, + 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, - left, - right, + 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, - options, + 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, - display, - input, - reset, + 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, - delete_saved_file, - factory_reset, + 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, - port, + address, ///< The host IPv4 address field. + port, ///< The optional host port override field. }; /** diff --git a/src/app/host_records.cpp b/src/app/host_records.cpp index a33a806..df8b402 100644 --- a/src/app/host_records.cpp +++ b/src/app/host_records.cpp @@ -429,6 +429,13 @@ namespace app { 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; diff --git a/src/app/host_records.h b/src/app/host_records.h index 78b3c3c..ff1af02 100644 --- a/src/app/host_records.h +++ b/src/app/host_records.h @@ -12,33 +12,33 @@ namespace app { - inline constexpr uint16_t DEFAULT_HOST_PORT = 47989; + 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, - paired, + 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, - online, - offline, + 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, - loading, - ready, - failed, + 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. }; /** diff --git a/src/app/pairing_flow.h b/src/app/pairing_flow.h index 15650bd..f462789 100644 --- a/src/app/pairing_flow.h +++ b/src/app/pairing_flow.h @@ -15,11 +15,11 @@ namespace app { * @brief Reducer-driven stages for the manual pairing shell flow. */ enum class PairingStage { - idle, - pin_ready, - in_progress, - paired, - failed, + 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. }; /** diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index b53c223..eef1e44 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -16,7 +16,13 @@ #include #if defined(_WIN32) && !defined(__MINGW32__) && !defined(__MINGW64__) -// needed for toml++ +/** + * @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 @@ -32,8 +38,14 @@ namespace { using namespace std::string_view_literals; using platform::append_error; - constexpr std::string_view SETTINGS_FILE_NAME = "moonlight.toml"; + 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); diff --git a/src/input/navigation_input.h b/src/input/navigation_input.h index faa9112..e133890 100644 --- a/src/input/navigation_input.h +++ b/src/input/navigation_input.h @@ -10,70 +10,70 @@ namespace input { * @brief Abstract UI command emitted by controller or keyboard input. */ enum class UiCommand { - none, - move_up, - move_down, - move_left, - move_right, - activate, - confirm, - back, - open_context_menu, - delete_character, - previous_page, - next_page, - fast_previous_page, - fast_next_page, - toggle_overlay, + 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, - dpad_down, - dpad_left, - dpad_right, - a, - b, - x, - y, - left_shoulder, - right_shoulder, - start, - back, + 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_down, - left_stick_left, - left_stick_right, + 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, - down, - left, - right, - enter, - escape, - backspace, - delete_key, - space, - tab, - page_up, - page_down, - i, - m, - f3, + 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. }; /** diff --git a/src/logging/logger.h b/src/logging/logger.h index 95a30fc..6243d08 100644 --- a/src/logging/logger.h +++ b/src/logging/logger.h @@ -19,12 +19,12 @@ namespace logging { * @brief Severity levels used by the Moonlight client logger. */ enum class LogLevel { - trace = 0, - debug = 1, - info = 2, - warning = 3, - error = 4, - none = 5, + 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. }; /** diff --git a/src/main.cpp b/src/main.cpp index 569db45..0a55582 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -152,6 +152,11 @@ namespace { } // 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); diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index c08b97d..af2b297 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -57,24 +57,42 @@ #endif #if defined(NXDK) || !defined(_WIN32) -using SOCKET = int; +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 @@ -2395,6 +2413,15 @@ namespace network { 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); } diff --git a/src/network/runtime_network.cpp b/src/network/runtime_network.cpp index 1177153..9ac5639 100644 --- a/src/network/runtime_network.cpp +++ b/src/network/runtime_network.cpp @@ -11,11 +11,15 @@ #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 diff --git a/src/os.h b/src/os.h index 9e86cc6..240b659 100644 --- a/src/os.h +++ b/src/os.h @@ -4,5 +4,5 @@ */ #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/ui/shell_view.cpp b/src/ui/shell_view.cpp index 7c46860..c71e379 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -586,10 +586,22 @@ 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; @@ -599,6 +611,12 @@ namespace ui { 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; @@ -615,6 +633,14 @@ namespace ui { } } + /** + * @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; From aebf7e45ecba730c560430033f5cdbb5ddc88219 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:27:39 -0400 Subject: [PATCH 27/35] docs: update screenshots --- docs/images/screenshots/01-splash.png | Bin 155999 -> 95527 bytes docs/images/screenshots/02-hosts.png | Bin 267210 -> 174906 bytes docs/images/screenshots/03-apps.png | Bin 347056 -> 267690 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/images/screenshots/01-splash.png b/docs/images/screenshots/01-splash.png index 9f6d6ca0c395d323b1fe0a6b6a5af197d8cb3b7c..854f78f45d7eac628a9e4fe34f982baf5cff1c5e 100644 GIT binary patch literal 95527 zcmeFacT|&U*FGA=4v1Y55DSV@Rk}3GC=(S>ARyfa2%#vwh2S{i3@9iFDAh)9QUcNv zltH9Mfe;`-P=N>`fIujLkl%hle82asbFqc`1Rz_UcNoQo;>-)SrruZx~w@N=bUM&wAEma zhg4K2vJ^{?N*fE?zK@T)32U&*#V$`wOQT=AM3%O)Gsz$Bs_J`t=l4~|L`I>f>#ZED zJL3BlWV0)_F25HwBXb#xYInWDYZZ3=o%`v7Q|WAwN6z<#eA5`)K^a!Mv9v=^;Q_8W zCz+VHH*`R@q1is>^+NSI6L~W;f0D8Nz<-0=q(m2>&u1Fg`c5K3~QIGb3;TU7w z;T3a9NVqcj{j+*+5PyEa z?6|Du+c#56%U4yo`uabAZ89tMaM&(;`OOF2WW(BzkM}DE%#3MrCtC)G{OqT%=sncy za7axsw6RH=onTNCts7^4d@E?t*XaLIBSD^LB;{&xO^Z8MRt6_IafgjkS`+K~Y9lkPbL%UlEDAc^ zhtYM*H#62IqU>mV5dNZOx4-N(9gl|-#{c+MW1o8T`uO+j;n|*>drNcXuo$mdp0@30dtWvrsFmq#boZ8;?W1#jjhOQW(R6ZE% z9SwMAf`vq*+uGVR=f|oDsms?FRE&gPqVK@73vnvW+2Ig($?%vOCy8J~P^zzI6ma~N zA0CPp41VJCovn@uiM(6fsrkx_rfuP(p;T2Qk=Q0)RJTT_#3tB9RaS$gtl}>*yD;qD zM-*v2u0|Uthshts?sCW9-Lz-x2N$oR5*$<1KJA!`RWq?u@4F<2_X8;_hf9i*^TE zReb4MPJ<;`^F7>n-y@A_biHQtGm;abg z=H6;HOMw)tr$Se1lw+!Jen4iUAt{Qwy=V|;CT~q`aGtlbRNt4)kJr1;_Qx>3+?Qw? zDH|o4uT*nvO_Q}Qe|wqQ+#GRwgSzD0NSP~z@apVCb55;v6 z*9hlyzTdeV5dXF*X6}-QC>|Jsv`@c9)rUzuGTOu(xhO)*l|% zq-~tb+eBARC74V@)*#o9`En%!0QmV((b+d*(_fjiz|>)AO(GHneiziDN!v zy%}=8{&y?v6Ma+2b!=7dk**5~$Ca4_!DKgszXUFw7epiq9% zQ&Y>LgNX6tX~nq`eQ!H`|MbEEi?xtTv9Q@9rQZONV9maNvoeU!)ra}jy13iR;x@gb zz}_B0zXQ+XX10|xv__GLvTKSr^Aua&$>$e7giF{s&G~fo2w$4KDRdUvgKAdxwz>2`}?!LNY;M;{0bnnw6m`Qa`Sa^a=#w$ zMVz|3ol8l7bcWo0DgF4TlYjoPd{31cHJQ0YbN}z+*{<_cjY-zKH4dC;)gNs?&ak>it%Z862;qYS*xEWjfJr4t`^<+lSz3D|K>BtX%C&;s z=HTj@8~c>n)023Ie~pP%WFq=rMWi5>t$)I@rLTfoN*AT5njr8B_t(UzPXG zMi~L?rWDV(wnRTGB#yJp3dTR37EUG*2t#`2MNVcawBd(%JhlLci?aTfVLA!VdO>Q3 zV%!3knO)p-9Eo$tj*KR9oT}?XUK0R3rEO|Ri2uHjsENO{oSd8)F~FDgMKx=hMDQ4F zZb;DZChVDjvY>Eqvp%E`0zHl5%c~nCbSZh9kzga?Kb)t@o1efi=;>|SdF;-M`zkX~ zw+0JXW@^mp2R+woS}D|NErRu~*j>k*%Gx{{n7{keB05{J1m412YDID+mYkvL(o^n>fSI|u z`FMjO**Kw$k?7wHtF6p;8^GV}&6SfBVG87X==4if=>W2Sk5Rk<**`YXKZUWz1M-2% z4a|1%qr=X`LH*0RJ+x=}-rjr}6t`3foSy;QcX=3&@S@hVOUSNQ%!@nEzrCF+%427Q zplTjwYH_2}B-C#8KEnwswJFiz+?$0e6L}slgrW>&WqtV`f>6 z#~y?rMaT!9(~=ajb`&^F)7#tM@&Vs%KRggYM+~;4LWcSpP=qZ_O-)D2Cz`%m1BI!7 zc_y^Et<6M?=2kCPP`%xvFFiG}F5&=w!$HMOey!sK{$_Yiy)c5rB@4oRoBw&?p3fp4cgQVfz35q?%Z{ps`P z$BQ~RU5QHhumMaJtu{rrHY{LTZTJ_suYTue+iN{tO!^PkJn zqinCNFwrcNl}Q@;C{U0{rsHO`MBet>5zd7nyXiOQf4vXo8l|j{ z8m1JU;bn<6N1c4NIyJq-#zfxcag4RkVWfBial2W0w!`CNkkTZ!p*f~=qE1mE`B<@x zc&LV>D_gZ{<%#8sK=rK0#-rM?)f4s2?C&AvoZfohk)G6JjL$`YPIYfs7K%=-Z(6Ao z`=r5e^Kqrh#KGB_H8RqM zTCx1Q6K2NJ+bmH0zn+Wy?+*T7!uSztqp3%7alUTynTkX`HEba+Sk7X#ng_V-I&?Mc93y|^X);-mJ+5p{xN{dv@wX#T$uT$KKbc1HYSU5v_>qFUs&@9lKSI!CFb{> zkCsih8dtpzSJ`A@;T$E2WaTzf%g5BYXJSXM{WjH!&Hc)*nC^)KvTY0UQ+>8CQt`&e zN0x522TEoK;xcotM=5jlQy5(Q;( zAnPQ{-kXu&J@CvvG2PsV`Pcri->tec?&MaGWL#T;_PI4GQ(xnG3+M>A$}znXh2;fc z4#)|h;$maREBS?u#pjZEvq|wD&1g)Gb$u?L%N#Cj(XVEL;$`ql%$%Ua7I%uSLlt+} z#m#@+LHcMH=Nv7$c;U8nPdaV!iecKE@9|!p4%N(8VaKHr@kxm_Pfhf54AYuwmqdGe zdv8$d4bp_NFbnU0`Yl{X8+{DNnH0PI&n9Kl2|++=y3w`($HMiZ|5_*xA}ZxG?(<+XtMnsKaTq?DPF(x?}!ls+^cJ6uc;te$|x+r(Y%$#AXWyAG_pveadPoslECFohsvs!)az%pr5%h(r;_3+- z9*3$v_&U<i zHtj!qqv7dM3}bFcgX>xyvaRGxuxN%g9qLiZc%4katuODts$myZ{Xm>+lb&k#lk@T= zlBo$^<{Nb8$M)%qgIyK~p>GH5lo0a^ELH;p%N%z37R57ZvJBix#0O-TKLh!&Tww@4(4Xb1QVG{(i$ zNZ>V9dwi}(%UPKCqoa_^2Rd9IrZMsqgbt&_KB;kXu*Dq`BIVVqSB|X5y1Snt(Tt8j z-g$qw-_5fRc0R6viiGg3kZm&K|7-#T zM@UErGK7qr*VNiK*|CxKV#<5`iIILSfog3*IEY5P9$*k#lj?mL2{EDarDB^@dGjSW9Y?5<}h=~fpe^w1h3A+aZdSYgqDE1)hMth z=Ts<-22PiSX1}9O5>-`H1^_~@93_l_M6TUppm~XKP>VEyI4uaDx>g+m7`z_=$rj)< zcZIdpU?~tiH9kk@5vQ7q8%qPdaLf4M*Ta;_EzkEtH@lC*qRRP?R(P)#-1!KQ{RWth zvn@#r#$`%X!9w-QRLe-%MJedRR zh#0_ts0g{h;sVn@BwFC^y#cl`xaAmz z9XT-}7stXP#Rt4IzTV^*>sTm48lYLMD6!jQO!~44nvfcP$#cHd$e=N=W_O$<5{028 zp$e5ApKtS%sXB-)43#<2*KUE%o5N$U3tuD__R{AER72n9QGub4OYnY3sIv-Nn%mo- zf^jnd#&5d$gqL{|Ifn|RfX!yB1F@JGG{E~2NOuwv5@y-C_yqSR^;Bs)YYWs{kX8(c z^T45@Ava(bcAbR|$Qz7oY-}u(B$1knY<~IJdx8JB9X>cDB0E;hd=Pb&JvfA$sglsY ze*HQt0;!Ns_prZ)Dh%}ak7ipL-UOUdh0+o*c-}^OUknICWdJ*cwg)?p?xW8(d50YS zqe43BoW2fsGN50^=J5NNk&%%s%aZ)d6W;h~Z*?f`icalsv`rT}V7JCuu6PZ=IjEx< z$BRK9Ad%JSc&W9t9nAD`z(ob9TV^aG_#z*9(>yL-MCs;5$`7z?1Eva+8b9kwSpBIE zYf!CHrKhK-{^idtuf! z1h+`^xA|fs=WI$D&;&=F`u%4ge4HjuR}89mHm-dD?QhuPim= z#eUQK)YD`Arv;RP4#$@HW<|XW)yi4efVkk`;LZd0pi1`bmze#%S@Gzw1)XC8K$CPK zR_Sn!xVL3zXJRERNH*OU z_qr@!Hk`jNXQ zC*^XwH+Feat$cq}?+B#=B->DCJFlyD-8xP2s>roRT0DRs1%oMZDVdovMkyC+ftecx zv>E7dJ(?$oyM6l}L$aHdxY=$>GLeqTAmnHwR?$!$l;CO%Fx<( z-k5O7_m`m|GueVrGGfGTRq|nRS#n)*N)Cp^@u*yceAGwF)guQI^Z0ZUG5*}&t=h45 zi`;bk_3&Z9S3(Ra2TldfAVV=}_QDkMYhbn|!};|gpWLD0h>f3G?iN)t{aMm|xoMyb zN`^8rL#PNhqRbL&O3TXJ`=d;AJWsU(PABOs{0J{$=9IayDW;4V(!4D&ujr zjKx1;;O+SIl(G8HiaI}$M3j;{tV18LqK5Md6;EVx-tGg?<6XLty$iZMM_JjqNWZ2a ziR%QlQ0FxmMTigGq|S)nw=W|zv%by3)yltviFjYc`F;fkqIkYmhbn?k5x|?H=Z5rg zK3wKsv!MY9r1hVMI&&T5MbQPVwA$g0f(h;@UdNk`^KRa9Ll4>sZ@o@`>gad|*Pr8~ z<xo4gi$h z{<$BhN66twFgMO1@^jm?K?JayOf?=r#-d9F5)tYoiE;`66bqsWwTIe?}&8T2mcI#yE&u`Sow90c6jhC5xCx z1x?3g@K7m2lxxJ?8?b@l%i^NEs8|k#0Pn>71X&7=K!c#jvDa!kcF?;LQ9OuVWVyhF z9b8=Go2aG0h}}SfEy^HO;*WLL|>@L7;DbR;t-nAbkPJX1YYPyu$9`31~*!zcqUh?Io2%<3#v*2-&vo3;5<4}2`v>f%NRdYUT`In1a9orPyW`XPJnQz4Jo$8j{$W77oB0387>kgpY)0A=2k-AHfL zg~XkD&G1f}c6~2O00i2xVGcy!SJw})YLS6l z4kp`I3oHk-Z{!6l?{19ps$0&FRhRb*J5{tY2&uxu!ws$w2(Rro(@(AuH;&gqVxLhF zxq^CNgTu|6NMF^BGnmSNZ6yP*N(A0~j5n^pGbH2~rc0uO1w|r28K@2ZZOqYcV=l6W z^EMnqMBK;weEoY#s#7O{L2e_{8$RWDV_PT7y(`W>+=ZF<2lLVmv@gSEq2DHBH^Rb? zVw@xOR1WVe7ODohrszL4{F>oDq_OJF=med&FwInc;r!|O$unXd-Q5Y$G|rmpne5(O zYM8#2iTIx&bqe1axGHfLrb#+bI}kozy~8FR{8C$(5UBpM9L(JvNn~U_wxvX)L4F=R zpoq3>jKye5X!$`oNOURcu*E7f8z5vC^hZ`k8uv1>r z^Jn`N@Xw&x553VI`mDKyYlY_z)#n8TifaI&9h1J?2N7^BlLZ^GE%GQm4eZG6q%9yy z7CUmAm-oTChPD|s^&!C2$`iBcHrr*E26h3G)o60t;|h!SInFDtM3e(+G>+nVbMb&F z>O*O-jsao;cFQA#6=dtBGR?tfPmw<0rNNe!NfZ`2CJAfe5vD%yG@}OL z;{W^w&VL8=)#tjdR(~-id}OOjHmp-Z+)hx(jhtc_uK~P!BTqSu7{}~ zL=f(_p6ji)?ry6KX_;V*LE1z&s?f65a*-}IwA%4~S;!_s5Bs2ZDYTd^Oy(X&oujZS~kxL4SaqMs0p z&|@sF+$5kB34F3@=gn8j3yoms?GH)EKYaKQ(_&8l3z4?a&Xu#y9VH`UMT-wtca$2$ z&Atb_U`M?`(Uc=f;Q7hB5sBG28yuVR+J3whi^yak(2N^k4C+Jf08Twl;tt!-HpdX3 zAWjuxgJ2cVe($rw!oprlX%1`X2sx`Q>`JR#96GqC)5AR2Q}RreFYx9Hfhd zXqd0`X)x#dYNe90N*q}nqAJi;M4g!W>o`i_q|~%$dhb^ERYMpm0{}TgGDPL%skFHX5JCyTRSEo)KaL?CBQA=4GAEO(eOfQ&w59ivsbyjVUO<#K#i z=->P5WO8K_CH%g>z|eS8TBfGco%Eqix?-1py9*sLh@R_)&W1N z_Z>kdG^h@7Uw{-Ha{2Q9-Yf{TI3Am{K*ee!5TH5Hpop1n--sx<>L(>YV&}PZ9`5M6 zBlvfdBuNAvjyh)nfkBdcadi;cmYFk(!vp?Jd=3v(g1z>I&I8i^I)HY8b{sf$c8G|D zN@;N+F`)0UZa&DtuhzCL-q=2emK01v9GFGT6B=9^eZh+`{buS1SO?FCIMf#JAsaX> zrG(Uqz_uo@j5Jr)e4pg{8=dqJ#stVah-3FC+6Bb$aQytYYOi+JRtTGn6_u*Y31lJ- zdCXXhcVfCN`oOs`@b~wR=zD&5Lme`cA_;tQ(lPHdRs_QC$2{m(&!g!qHuM$|;}`9h zvU#feR52CQD?-ltAbaZ#fY%~T7Q%Uzo#1%L7je1}LkY5jDv$PC@s_YEO7sunV>-fy zt-Lx83_k1eW)Y}UW+g6Qd6~{zg0vM2ocV%j>F1amZEORs|J&b4qgWgK2PrgSS+BG; z(4M`K=r@Ge21r{N>Tgb`_S*-DpD_#}Rn0`q3Je2@GELq`UF`@y{?*0_qlA>{!dzwC zcEnMFa1Wv4`16P-`;m(5gsqT==?5U}^-LFq1$`CBHrSMdTAMsZr(LW2ONC{bhFaTxSZn}8t&bm)NML4{v##! zhzC!3nS%IUi;yff<%w8^Wn~&*%2Y?U7I(RwzfFb);#LcDS11)|gS06~^~sX+>V<`c zeTWC(4Hm-Jb|iujd(E*MamiNRK$IfVO`u#Of(%ke8WbA<1~R_k5}-_qK==4ZDfn=$s`Zo!0{ ziQWNm(8`G8o^gHD7`Tq?%O=3vaNO{9WHgBq3$ky1LWk!rUoqK?@Ux+=(w)a{{EmoG zO3ycd`V=*%ce5uVvB@d$VL97V8GtZwHa9&Tchw2kzXzXnxJS6CS|xMtK$Q&g#R= zRFk-O*CqcMh0cXy>VX)`2&jybT3aL#yA7_FRJ%?p{t?nzKvau6uv2@`iR5Aumvzlz z(7M2`F(cNe7*M1OBmxHO0@hf@EdmTu8ECO#)r3AHzK($W+dVt1eXiz^7C2qNRghZ= z9UBLqq)8Y!ac8|7xI+C<1UQ+q|3 zE^>2S9nPgDwQmnHShjPqFdKpSI77(|R0fXhqCHz{FaQpDJz}#V1_g#%ZX{<>B+H0* z*FP%mJ~Q@ZQI;XQk@hOA4ysi=*i$@L?dcM7fA?shcN4%jdm-+Usc8(JOT{;mxHM8f z^pQRytC_@`XJBqK&g`%<-sZO4`+MV}@)znB*iV?pt*|4HA^V7qjh&WIEPO#qL?o_t zpyQ?+p?95#fS(9-y(ksPNE#RvDL&ly0t$lj7ONcEMLn2OKyEaTa2t#(q|=Ugb;!0TLN$LamBsTi-bGRY}S~d*SyBE^1ny6L?Jc_Wa7I5KwCkFZ-C^mh3e@`GY2T^jNNd=Qm zMq1dmtPEwu&Zd|Uj8gTxpTGKSjWi6_Kyw0_V?=1+rUg$guYrj3^0kOH)OjAnnMu~7 zO_bpr1^`kY0Bw;bDDKgV@(nBkL|p`c8p{ds^!N1kMh0IyKvB5=vq`saB8eA(c|F(V zPVSF)Z%Qvk`guz&c{T!+Na6|D%$!1QA2n?!7a8H;^~U=j;)0w$tz0~Q0YsM9gi zv*+x5!WB?qvpPHmZ)*z-5HbrOw2+Px88-Fe1xx~%7({3z(6GY-OZ*2&N8lpjc>A?l@`$ z-6lk3SpxZ6G}eJ7P^@F4WK0o28weK7kdx5LWg zlD^uPKZikiB2E%E27`2?5ch4KNm{@n#r3QA8z>P9k+@(-{DKW&4L?} z`zG8A@^JM?Zv|-=Upr@?3)_VddQ2YHuV+GsJ7_QqX5ljs3MN)9)UFcuiN6g3_L1Pd z<3pLb+iPf0Hi0=9SLfk)DdmWi=to=vK!DGdAWRpB$K1gNXn=AZ4cO9( zLyvQD^_@ElGp5~|;?)ta*$s~MbYXeWVQfbR3<-MWj{cS^WCq=?0(P54D%fQJfUSPI z{y%jK(j!5}Lm-~hKw}`kg~FxM?a|VZsHL%}JjPX>!HP!`8(ge{w!d#@l?@CGI2AOi znC0I^AFV3S8+2ym+)6wXv5CbShx-mBgadqP%QJI(c9?0Atc$Y;LH-+*4-T z8?W4LzlIRT2EaNLZQ5Q5b0P-N=vN263%y#l4Z@k4Nq8PY3aTU)ZXqF%0+t$@tkMhm z-ip<~Oo`r{+=yC%HyVOP0Mj8A?kL8}s}^ZA03VS;T0V#ghDboYWJ7cW5-|vrI=U_= zA4+s3-<~nmWRRXx2_A+EK(H&?ql@zL@}6>HBn7t1endW(F~*wTU@zn z#zF~ZDPg==8>%icp*I0YV>>n6f%G9KKo+#h+l#ZHdZ>eDF-kW^pvuzY^BFJ>vaEis zq3FeQfh@5Z}t4w<@n{BqfY$m-2VUA=t^_@=X26vj@II*ILq16Xc*0a?@O{^6Y_^{CVO2C zht?N-K;W2FH@<`ScW#a$`t<43Lql!zJgM@bobJI9Yc_poWXPON@Y;)%m$4$~k-(po zt6vgxyeZ>| z*JT=m`rgMi%9`_D?iUvqM&7#oa@-PO|C`V|DEnj#Al>*7n? z3$A>5DQi`lH8fx9=rWS@?ygGx*ww21?zG1ug&mb}Q+KRk2nDXQ1!g{?WHeeR-54i< za<~3E505O_6#ks=v!wyEAs8FetdAjygLlMbj_1F;PKkx4Da-<$$MFtN_|oE`5s%L~8=zLj2U z$@a9ZsT(?cryD|W*40>V%dq9PJzi`lElbhg7UPdD{p*HkyF^9db9(Uu3tZDs_O4yK z;KMtg6yLd0wvs-4_@JcH8sLn?DA~9&1rx~^7rRhSgpXZ56cS2zO*6n*C1nT79DYyI zv3H@oqZN<|{leb#xSHMgeG0_4dsr;I8LAx)zW4NM`-TlP``l@w)czvJY_q%}o=1E4 z1gX^jZe>sQowDOC@a0V6GNw}(Rt*2HTT_RzSmm7+J74_Lew?1+E8~PH;m60lY%*%R;uc2_`(+Wu~%-)?Q?%n@bSAr~{K%5C)QQ3H3u(r&rbMKbv9>x8|yDyd9co?!SsQOQ0lgCt` zRml*ut|`h4ZYUu^rXb%PbEv)S;jK{)gcN2A|D2PErI-a#^L&h4eRFdCcyqmi{kuOf zksJK!sppb&W`;{S0SXmHVoSGm2la@TRKas{Vd}pjj1t^WG>N?K`_!$-j^Dx9k|^v; z_9%;#i$Fb9eC$;@agBfDnX)qHbrI+x4(7%8wvA7IcbYdNU>nV0iQr))RCT0CC@_&( zS0pDAoKBI(Y_@kVY(AHK4PEu+1ly0r&lVlc{$|sS>sDGEO%!bw)RNC)VRzL0y}iD_ z=;Wh~dlt`)B5q&#Q)iAjY6?+t8%q@n#*&z&>jMlEJx@ zhu*W4z29nU8UsdSCe2p_WwJa*&t+2XOSE8A2ICH2w=up{}Z=Z>VeRGOX`+G|@`n~ayrK__RS`oDLlh5Pbs3xZ<)Lhbo^P4Rj(ddlDAB01lj)FXn%hx#zGTj5I+jAgezAv26W zGNQy~& zTkQ+pU3%T&lrlHBrn{TYTv#|+?Co7zQqscePJqwfDJL`)UY6flQ#<$Jq&Xjw6i`N2 zZ2I|K3YDRc^+wFyAfHBU8w0J|rgj9s7%#ba@6^faYui^e{DFVrR?_9wOzphDS=i80 zQ8rFkh2lj{8@*4D(2aa2EG%3~N`k$xMow8#L4_f{5eZnAR5O|0eb`m;rmLXGmoR|Z;DR(Kh^j2_fKWUiCS4(F~^6_#h6k{ zN%&80dB$Qj=N$N#zWkz6;0kw|H%sD;4odtSXe3Eh9qDT?(xhTG&PeyUOK%g%FsXBF z#X1(p4taJCv5yZ;A0P7SbR_J8UuGEB(T-f{og3FQ4g2;nKT9|Tmo)LuDBca{p6R_t z_qYb>lgDw))@lQte$(4xx+O=9UtMbHx%J#FeR9&sTCD2G4Kus#+dai+6LXKO%|HD8 zly&tf>wBkE)`aPSx|1&I=&E)xmv$-h^F>(17mrMLt{m4ifD0{>dzL~-n8S6= zOsmc4qQ=-OQAw*cRC8ws9$N`S%hpFYo4Hx)-Wiois!z9+Pq)s@u+B_3$%avw!!ZK= zB{>)B8_ZWxw^S|e9;(v))Uw-05^d>HB9x448JygCC`kY#y$zFzX?1F)xD>at3gI8h zU?IgtnR2t$_;3uw_txMSnmpVXIX}zuJCkweO=@^d2&(sfA~DB6BBf*rMN zw1IJITj;870;~E3eEZ#A{81Uh@OKl}4P$=H6E6wdPxb6?FKY07_4?+TpZhxcE4u=B zq&PV7->g1$GD>%X*sXt_>tfpNVv*o=KHJWUZLTc9&1T?mbn^SlVRAG-`c&$UuHk9y zCe*x7+==J5vH8yDixTc}%01UorZrdH$vq)8Moacy>=Z;n&VV_S>->}l#t$@f7^7cg^e?N43RTc9X-y2!R}8W*MV zf@H3jZK5c^C72HtSs+(ZL~~T$m0=#6QD}SxSc36jt zCim?uQvRqkg?!E|ahb&yw}q#~HU^7clVSNG8CI~T-iqHS%3ur@|5bkZr* zt>Q!#ewR~05VxN3PP^J6ABZdHMjNx@_$EqZ1>d@)6v|ovoS?5AK)+PdBXO|XmTo<1 z$MV^SpIrSt#o^;IJ(1u-^JD(^I}-Ay4i zzLw_85QZpl>rv&8W!jx^)v`%2O}}iMZhSHCsj2*qy4z0Gx3g@wI%p@!pf{VfagGmc zUK)6+v_G+&4jOsu&}PvG=o3%K_ocUoW;{9XNQi~=b&5_Zi%n9Ti;Z(J7)XReecM*^ z>ETIye3c5{Vq*EkrQ+61;qbKeZ)d-k<^b9;Vb2w?f`a_&VREN~O_)KlOSkw&=2!Vg zCkDULBpU%vrgk?k^qj!YS9x=CbpmroiQKdPRd+fYQ_fYtoUe@erbc@s5va8yh+H+$ zSu74|p81HJX`Y=%aY}1(N&lQClO$kED~eFU#O5Rh{`}x)Z{)`1;-IUjXOzP{Bp3i0 ziIK5Y9#Zx!6Bk;eCA5a0#S&^|dQq~da8pm}v+V;Vl(}Du_o8Mro%n;I4*+)0CAvY> z20$UX1X0_)^?R+SmT!&>c>*A}f$d2$&n`6IB=|FlYxXWPiSTPXgDt6-ZMGIF(B#Qq!@-T0>`#4qC7~?U9gtaIkb~S4pUk%p#S~F*#PU3BaaxqZdRsY z*Xuo<)Wi1I6v3JbR=wWzGc`!-ew|M`#X)*)07m)%Ay!C;qgBx%>WFuJ{_c6Mep#|# z_RDK|&nt!dO2eFJ(GI(g3A%HS3M@ciDgkEa>QyQWtgyR!qD9H}S+tWBc2z^aoAh=8 z>6lRIm~(agZf;h(!B-EVHy=R%%E|w6qx+!#SVUWrjpPLZi;Xi)C=SXLmY33ZPxx;u zz;qhb_;Az{a(~}kkJrfb?QisNYqW*E)A=6(tWUq23xpEpKAA=NzQBNhY$<|SC3bT;nsDjBf z!ANh95;(nxgMr)Ut421V0(^V!qvSfgRJcsv!~nYK#+mh1CrT{!?*-_?Dt~j@#b=XcM19pd1@e7N6C3+kb0*7Z4Eg86PNo^;k0s>c}Y_wtNpQbp~ zKe%#+ex7k2~6oNa4G9Z9=wx@`z$9f)r>)6xZ(cH_f6EFtIDB zOk}vk@oAAG8Kt!6taA|$wvGP$@aOLl`8=xA(n$lNWb2=d`buvd&%k636myT_tII>}B!veOMFl+9gd{rrIoc;u&j-dLT$%=c?Gc1g}87Kopq5QTe zfhu-aZix_T^NN*T*J*2S#^%c!jg&sivy{%bl$%a-z`C|ms|; zy=zDt8*se9r8(wG=GmXX+$(c!C+HzJFy0Axjf467s0OlO>a%M1fUb?B&A?I_N1F*UhV?WN}8Sx5NW+6ZWM9wI1P=g%i z$J=5Z(FwRhgp)+QR@$@^=2_O&IcBi?4 zrobO6a0RK{iWi*jFreP!v+-S?O!~pe=}Erc>FG%vFxbu_IjScJxYjgk>!@cTNwuMe zS#^g~krUfkTGt=({>YxSOM-B0{j&V>IU`$dxN`<{`mQN|Bo>LdxwCU~r#)$M6f7nx zq}q8R>C88Yv3<^`f}$N<;!x+SUqW>&WkpX--{(xlsdA=RqC#_87@2MN?kLpfCk7t= zc_UYT-H6&E^as$5@-AMiur?}gY3_NG%dNP>;pTK^r$|SIfJBGj2d+?Svx^rL)93dZ>@nT9`Up z&rUs-5Y?Ro3(y@F%Py^Bw&~Q|z7gBCd4=`<7Nvf*u=jf)v<(YBUnJ2!FK{lji*9Ij zPz5&8KewBev~(A_C~yCzy|>;|b)?4tpHros;AK>G(g5~DXbp}))1x?IwypPFbtg%0 zp_#L)-26X~2`~hTgyN#AEOz7Jb9fKDv)>SXbu4gwy!+Bodu{jqq#a``o~0ay-K)Qc zl#gNyh~A;NVprJ2i^VM_$%fX&3mt_Ed^XRrgg%+In%(TslIly@vIeGhwIdRfY(;0Clp1HP1{y>^o^#l!-Exi znQwea7SRVl;;lp^UhKnCzlyM_VE&2@2}iZ>2#x@7jA?ZgHL5QieeC@*SM%8`h%U0N zg)|I31LT4=Dl}<>=iFUjhSRa|R_p9Tf-5?TL-xapIlD#RqYoR1}4;j1hPxTk>lIp4XUo%fVzru8^ke_ z9Gd%9oiJBjVV+Xr%%An-JXDcg8|M59NRTym{C;MQSY+kW3Ms7hzP ztC2Hz3u*_yOjeuZ{;b3)PKZb4&g}yV&O2 z=L=+k{zzSK&iwnpw0HB=>6Y4t8zu<1Ea%PS%W)C{Vb}8-rKS8J0f-%EwDzCfSr^Kb0@K;DfSYi)7A=HBCzlX-1#u3tq; zVDYPpt2RhlaI}=;gre%ttRj-MT$h$&EZ|p|oL#K*8OG;2MFl6ji(T1`G@$cZ%A!Kh z_2_?pV&v7B1S%j)Z|x^fD|iaMj>fa6s{#$^8_nrqP*0jXf7#dxjl8jiw{D^$;KsgnEnQ*qSTT7Zf!@5$wBs5$qTs_i?KJeg!f2 z9%Bh1aQq_YE!sctV)gPi%!k1eF-w6iW?*DMw!`K@9D^qWPo?`N#&I@?|C8UQgK8%@ zZD@b;O$~kp(wyLU281SL1OhcC=iw>H9<4J?a(0WG5JcbNZNIb~fl{#1x5c)Nt5Y(! z0i+7u2PHr^%B5aPH-5r!Hs`&4$lqIkRL`i$xqd=3>f9N&pE$grE*QvxSHYsa=739 z1qdwIS72_%jd(CzfXL>{p2DL7O*>@Y9H8C?kx`@<1V{&)+97sJ{>isb#!C+1n165B z(uP*mslz7);6!uHXb831y|4R(0B`j@l9m7_zqkzxc#$V;<(l@pkod6HD@ zyrSXW3y?@xchV*{EUjpg1mB6JRaW>|cmbfot)vhPr531kCIv5!2;MMD`8dbt6DD{X zsN~h1pnq>Ja!4)UDpBxupf3PE3AUpeuDlD78e+3IAM(Gl?Yqa^=s zR+@{|nJYz&t#WEgQnEUX-YmEMJaTXIUk$%~+wxFqOG147(D;z~M296h!j3`l;#Zps zPpuk=$1dIgs_gX7dUZutdo3Yg;{q+^YSBxzwkqyjd3gopSTW|<&8*KiU$(Tkchhw~ z!Tof!EAimR?~{|3uRcp^6dbH6eOulNuYai)$SD+d(m(P3=l`su?96g4+%kXdscmud*OMs5ys6Z7qubmMR=S##Rd>Ul&RJAe6tZMXq_S_>w_&vLJ38q^C=)6n`$0x_91#;r!q0z20;A{Vo@pdFK0km-};n?)!c?pJ{MtVK7PA5e8EUJ*8ylT$6nvzawjm z(cfL z%CVO=b@ly+_ickpocEdeojZ{8UFoY%tF*XVW-2yw)pO(1+tbV=h0u<{$76Z~1U#wl zGFl$I`_7vUAl&U0Vw`#dS>0_sT#AFkJ*B5;efl8a8G~)7;;XWVz7{55#ieAS$wPYG z=yWypc1)?N3_91In}5^)Dr=Sc&X?~>L#Jl#QyVpOHZtZqj#yjwPF3Dj?PIbg)WfU( zgjxX2sEsF>#{#(q^BrE3bvD|{QV<|$f?v+f-HjW$yMx@&(vpw1(Z&-)YB6eeYu~ob z&DXd}|J}ab4sS$rxK20OjlS$-s>-mGb8YDbh2*-H$y=dsF3(QgZs{Gjxtn`lvW}!;7)CN3ZqqB2JIL9R7kJ0^Lx1od!)bCr6LcZbFrlKbvWTX$ zrYphEev_jtR*_e3?x;Vte7PF2giDKvnTLrQc5~;>z}B~~i7sQpPbO}w4||T>*e$sI zj(c|O?43>{+o_@64?QI5GkPFtt)J(p9P_#w!1;uvI}1~C<|J_>2!IN^WvXSd?ybiK z;~poc+%yBq&Wq0|l=B$P!{pg%Rrjmj&uGKMqO;jLOI@kOHDTBw-H0uQ%zBKU3kq!S z%Otac14v$vDfIIdIz&Zl`0#SIbaz3BkU9?#6nTSLb-dA|IS@vMbPPf;(XGJxF@{n zJ*5@8d3oa)>W?<0wDS0SSiDH;u~(pos?%Z~9mFSeyMWi@)VI0k#qmCPxBLtg@UbeZ zIak378f}A_ey%i$JiqJ59pv3@KZlZ{IfZ}CasVQ*!mF+y4!i#>IZUX4Q(nG6JKC8vLV>I^U=B=>~BEkBrj|G-!=u3}M^-<$s?cN}6o((-?MX z5bat1UprZE7}$AtbPoC4$3}>qf(Z;V#t&wD_vFqbfF;e>#{?Hrhw~gChqOzwcDQL* z(fF=1xewfah5v+YbY6TQxxsx8f#zF;vK*~711ar07L_&M;o7NkA{xEhQ{&@n6_g9> zyovjpB^wiVB}sL>IV}@02rgU)#D^A$m#=@{D}=<*AqjU-69KZ zvQ5$r=!0!mzq{qWI~tf_UrlW$v%>dXydIdjVbS3kKFYAQ8y}7fWu-G7EyOPtMYu}QljN(FuENJqI(b*z%&#YT04vGeqL>zv5Ke@xQR zHXgH2O4W~yn4QfCms!LgH1uk5=2~22PPy5h zyRdN%@({@I{Hhyg8#OTZ^llt6L^p51Q?KHZMeCHcZhEr04MQM^#Qaorg-K?FpD0hr6`qEz587IC7GkACX zgSvod2%1o7f&vRv#R3-{XnxTc)Sq6yzJ3y|>1tgg7$;md3WN;CJL$GV{fZ&Wz`pVGiI*quMsr2!Ji{p=q>dsS%?rzx>@)V$%=ndqqr15NXU_1S zkZf|7ZLX=g^CtZA>;RMXvGB&R7<03}xquY8(EEvn>MbaI+J-S7U#JS$key$~XiB1F zQF2x^ERm1P9zICnr8=U6Cc`Dc$0Hu^t?0)A(1GZGamw9^r6s)}?NY$`fHr7jB-5Ng zVOF`dI=VG>2HQp-5O-5t`KV?QMdMqS&W85NrMa=6Z~A_dYdR2eU+B;Gqow6pj<|;E z19_T(Aip;gLk|=|`QYy3kse=fq*|g_r@cH_LAkW9yj)-B1!b_UM}YUnVbII7W@^r3 zIfLf$W_noJY@!nlL~V05HdW{#I{fF%d{M5saQrA)Yzg{x>(Nx}2Wm0+ zM$3*(&9f~a`?W^BsMWbMae& zBTpuf^$b5{>#sidL?{Vx2#%_f=Z>%@hGL+L0qTelDtx%Nd8$9h@qDg^aT|Hupv`~U zVAjkKWfsxjdNjqX)xJFIAzi>b0xOIf@fBd|}k{Eb_ z-gtNBF%MRLgczYY#pYIS;(W>QWIsa5~M&u8hJ_EmfCg2-p^ z)RrYEgZW4NP!i)bLr-PZVlq83$>UUa+aG^v{=J9ijU07PEt=ZYry?e`V~Hl|4N2Ow zHWaD$10kDE)cb~cA5?R2-YYxw>o=}{OT%mC-V~6%>sl%jaNf?&?oj>FJ}6>8N2!fS zEVTVyh8O*IwBffh^j1U@yiwpek|FIm?JRHa15^@!!4c;Vs?p zszapE%qquYxQ%Ab@?>w$?D9ATlvl}Yk7m=1VX6oyaF1*^=H?sz+DdbT7Q|*)N5fEb zkIPe_%4*9OG`9`xi*4Vtd!CRi@eL9}8U4N&8j9RfRsGWDbHxVd=G&2?(_hFN%zW&& zT{2$IM4QLa*my;)^nIb^Z*na`i@YpQ9B4MAiQCUgUEv@@>()!BHndlxDN-tAKX;ob z>vD2<0N{%il^OtfhHvxW%-NJ_{Z=)?Vhdx?Tnp>*28U*CvbNq5M6Vm}JUfz!FtA-v z?WEC9hf6m)$To8%HsM`;G|tgjqA-8dlo{4YW&in=B$0&k+KkOjrCaGGTsl(13YZ$t z3o_guY$ir~U-AKBD5?8^84ahiD<$~TWmjLcE%+SZpUx5wWuM~fzQr?p^GMDUv%hiO zY+{bzk=D#L%Al|$BxLw^Fi?ZyYmyDbAy8AD{HvZiiMC6eA)3c|tpwAJH}{RtIJ!%+ z+2x9|oxrTP_511C+UsOWWn!yT$nF!>r(6V>UDF)g;axSquLNdBJDvxxgN}mVjS_Hh zphnKU+q3kVBtc@AiWmL*DE$6XbfVZn%3`Y6X%ljdP?ZT+r{JMQ!goI}rMX9Fa--c^ zd2is8aZZz(v|GnG8YXuWY*R!+Jpg!fhI#JntyECrw?81ysUv=3eQ15y3_(&s1_=2d zxejAqz&v#fw7k%?Crdpn@%d0G#akuyzQ6x-K(Sbt5~ViRNFpNf&xZT+62S@IgD{;pScIHE}{*l=GawUdTh)$?j2{ z4~&|&36Lh>~hzvsHmD^y!o@CQG;q(O?3oHr&Co|{OF&WuIL4uP042p zsY|n~;)JS+UM%v{rxEgL7+$c4s}B=j9yXAboP2(mePv5j3mU#;a2<;p9j zJNSN*de%b9fE*-0mRpOay;h6EcoZG~Z|dVV$%n>E@dEou<3%9m|tE72-GV)@$1Nc9;jE>)F z{he#V!}rTfRATZ|kp>5q=Em+^?3scBcTn|bx3u9NNW2EK_m=92LTuDm6U5(*0K;M} zwL2#-BwW&gcFAmcv}$loOP^e*kmZJ6m$b=Cv9|=g*?` zR{c7Zx`&p1rD4g$rFC30zK-Q&mo$tT>cKmA?~dk`vYvYZ1fbq(Ex++pMDdvCE2`sU zY?57W+HA++>GG0g#XmRO&e1=`YKEODzu&2F;L3Lkg+>Pz!;O{$$(+5$eA_%*(VlwA zI1(Ihvc7&{OC7=S&VtYOm@FB8evdgE)GmAEU(F4V9nUjL>1V^P9Er39Wb~rpkUq5N zjZ1dy5>0Ab{AKG!|JFzk_0D}A>VwRq7(^;{lIhwyGfsQ;BtSv#V_yrjsEH;LQHngQ zhl$BM2hU|1LecPo*qZ4&^N>ZwdN^+TEtc#V+WkT&?nkvfA(1+=Ph$Vo2y@^2-yC0P zsF2Q~L7c3wi)%(Nv*Vv6gVf}sD&t26nQlSGv7eVP0RR~V(3J%75)7LHMg4A~s%oQM zu5K7{9Ry$DnsuM)N9g=6S5tF#wnxp(4bh<&QEW^=~ zG;&%%{W1HCKHa>Z8WB=5-IHE2J9Y;mLZYRaU!+t!`>nU4$1g`rbh^z%T@)0Pc)XF$ z3w;6F{BxR1w*NQFUrF({P}A8ve1=(Gdhx5nXa#laVy^kGo0|^17ODC64K;`BYhsIC zmXQNJt-<{es-MX^ktPRO!!Tj!RU#kCtTTz+Q!cIgVDX#bV`vO#`?N{KpP~gP1Edq& zSZm=i_N8}?h~c>mp0q6m@{;8{35jEAb3M_HxLQZZgm5e}O5#ql8hUSL*`Af*-5Rtn z{h8&9kFHy7vy4POTdd&3EVt+Xce<`Jeo$eUYhnO_(c@;lOu9k3$vx(Dw&V1Toz=|W zY+7!bfMD*t;uuK1Y`;}}llbmEwgoxH+UdtmP~_{uuW8<-?0lL(H{L!8$&-gVOrCIpf0;pQ!0&wN~WP*`7Oi45{E;s==n9YXX%m zL9CHmwbhlo9HA`N3{-z46570|Ohh-Vf2?R8>Sin)Nrtsjyud>m`OiF4lhGVPk%IDs zq{4dDKM>`Vce}C7r^GRhiC%04%hVkawmVpf(T1$P9eMS#3G+XT9Nm-AFLVtdfGk7GV5e9Ujs=+7q z?)pJO%B8zC=-%vXXwDinY%X80xhOdF6fk#ehuI_pyd--&*LL@Gc@iAFykf}7ikw@r z9g1e)8FaCx%dah)%|Hj;)nejRe`d+m;Wo)XaHgKw8peBYI`5e+$|#wb8bArJv7C8G z@PFf9ljR7hm`QrU8kProrI0msEiZXAI>KZQxP+q}k)7tS>eE-*AUCI)VRQe1w))YMK|zNy|O>=x^Tq&wLwvm%t+ zY5pLUM{^rWfPvN15F+Cs#Bs>zDQ!#IH);;nFVXfC4De5x{-KaMNPMEamZWQY!tcAO>oHzpqF09FptF-*E6Xr zuMT_Aq|X(YRGDh8FNBn>>Uvo1Q17S=&YuEDlm4H~oSsr3leDTXBgj<{h~9i4#FNi~ zev^ek1>(i%E-QAD4L5sRJ)AB7tG8ih{PcUUoSa=tEYjrUy`I|@YNe{231Y^*u!l#q zys)se$UUk;nH%s@{NYIlX|}(Myxqn}VtbxFn)5X89b8{#dZHbDr5jl}GDpg0e=N#s6A z12gyDHsk=7y&7RgRSN%%?~@IG?Sh4WxkCMEqun0*yE!wAd*I#kWwESUrU!<8%Hb9K7$atZUd}zAq18jpjnRLh(^-w9~=oqpoyxIdIv!_+$ zq6~~bI-?8Z=a&k$!9AiTF;jK=MlXlSExNL>ql<>$YJe6V;5oIi4ri!Gq2syzmff!p zhh8pye)cwM{~8?f#yo9_E2VTco}mBpH3=MZn9w=&tHL!mv}-_jJ6bfZSEl`%EZJc_ z84bUSpe(aRbFuZt%IMnOf}(;Z(B`i?PQ}+x54K&(`lLRAEe(f==@xL6DRf)VS(Y{A zI|UjQ<45|U2ETs2d2jV@d-2ApHUaV7=mOcQm|xk@YpXG_mOb`G|5Y2QVM}|&x?SaQ z?2q~ht`7E>**0peJUeZVIbKcelKY_WuZ_Bit*p6C{$JD$C2@B2<9wAM1&1q+sz<*Y zR{~K$&sKRL^i^rF8&|E2&WW6sbX<*xn_DWTI(fSCuCLeC=flINTiaUgw^}lXF8t$o zKDIa6B;Cfjj*$6dPqT`Daw%&kYbp8()RkwP=UUNalcm__|0QiMp*rQy8ScSH3m82! zge^;paMeMJ7GE)1rz3RLOIWV_={Wt*W1nadbLM+}JN!6K1(TB@-&&WL%IZCHS&vt| zFVsgJt)&Xeqs@K5GQC_@a>#lr4EjlBS+}plEz>=vackC$(h85o#KsbRl-u2|)*Y(l zQZTq+olX^pqHzmC4vgnHliInG5w`{@p<_V}=Td-1Ph=6Mn>fk)iFcC%IeVa)%71e_ zOYC%PFE~yHqK*xw{fi`gGD$S)}Od<6MI^;=Pxi8W=q-H zCqjm9q@PExA8#u`Rj9iq$knvBkv+sXZopS*@o|}oMQVgyQSPAFZGQM3fWq*<$CLd{=#ve#kx7fU9+&jGx8hEdn*{dBP z<7L%*Il&YdrICYC4C4*XJ^4F|QLy8TU#UA_x^%YNfso4=F0|RGpvAC0K|MvAmCTtS zN^2L^W9mP)bD~ipgpzg56r|)S_p4nWiKUIfDPXM=|cercA1#h1XqOfKl}0L zHA!}k^hoA(O0zv=WfYHz%p~93r&62Wc|3BQ&}IyG=SuM&q!`6YZme|i45;`+uZ%O? zjsG{?5Pz^YQ))*k+V!R?={BINnp@+P* zrz#_w%TR+R#Pji4a)VIovfl(N?{$8nv+mReLtXsBcCHXt-aliCEB`7e&^J$2pf_1L zr1hE{hEeh@P60Cofm9G1gF})M+xu2Jr#-{0V{&3*`VVnE+vp!10V#bBiJ%1KzG~LZ zL)r1;^S$UI8#d>(i3er341^*BYp0<&`lW16bu5h@E3N#R=TYp;?cP7GSDE;5DOb3( zG5LLugsMz`wyo4@!{jiz>4G;3H*M%tF~-oBUyM~7mG^?TjT|726-+XTI=?C_^yCI8 z0N9OV^bT)#?=4l!b)2g<(Se8tQXN8}R49n87rMZFC)w#~{_~3DxmmF3r)|T~m1-oF z)LnuXt3t!;ssv}`XJQwC;pQ61NnDA}8pr|&o$&vgcuus8F&^cT?1pC{3#CanA5pTO zCToX@EqRc;=%>{$SMXbA|Uv11dk>7;Kw zaW9dUERm5Rz1_Noj2rL)&=9+6#igw9LGz}Ws9R4NxhW8DLV@GkN`;&ZVnLq*o+zG1yN{qJ2xY#5 zELvFvNzoCRxvMMN;$b3s$pfFz6bXCMs1ed>N>?uQ@(Guxa_WTJ^n6Pj zLxCb()<0Yt`A>b+VXnbRdXZ|FP|a&{lwtH4t&yLDr_?&DFqyQfvA%xwG1bzy>6A<9 z6AT@_{vvIrISp&=-FVU3XjaLxyEj5pM_Fd+1~60tGx$Ex!pX76-$aC;v)PO<7|@DN zMOr%2SqhB4HnXI7jRY~GNn>6)!_<32U0L_uWTHRqR;|=u9_P)UiiAc8WvOH>IcuU# z5mt|`;dM@wzSD?JJ)(2QmG-H5B$~7a7KAT2d|}> zbP&K%JuJZW;C}XpC}S=Nw7u_v13;&UP{B6qKxrDUW2h1rH5wsfIx7l>pXwm5C0k{0 zV%0>FA+XE+XN4<^94K7jMo>Orf~xCrTx92scX!mjmYc9)J4GfW)z{bby0w1!YU@gR zS~}ZVIco2jQAj9J>a|c1tvv1*3&rZP8?jirtEY+`#`6BY#VVopl$u{kBO6eRdqhm^ zRLu8>5ODpQoX^xo_GJfJFUW+v@4eos50IHh#~F7nwFN@nFs7fm|JxJV(Oft~20qc( znaM6?!Z@)X&NVq#>~x-^C?jei|AZPcD+G7rjGl?@wgywNC=;Oi0Td#sPvgiHdlre*(W39)^(HgAP8+| zI||vFUzNCNp=Pajd7Bm)AlDIFo&i)^lMQx;ni&xAKKE(4L9HNcFnMXdwib1!F7|7YMFe0tO3F}89L6i#{Rz7b{BnU-5nv)^~LJW^DnWV7)^NcizS zg@5u)4o&)p)HI)>=~siO`4{f&$=Yl&V}mA*m$r+#JfyOnt<>feIEb1Z8Fc5bkRuwh`#a%`{A}^twE_2;%~?&B5`_Z^M3+@8$eReEKh@O&Cg|t5K4O#h_*Z+rcoX`vbvgSpNM1)FWU7WeRaYVz3-s@5s9ud< zT$oj(#NEwz;u=vhgjIUf_Z*t7m`s>b(AMUYv@$5Uivv=wv~qc~J1TByeDdF^j24JP zdqM+p57c+I(Lcq6mXe&X#r$OMaCh-ZfHLo7XV%G1=zHSemEUDw`sGMjBjJ)sHC0J6 z$<2;A%p5OBX-?hG_v=?v4VfaW|Dl}hI6TZQe3JjJPQ?}KO6=o3CeJu&%$Y(_frT8! zXnPCcyLA-Z9JGHEY@}i!=xLKZgAT8yj# z>DC$-W87hbQ}$#2`7z3h*c{!E6`jk<|ZUa;qKxCm%;VP-)qAYV$6HQ~dCVS!O) zQ%u${nbk&9ohKW$&d~JN!H**P!;=s`!zA>s_o$CqlfK&7&gK?rbH~qInphF%vzuxx zOlqTr32N)U@Sg@IeR?@QwgtLj&sv(z$oGY4(ui7cP^7p7d@iV9OjZB?Nt!?`ZVs}O zwu2c0cB|NcRZZ7QD_O;+C$Wnc6cshtxTfJe_&@lA2Cq=diQ%|im7dfZGPKs155-Pu zkT$bfXUB3oY1XW4(7l*HK>AeKC3IhUG9A1eXD~qAiQisbVc)F5nT7hh)}+b6I>kuj zc{9W26Lb!?$+{9%>|DdqqrO&>V2WRckCM&8Lr3fV_(jH1kWJt$8`rh7tDt-uDSuY?c z4JeqXKIU`;=sFY6Co`cI!WYcA`upM4=B|r-3k8mlZR?LM;=9G4o?1J;Hd?YLP9R_z z`_d=AEP!Tfar@^vu*!^BIX|C!oQfjiXlP6Ig-_CpOc-&)ox){l$&xx7D^bwyJ@O!J zk(5rDP)O%-+vs;jr5|8`bP33>`m1^37%ulO&1pKgIS7on23)~V=-y0BE>!YtUdBdKF4I=1qB^w&ZhqPr;NNR5joD{u*1*R^+dl@;AUgq{ z1;I8=T|m*&Xt&8Hc6!8ihj{1)Ou5!Ct$kf1rfO=J@qnglYt-1azU+Bmpn?7$J&Q;y zwKEHGo>9czJ^bU#A&Z%Fe+=EBmR7C1n^)50?}cVw72{t3%xLxVl%6rH^j~CIzj1vL zWRn`CHo~Kh%fiJGvVek?_rGX+ImfWlhA(?Kg;s5t1wX_h9lo*PHJ|H?!F`+`8ouwMbjgnuJU$l-vHICYcIKC#j^jXtUr9a558AHubZFesM z2It3a7oG@it0YKWA)qdT2JV_T&KmCBF;YsNJOVU9FftoYDB91_rj#E5{v7IO6gs!r zIXgf0EF@U6+W1MP+ov;63Pa;}zC9X^&4meOXDS6s^A?TGc9*LKqDleG^zNAIa;1L_Td8jth4wsadlC|!t3hN zUmmOm>fl-Zm8a0iD4($odFq|CNVL8AEq6-R_QZn#{I=2$zqh`+2Pw5X$u)&u2)KMN ztc#XeQ{%Hah~UjoF^(a7>^tZWc5hBj2YVVAaNI{=Xr7HG~2#U*oo{KQy`Jcjo;b8q0G_f z18v&I8j7F$Ro+Gj*T972wEQJ}n50ALDMju}b+xGAoi|g}YsobHnKqshZl7-oJmsZn zf~oHYpY>=kv!otwS3g)h8tr6|ssYW2I@iE$DPFxJ>z+5JIWG=usf~@}kn-NKo^DZm z?909w48w%u=@KRsjj0F)G2LI~(dxmo;mBXLj!e3qa35E#;+X;Rb#^R^l~g}qMuf`Y4BsG)Q|rfP|lf^o#6+S z&m<9>)l^?|$MPd|yK`8%&ST>*4hcMmp7re8DmP{R?eh|_z(9Bks5^q79S z$BZ-O2L=Ay;X^F09DG6O+4iRxfEvn|x}US=;=0!p2eQ{a-q-9BQ5y`d3%wOWUVz;J zq=2i1NOd-G9<$}xX%N%j#_&R$Tp72982tWkN7vFGf<@BcS{J8YP_k@C;z1}$zk}Dl zt19@teee`C;)IL0)7yHNeswnd?LhD7;1F*Cfg4)$lO%{3EREmCkoQsABtsqd0WCym zzyb;v)GuXcWo6a~2Ul>FN-m&j<39)?4`G4JeF`9TR7J8jektShfxpsLR!i5U-Kihe z7wc-?RhEHPu0ncZ3Xy&QJL0=9uY|5*5uCh7D7<~qAR`31PpgKKB|yE^yC&ZkKHLvc z9I^ic>qOzw1>&4COKCaKX}8(K)Deb0SqZ&Rw02yx{iHE0sSX4|?jCwb3j?SQP zt>n67_%%=a18fh3B_TL?H=3^s2;v#Iz&Cdq(hL@ABI}&e+|hof@JuuMI&XWs3nZ@f zwpC5HyN&TfCoV(rxOFY=)*~NH_Jl7xxL_YqTALd2!UQr5P^$=Pf~&n%TGCip)2Q@D zP3^Rcs6jH}C^JOlP9`c~qP3IiW% zdP#V3yanC}Zx<1A%kk6J+8dYYK*_iuKmw)ad$#M}a(2wx+y5Um{b0h@#C_I^w~ci` ztJCwqQZrly_EOCLW%jsiYe^KWrlzm8(!j104fm9h%Oj=P^*by#)MBNp^t*G4kTmLUK`Ytuw_RsgxEU0TLF2R(s z4Tv17P!DVZBmJg%d0x>qIe+&heU`QfvUu*Bo8noRJXbK8+3N7zR05}L3l(fqPFRc= zGQ$iFn~@V1^6j{KSjhW7*muQS;y*0+%D8r}$zBF~^S}Vnl);V7qm_D_iHY)XDTKyo zUWNiTHG*&W)>9GP?AYE}eM)kZ91(uSSM4y>Jg%INUij}Nalv!^uk~;uezh7qM7XBl z2m}4&`m~hQxwlwR?%iv$mzRk|e9TSOi-61tNNw`dKw2Ub0G%t()F-<8cvvnUl+}Wu z%FBqb!4thVoq%PaP}Z}NcOK8}`@c(u0G(&448OYtT$k(PAh z71{s>sFtg3ChK3lNXjlVNRiW`cDxpovP8`<@7c6|^2NSixn_z>xS+$UAsauCt}}(3mmTS;e34GS5dnkj@*_^xt z-E4kNq8@vQ1GQgya>PyEd@$k?!6!W!+xtAnjH#m|w88iDK@WUvh2^0-v=8bbCsB!F zQ$LUbh=jPau4Z&-IF=Tca+)#k)m#-`DXZR|Iv-|&usOi1BEeX?1uZw1MbDz#I$>=p7u85Y8gF=9j;767x6G_DJ@wkMtJ4UkEsfte7Vw29>UQ zaRyt7C(=@M1^1?3K5!M{z;>t(n6mY@I&;vTYs4R*O$WLz2)v+8ENG5N(Sa!`TzScJj>urqyV0&0o7?pNg+y1I^o z)B;*V`3lB~mksp`1qW86?c_%UMWmOLDSZw~&Qwd$D4ug-S4!hvmqlsLcOZZ^FK~AE z+Uu6as5NhqBzTN&E?ONZ`9D}{?QP8~X6L`~p??7i*Xn0Bm&vme$j@_NYru8jE(LOrdf zol&*7h;W$lv#5=J-#L8Afxi~C?31%+5i^0-?jgTp1i(+(bPJ76s#n7jSpC49%$DB> z)y@_!yAgj6b`s(WJ35Y3kKSiw9KGP}gC)zhH;oJqKCVu+*A(7MILBZ*z|J+q1V zT&98D`(__5P=b%5mV>>g#`nUlqh_(aKhu8OU>;-{{mazQ;=ybvs_@AY$;y@*A5JYe zxbNj{&g2e&lVacC4Ub{-Bl)H(JS^iSqGr*AeOs`5*m%Rm(l$2pvuzAGFvqG=P{tZU zP&itEY=)~(3knGI#iZ~V;TFSs#DL3T+s}i`}yG@XDQxY5O&UF zIM8(^Hm4Ygq4LH_L~xXr>%)AAlmvKfV0n&_QQ*Vg@6Ujk+ zrN&KHeu!Wa<1{4{AJBMOc9dRvva@X*-ATB`>7FjLoz-u-16$JEBzTE-sfn_x8nnm2@1f?dvvZ7IM> zN-RLwjTlroC7?OTd2%$irL7q`FY*8?Bj=1*AF!#odb)>lN9IcC(;-BRR4S@)RfM_BMbGLV-s z1=Word`!XlBc@6Sw#037ou+;dWiu&k$u*oZV5-1Zg?1{rJcZM6{6zVxX$;A$At*ijKX3 zz3A-U6fSFaKpT;0lnf8_Gye~?^Z>L}TnersM(qtpsFWuZ)HJAem%wx!en^NiE2t?<*gI+l(yfLchFv*?3%1<1B371_+vp$-Tgk?WYyG2-o4UnZHq4@xS>Ml$vC7E@J+3~X9fnf^8qy?M!{ewLlS4%6 z#-61|c2@3sJHzi7z2lI0;G}7j8Kt)S%E6_a$b*2rUv67Qt7WauIQVvjQYRL0QIrqeJHj)ydpC{Sh1xkL9;zh3ojc)|9Nbzq;KZN}@Q2uR99NVot=K?gv^M$j#AZs)W> z<})C@Ge~l`re1{$kz@-8^c`VXQ{+Cr>bMtrSDUD%0274NV^Nsn@P+sqQKJJd5FbVu zAldH60~T|eKFtK68wlIs<{)FPe{{o`J~KZ#qg~4L-^KrMS$xr>m1|4THHXT`BUgJ; zEus@m_uAK?r$~gJC8?5_ z;NL`W8y}CIG(SE75{vZnS`APMO7J3FREt{%TwdstOCu&1QpOWH zO-o*;)@|{0-Y|0;g!5WGf(KKoiWbCv0{R&w+1~Wf77shLG>E1Em5$6KJM^kh`evQ| zn~7*d`@PNk%$B(g`-u%AyX~Ar1U4Rr+YHY7B>61J4K`ms)#CmXp^1w(oj!q>44>G& zx7+_UgjE8uIn5sD2)s}ppzYk&Z|f}e_VcdA=8=}l{y)^z4Mh?5OQ4n8r!gZMRLQl{?qZZKoTwggQSE94toO36yAs*wU;}$u~N}*wKykUOO!~vL31hy zEBdGka#!UFOO8@lL`t7>W8Xqn`REFH1)OQJ2^Yc#7TNK&sfv3BqIm|@?}3k zS&9c@l_0qA6P(-qa%S`$cOWwe_zxR+R%kZq&A8T7EG zY<*1<728>ZBGt==Vc&nX* z-Jix}|Cvc{-eHz8fSj`L3Zbheudlk*KW-wsx@L6#XyRRfG%ZB!vn)Fa@tr5rw5K?h=Z;~}&4eipsn(Fm) z(+6Z#dxg}qo4d!f@QbOT)+a2x83JTsC{n`5DB+!bp6U1E*V)AMEZPYDy za-gub7jK808KxipJHo!>z%**Rk(EP`a5*gw)4rtr$HIE^f$QPi{V|T2r+{4Zl>0ev z)S>2FFIU01%un!)Dsg%$o8M7ahOiILK*`&M^P&`h3N%#m(GL?AWV-%;-w;M1E_!|uji0{xIBEn3jhRs=tL}U<-Be2V! zALWA0sV3PnY%_%&R79g}8m=uPerU z9V=$+*iZsGAhaKm!c2t9(GEEiA-kRm_0fs8vMZJ17&9|RRcB^*5Gb*_Cot0)k8x;P z?XGz0$q|!7tOVQWnmMyJqByQqozYaC8Q4L{3E-|nUOugL=KY}!G?ZX%MXk*{N?8We z<<}rkS~Xc@>gCnE!3+W+v`?7IQ_8*7)JbJdRji`=^AZOg*1nU@87oETuNWUfz`F98 zivw@&9<*0-x+fN~3cUxI7q<3U-0Xf^5lQ1%d)^)9dzBy{&FsbFM?2QhS09UT*G z*Jr=Vq^@jvNo1oB_uTBPNB z*9VBh7gkzJGytN|Tru3T%=JQ)usZv?2U}dmIU69!AkhNW+ogz#{~cW6Ui)U>8LqjQ z8V|6L4%LW7MoS$u9>=-=Uv>XzRAvl8Gz3g(C1A=2qQPRL)P%xL!j^l+MjI|`qA}r` z0tEvqr2j}+ki)Smvxh(yBKK_bGInBSG}I#VWZ|aUkWy1Eoo`eXz6=;FP@Yw`Qlz@( z6H(cyFFgbvF3W4@9G1Nmt(@sIXuOq3@b2;>GQ%yl^rt;pZN8T#eZ`oH5(~Mdsg2M_ z5@tV0B++Tv5|lmAYHE#aGq~}QXqMF5s{n`vCezzBZP;Ur zDxx=}tlEsHV({HgXc(bcllN3n#SwIioPKD&Iv!*5N;OEmR}Dj^K6S^Th4H5|K2MHUpm*j3y~kKl|D zt|UtRfq_;}vq`jl&}bm z>l)Dq6^Q60cJ~M2Ym?W7%UF=^9s7Weo1gcuD&S@#ptsd zHJCLC0;Tm!5X;lWpDXN2W@a97OpuY5NepVBWFXg0_UKNez1Lj)m?5%)gvZ5He~VnA zQiCzoBl7h~NGg<}+(}#c4;{2S2o!^LS9WD;ul}~ss!c@1UCbjZ&A{Mzu_fMaZt;_( zxssKGI>W8acJ!INSl!TX(72c$I&{c*!daS-7lk7P&a}-=A!c7_r}o@KBpJnCuF1Gf zjB8%8(_D4NZK**B*v`G0-)^% zh^<6~NuTB}jL>wRLg*7tEB=vbEDwM^JZ0pUlGonmR+#PX$t=I#t~Ue> ziQd)xsUlpF}1KmEct`^T>dNq~!LUlssNpH4o`>NMOtTdWfKm))8 zz^1?lB}}H!UkF%wL$@jC;8FZ zaSv4hld|4xWEf$gP-&Q?r&&MtLtTMBI+4zDG@3Q&<7mYctgH$c?;a*vthM2mF0JfI z5LkPoki!7B=SbD0ErkvN#ZHMf7Zi)^zC*<&Gh}ukQg(J|+IiHI410_Y)sZQ-A`&SC zgmM&5gv$?@V6=_)?wCt~u!cc7FV1&6Gh^jdI3J9}2+)e3qG@9|JD5i%vY}`M*(0h+ zLn@32U<}nU;`E8));8q*0%rIvMq3!T1}V4pYMF-yT0Z=<+2ye;V6|Io;I?Jg&>-vm?Jx&>#;x_@ zxJSyYEi$%!;>P2Crg4sQ?JN<&5d!7#k_WZWrQ66M4xjFiPB0#}FVe5D5N?NVkLg`Gn4koE}yCgVX2t zkq19hQGfsP$tT8kmv(zI$`0s5#{HL;NZxHDv&Xgl;I|A;0X`un+aX%%(v_cK+PsVF zn&cZPSn9K4gL2__)=0PxSQRvz!67{MM`XW-tR_C_rNfwKa$%t&(f9`wK*&t0aDrvz zaD5SV;3j>U86O<{TZR?$=m$0h5DM(z&{mijenJkN1!MJdl<(m1aSmG$eT@v=Hoo9o(C{nUu2VQ@(Z1b4KhR5k3HTOk)oMPpSTUR?iv!!Mz79G*% zzPSA9o=q2e#6-`#QEF}*t1+%#cw1EA=x1Pc@}!8+Z{myA`S;GMTn)VR&w^XPs=b?=qEbxXFW|GIIQJ|MAX(HgZM zW6OVx`SSAyMStiqahCnHLweEn?UEJcwE{s;NNvr&IrT^!yC|if*Q}SPS_g&Faa|OeI9=RNJ|7!^_bw#}XXr4Ra=2 zlzm1y(`)AP0>5MZ+rTk&N50(UmbKyf*YHX|rPDP(4Nqf0yfC5lyHg+`DXM@c%+y+2 z?x|nmZSyZ(o*?dv1yfr4l3V*yRJYhEJhaVoGb3$iFK5cfy#Kg^xACWYol{a0{i)u%&T`0dUWUDG8`rARZ<>}3#m{Ax32 zwF>%In|I6&L*l=-wgsw^)O+7!Yx|#=FN*TLz7Yrnj?h;Y`W>;B~ zY~=|Zp|wXi?f237y*=QeCU?=m@uIXH1+H)f% zUE!EWTb0=+cvx`8@ z=F|AGFY8IV*AAzTrNxby6`s8!r=Eif(X?!^-E_M$<$?j;z@yRbxmn@IK9!lM<8HhE zzIp5-p478-N!vU~8XGBmz`k0pwg0|auGi;oqITfn`gYkxAFOy*2kp)+RQ7+__q=)Amtd|NJDpZ47763&RJz*(VnsqW zE_vlYk=eZMd%33CfRpx(j#D|tt~KQ4cn=)7jn$Y@SP2Cw5v(sW%axYHqDd}U^Jd^a z=W^7)WpEbgsr$Ck8c7XkW9la8)>l;Aa?Ka(5KuZDEL;{IPCQ#EBhzH+XH72uXv(Td z(j|s$$nV&^VmfX9Fn`{*uDT$@?3d`QR?W)492X)k_iF9*W?U_ZSGeaIRQqm zc|c=Zz}$)1xx?_z6#BJCpZ{x`!^@fa^hUD9v*#z2Tv&|DCq8w2(#ECy`!T=UV2fFt53}R>VmX? zJ1x(r2ch52lzl;@3 znYzjM@uW_oZC=}bUn|FqeYuB!y`*yndi|#5DGl$g9Kfy3c09@YHkl))_wbBK!nZ8D z#?$^@{t2qAJ~jXG9z0bOaAWU!?)%TsWb%n^qTC6J&kC6H`|`M%(Q2n+{?5g8=@Hk} zzUu#@u#U=yrcNqtpPtT(BiGlGS?8dsX4IUF?cK*)UMV>%lt@G)MTDFnUKbB@x$EoZ zpJ12!RCmbe;Ta{ZP2L~aX_L4l;==0ZGuTuX|CHsdtm6+8v(_p;i_l28a-MqT#`$&L zU%x#@?1Dt9Zt}BgjT6op(FG+1x}(2z_wKdqb{%@zBE+2@{71pPX|1WcUP^jaL-~+e|E@2Bjd-~AUiMLsjD;{XQ{eN`9(X-XY|qV$wsEZ zkWtT%?u3lYjFFy{Wb6bQ31R;aXKx-(W(mu%toMA@%Kp9I`+mpw$G4A;eb`!it!F*Y zec#u4o!5Dt7mfsbvhFtzlG@fLS!=TbgLGP~pdu;Pk#(nYU(T^`9j}`p+g~;3AL^!@ z$LS@|&7)TLq)AFdl$vYsank67x)0;<$*fdeU9EAY&YK%vjl_{!FNKc2^Ja7%E-$^B z<5-?kb3S9(aoV1pSSS%ShV6cq#9vH{I?9!mGi3+Ltl!={?CN*+|`#$fMP30&cpE@d^fR z0u};aK{il`BYGlJT(UEb51hoJjb4W(i~`IZ`9`9=>iptVwwxeW^T^a^>XiJ;P+az4 z=&PJY4o}Vnud`9inXWE1*aJzu zp+a1wgV`>fiG^wQguDY!l_IsaMd(V$Yc<_o#k@}5>i@Z5xk#5L<<-L>B*=0Or+o?(Y1bQ((jNmVgu6%rJf8l~{Njh6X;uVV{R5(Jc{2x^Tro!EA;*j8ho)NDbp-4(dDys2RCUk%G#n-#NC^nwZSm$AhTT1X)gdNDBWkrpr5M)w z$+G`B$+~|J7Rfd7S~(q@^47aA;@=xc<7b86po`FW>;DA%SUhPEC%sqNZpMl{9`vsCNt6HbkJWwU@yRu=sjr8}7Os5l2$PGi&nFM_ zbJWQ35qGU2b)d_KDtT+l0oo{a)3MG=N^&H%R}!Co?ehok{`W_$yVsWV zLT0_3NY^c|e>witN_kk<@9JV;Jh{t}{aI^9@w2}&fo`HhA%0zxj%eR-H*OjbJh1Y< zcWD`-{jwX6`|jRyFKI7ZA#3pP45zHY6WI_T4EK_F{2qUc*LdUpwUymzoS5Gl#Nc}U zbTQ5sA-n*uWkd@o@7X3D-LNe(STpq#ZtQ#6HEr%+Nq_35MRw#|U_SZ)apyu-BoLQ! zBx!Gp*WHE@9=sv!WeVh6baK1lnoHf&(b0%M1HHF`tE+3pKM$=_WLpLs7o7PW{8T5*fCxpO4^0zLv^@yE=oA&%H}*K(*lt~zZ6yQnV^1wuHcSQ%SD(0_+NS}F|+ zvRtLI9y;)m-ez95El|Bl%8Xt|K&b(@(-szx&*G~{-$@ZUbYGRyYLoZTUM;+O%>622 z(9>2?+p<%&Zss#{vK=RWcruKGb+V-9pOIlF|2U?cB(f-X!HDa3`UGN|?b56%l3 zD958m=2LI)tqqg2WivT0q6^8}*5th2M6yjAnmcU1`;HEa%IRbqNn+m6037$5$Z9u* zIZ54ah@lAy58;%8^EYL*!<-nAdaq%)x>?PySybSV^A?I@ziru{nb_d{<&2C(f8R`G zHV9ro zU%W$F0C{t*P2QZb-2T0udl7*z#^yfrl)DW4Vr}xk>V4ZDm(x4%zf;|o(6mvJeHp4f zW*2a79!&m72yY`8S-^a>YXv1)hcSIvIP8UK&bK1x%FeX%3C+N@iZ`N;9XfqlezMZT z>1TVKG=V20!y@aft;q^=92UP(kj>U?ybE-GT;YbsUK~`wDqJ86Yy%)_T_2~SNbP< z`vmm=<7O^%Qo3EoX2VymeDf?3bynuv8S%$e7HuxxZMDU;wg3moBj4${gqv+wN%7W>A0Hr1zB&WSWl0BlvLy@ zXs&v5$N!}qI3Rg-sl^sxh?ppYIsLAJN#}YNJ{DheTR3tPYO(EL2kLUQaiKTvEh%FQ z;yI%bp^r|wEbk(2HU-0+(*rNejWJUyQe*Js3+|y?T}`nJk-OFFMk&+d{xd7KHyQ3o zY`R$V%zM}X*aX)A)QUlEzI>)aH`y)n!iQfL4AEa+zI$m4bG0Ag@%Wedg9gF6UmT8O zY+pY&r5;;47Q5Gx{h$6^9Q4#5KlUR?K_eYmdkZ1E_earOBl$!uDcSeT&Wrl|zAm&; ztBFJha&7Mw*>D7E44GzvYgw5TL=LQvV=O6-;$v`{>^- zyVua71OuOQv%TvoJivD*Pru2!cRy;8Gv*^kg&pv;gq0%#W}TMMbyYb={6iR+kQKVN z`kYwZ1Aby#)2=EOB};nn!+TWW7jIuH+x0Zny6u|K+ zp(KR)V`t{B^Ftd+J%3c3CymR1QKGsIsBxSs4XN%@5D8hLo7TigUpiW($hMH&0C(hF z)SrGsPQQRgb6qWb+T+9G7y(WX#xJk_`}aEK7vETipsKJ(J8Ly;=Gvmsv$kUVy1u0W#<@XS|G+$C2Lq-Mz>&hrxU93h_i~VGNna!m~ln@`LIu2V{ZGr z)j`_m{vXVDB&}GqVS4bKTM@ktO-eUok0^DL-EG;of16O2i8d;I_C3SoWafIsZ`d@} zyJ7^|!UV$U^@zCX-X{96@yKjz?6p3$wKsR=j8MIQz>CrMp*O~-_j&OKFh4?U(^p^C zmbxPwqQvv_)5iLMhSzd3)_Ep?ZDeb7MzQ%bEAH&9GqhrLc zJIpQ9ElLcTp!@#CPxH&a?iOzfSrQ=eD>=}(9>SPo%=jl)`?F3KS~52K2VFgQ=}q=< zQ&5@zPWyg@sy#b?;+m5lVR+@0kb}%yAubanN+G#5D6T-FDF&^v{8fbm2~d@G^~0|{ zZL9TZ%Y5`q8Ilz@*z8`PV&Fb6oIsr>mBC^g2dkXm>d88?2WYV3IPLuQ5fSxYrXk#m zr9R&K^){~FXaP1ffG!(CS*-G?DiXWKeg18*6Z=8eK!Q})s|ASMRHGd?Y-}Z1_LTGp z$$P(x@*bH?!|ek!+L0oAV+>iq#Ee~kvU43-c^!qti+ONIC9E{y3jrGI|Lv1?(_CE* zk4r?{sg4=ZjNKp}5ODSW=0jHE>SORI6l!gD?0-M;YVLBpg$C*PTmyku>unw$O+%`J zTy=HCgovl4!B^6TTaT~H!V17NtHtH?==cd1q*#z$49aU$*N1b+Prp{LIWxO;jQiky z{zH%7$U1VYzAnbKytIP!2uJ6$m*Wj`Yt{$WcyZ4!8Bga+?04pSnj`zIg*89OQ~B4M zvDPF0{Ys;+ht*X2->;ZP>i_*0^M{>0Y#RU1-&lzK-|zH`GW_p_p4WaIa{v8ijD0Z> zE&i%=>0(4*{7uEGMc=H&-vkK!H$Q&ySO0%6=>J;1Fr{=L12_a)9ocnWFg$T7bG?9b zyK&r)M-t3LgZEn0=^RXUWzk+KoMjx=SKvU!-V_kHK!Cd5T%6x?Jdf6El4{u4S6-+6MX@h5-4PXAFA%GJ;mfdl zc;c^mS28&IzTflUC z&yiK|7NOOA4u#}uJr=5>RsI(=zfH4M0O=3H8iQVvonuA-s5%=_^>wtqn^s@v`(I`& z#AQiDq!*W7>!7@t9Ly=dsI;NZS$OHbvCZrO5J^RRcE}?_NS! zMUbwsyL2{aTpMO4w%np$U>U?%I567b-{`6OF_0w2T!KeP9-Yh|G&ZB>kLTvf>l#-4 zS|9!TwvmW@lVF$K6l1=pHZ@x=Oyw;s)S0#Gq`8=%+{dFfH{qUKU#^X5aS16m16txWcP zc`aZ`nS-;e!o8lCraMkE5#iFi=o_};P^$@ z?W%7mA>PXVrZjSQ&Z44!ttDTw?3co8%Yt93v4F$PMd!Ucw}~fPxN$9w?JV*nx4l?W zYc$WbzbKiV9KMQ-4lK$yubqnG9-KsD4O;xThr|4Q=PZWZPlDqE)XpAca(qk6js{(k z;Jo+?%h&u5P&i~DcyrD@S?R0BD>h0-c)foZ1R(Ev?s$WoZ@2jRK(XxV{d)iKz4q)v z;XUq^?uBU7cw>qsCgDvm)-)yoWQ^SWj zgvLUPofgcp!TErf`uVtB9|7sSGVnGKZO{zDH&mz(AkVgn4gi-stPguO7&y2$>v zQ0B{W5}w7;C1T9?KC(DT=3WTW3l3)z-o9%YI~Psip#H1A%DF!Elb{uQO73{m&D@Ij zPR=a}9hET6Y5DQ0b=~b%XkzQXSZZ2L@sd5B24`#k%yD17GpZv>Uqz+akJH?Trh{ZV zMBj+rj>uzv+W)`?-nHTEYu0f*PLd7L6jAfz9jm=%CD}TWZ<-^&VOX=K_}A3l0@%>#bloJ*RF3R_!kUDKMqWm+dJjmFl}P6}lQ=?hwJxe>5TgZVM{ zFqxeBgV@^po0oPyW|K91;l%cYx+WFUW-sBUbNY}D&8TFs(pRY?C2r4Av7Ng)~iO2 zvHtM9XLge0gF!E1$%noxzUVR8m70Y`!Q4cL7yLL}c&nFqeozZ!bYJT6J(7^ZvNYmd z)X<7={t`}_y0Kg)U@e>ID_!om((dQw1c5CP5u+SmruKr2s@sO)>II2rOdVp*!fxAD z|IKkYh8W3!nr8^dr(64Y?D}7tJ~Q6Xj!h@}{Xj{UZW;*LR8R}k`#8tONEUIP4nfmw zGqepg`D>XsSA1(x$xY@@opJ7*`!I=Vwik%p%t}Cs6ANvcYd(8^(XI@A^sS?k+_tQ(3E^OjBk|$u1 zi1mx4uB0Rm6+9}4B^()nJmv#ipjaDyQ@$ zZy`!;MM@{tq9d$h@7yGDSKDGjr;L)-b(}w&3@YRVAgAXneEhzyJCoFJsolJC`UqsWN@|B@-k6Nz8e}= zMKTK9^<%c3?{PRpDGTQ4y%HH!uMc?4ZrVfh{Q zQOd}jPq zf3SQR#g6Kg{~{&EG;kkPEwlS;Cib%L^)x~zkDZj&vLWo9ok#I(@32Z~`Nc~Gf7VSt zSoM58;VlGzJfk9|rbNGa3;zYGCdP*_lX{c3DEbrqQljw%>Sz=EuKAKYeMV3HyPi7z zQEgQWNJsm*i;Q=#H>py+vu*AcF{3frqnPR{kL_%c)$3IgqP;BPMrG`@uU>Y4?SySH zE3v5g57@5*C(;lx_|9s%IwwD`XT_>%=a4LPsilm!9|S)?tb4NE!V>z&xO#=>gq&9^ ztqG~k_wIQ^r0cD%m^J7YAOF|WdkxZ&VdRgMj9gp_k|FbbUR zjt_cnK8DAY<8_~`7`rT_7?%YfGG&4>&ISQ@*nmFT~OpP)rsZ608r63yxj4MWHVCy$;>^4(XMmL-6z6$!_`GtnO&)@c*4gxpfsYNP z^0^uD)QskkC0_X%A3o?t?)-K>ZAfYU!V$gqjH7|)1)Vz^b=ztMT;S|#lx*BQ>2?gP z%8m~WuDDKEl002;{0Ci$Ln%hLnL0VSkeaNUmPY<~B(pjA5x>Q)1D(wi@Dh@ac4O%a z)fR0g#PqHBYbI6qWW!2hr!oJb>2Q$U2v{`->8~}~E%Z?LOddZ-5M*)Me^>9)v7V~; z@4t|iVYIW^zNPtTGmQL`l9R84gxucr!0JyuZHBPmuGJ@^E}||o`28sc7xBs4$wOV) zoDr?8em9QP<3C3&*IG_Jb7$@b_>;9_agI}iTQ&q zG?W~+#3#n5TjZp}Pst<)Csk_l{(aQXenUCq4_}uvVxBXmb(##7BR}asCe~=|T*L94 zR#!jw)Sl#I4pdi=vG%&C8x(m6rmHq0Xu?OOVS;akF}vjfAyUXRPqj1Os+FuPTXiv4 z5trg36^m+;=B_1;QM)8Nk9q#9;Bl%B_s}CE9jgbm5rBcvY7bMHlVBV7@u>R6ssDpR zz+fBf12D-l1Li%rSc}Yo`5|r4uV8TrhAwvQZAXuX*LC_F0uRWTn)8O+MXL9`H8raD z)Xu^zv#u=8@?tW%)B8&RJmS zy)N5YCwR@s1rF)=8mK>i9t@pL0Rt-^Cl3;qsK#UKh+m%0K|Y0wes#4bQ*+Yd3cZ1T z`DZHi4oGx+viE$h9`A5<;La!lM&_%atX(+0sp5`8+%RyELG?x&FG9O0&83tsfBzRa z8C&mDR1(O%?#{Te#0{hy0gYSvywF|iv%MB7&QW{YvY zxFQ6k8~V;;U~(C8exs5Z^8t|Lo0qd^u!P1dehfC`wEYEU69s-{$oHPghLB$g!D8L} zhcEOJ>qQh84uvY1{|p#NpG91!50?g3K1Z}x-5Fzi?7#!-*jm(lqqU0d6n<8EwA#tX z6+bfvi$c4r$k^*n;qY`N!0MAWaUFxl& z@L-fUkxxEd%4jrZfdHp-9w~jgj{TB${8A=7SsDC&% zOsPc{IZ6Iwvm3v3bM{Mx%L7!pp!DwE-3|L_lhl~wd}k!9-SW+=dID716HwdAY#$jw z$rYtJ(d62)>-{5*B*Ac!dv8&862}w^umuGDm-F4SddaP#6heA;*V%U9i=zinI#H3Y zqd*l(r=LWi{W2^xvm)ENN$z3p390D2v*SUdKh+oJx1zHRVYca`zD2sB)P0T92CFM^ zdVa!x<2w{P%1=7^FC0~07%Qz!Ng6Vgdj9Av?(7YXlK5nV82`Ds9Oj3(r=_8iIqa^P zCuwn$5>8T+BQ2ICY8FWhUN>8P^01IY0hzKijdQ58sL1KNOV7tVkBB=+MUi;rtL$y( z(lpId{6Y8pIdme%M(Bw&|H|T*N+*)_TPKZj)?yAxM1c-D26z~wO!opW7t0RzFG{+4_p)LU4(ophSq;A?s9D^&ozq{)K?(GSj zr^+gOKN{sdCUDn)5rWA8?dcZ4pgAaXo&aK!Nk8;wRnc3}#2JCuHS#f0l zE5}Oz%fjd5St<0lz4RTwwwgN6q5G)tbuI^f-Pg(IQ<1k;kx_NdAEJE}n~4O6IwcpQ z3wYQ&Q}fKE5=t`>VPR_bymo6=D#6O?XHw^f4B@euP-#qkPQLtao1Xd1a%tjSOQ%NoFo?nowVm~*2 zOdW$aT=wcgs?sw)>R*B|GdOv;Y;-H{SI`eD56mR&q=erXtr?PF9w*u3Dui4>v|8Dg{hfdnS8pmep&(EXWavLZYpPSY=3xS`aIV{M+kXkpz zP?F!(-8;i>(g4Pb(3$kJ&fA*v=S^CCNn^j?B5^0S%IQFZs4bCvcKj6n9-=h(V`Uic z1g_24|M-!s6qmA^5h|WSns=MEl1o%3moITEhQ^WVO6d>`Uy{0l$MP7G2_hHR#wo~P z=|R+T(kN~$d_!BFw7kdvbZbQaOz{5wJhUGO6VOgVnF7c;N*RDS0s0uEoxjyt3ke;Q ze0mN!)~#2%dUfE`SaL=;FR#ZvJ;`qm_oxX3=~n|4z&ZAKrU^XR9v{X)*rr_k5B5;y z02Lg*Z{CXjiqDsoT69V>xfLBI=Rn4!eQ_*=9)crhO#Yz0kdMddQIB&htrAHssP#%T zE3;OW#tND49!u;g{-GNE@h{q0%o7HbtGCI-v8`K}16Inr)E8q|)7y8%^bsc7W>GFP zUr`n^VwU!dh=4|vyK_-MPMvYtz*tbhyqp3V>)j^MrD1oa>xY+LEj4bQX-jtjlBv^r zLY3uYA9fGJfOZ^~I1LDW!y-=T^%6Z0Ajj5yY-OdVd47+QGiPHmn*eih_^ADlyf*N$ zc|+3&=NIk6--fDP*ez=TU^PnGb>a1T<8>BzG+me-v<}-ogMVq^2?_oW&!==ed(Xnd z;WVYZs0GIq&;g+wd4p~%(y3p`WpB1W3vEpt?Q`77f(u)a;yRlH%-~N^ z*a37QX!BRem@^R=A)Nw;NhnW8rsRY|nbe}=*?LTzl!~i+H|!A_n;ok|)I|SEtnMn; z?rX*j9@M=Effm{_yXVd8>CbKD?1*$&AsPdy@5r+$6EcOAF54%doAAc2O;i?IxZm%! zvhoPZeknIWe>soj_=g=>vV(bzG@nJd(6D(m6o<+6Hu#en-y_MHDDr}RVI6_l*c}SP zQuB&HYb^X|O`Ih3h&U;R-M3QovI)qTVsD1~OH|hxQ~imqzRjPjW{SeXR)XosQKn7* zd2s`NLWo9+X%1BYot)_ZiJMZ667@p|@*NIRJ|oMQ(&}E)gm*2c&)+3Jg*e`!eQJN# z6CW{~b&a9Rv+T>BFi^6P!GOFFTqIi)moJ_gCLB!3YH6Eosl}zdw%Tc*ch22;${dVE zA}+}>CnwJRB=+C3O2|0!Rn;x->Jj3dZDyLcULi^RZ2$RZUvUcZ+ljp0!lD8XArUY! zK6|Y&x`HWP1{L4agJb&xCZ)Ohwtp$yoREI}y>z6O1g9W!Zc3|NY0DXqFzrCHVq%+ zAhCbF^IB|u(vVURJQW@+Ja)eoTO@6gB^Nxb>vQ$`m=&%18 zb#_|ep;_Ql^tl&x1 zWAZg0K76z(X@au9=Os{t)q8K&8J$?&G@8LeNb`JFfAOdeIpwm(kzwx{=%~8bL%sa}2ygQfGvN4u4Zo;vTR&pu6 zzbZ8OCWCci^e?8zKE|)r{_IsEsxs~>hc|UolO&7%h2E9zh=zDZVqns#85+kyW*p47 zsB>#S$Zkmp0~Xl?@Cd-Z&@lqOHIM7>gBUr=+Ajhn0eabrMgmrhe=L)KZgBz?@+|Lk z@DIYm>mD9`xI0Z5Gij4NPSb7g0R05|0rXS+ zZ*z}M8gykpbQt`uACSf59kP?(e56Z~o%Z~kZV(I{KjZr2zLY?}-AH)XCJirCjHO;n zXtYieTG*L8 z_$1sYKEw+l1eSp?5?F$NAC-nrvK9ezZl?P*bYCGS?Iq9Ql=&^iHoCdgJ z9BIaR*(}B6ozQH$vC!dd0^#9Cr3A1ziA8`Q4RP+wehXN3__b}_6SBl&d(;N5YNQ&( z8;@3Rsu*^^O5r#{Z)mrPn%Kcd9~@(og}!3w28>^kYq z7v8@5Iwjv>ZSiaWelSt}b3`usq3;*VJG@421A3;+?Fz{?dSbSAMD4&}M|9H+^^RSB zMl6Unf`JG=1aUqLNJ(SdxYy`Mj;n|D_rO^S$G^{74Kt10{JfLQcWkZ8<-KU|lTN?^ z6qJ(nRi%_IcKhl;2wbS*#l1gZ=osy!YFXIKzDJWbd{EoCgAdsBpYFIPTb%htXwSIL znHG}cAtY>x8&>0A`|0H^^=~{26E~FpkvH2;Xe$QE)3cL>)9Bj4rPsJctZIjeldE!; z0reDwxxg8pj53k1pdP|IKj)fyu_Js*HpERC&5hGI?11E@gr(!p~fkw$c2Sl z=(FUj;5$kor?t6zNNB?v_Nm|JHwF1(E(31mq)le8UDmL>hq?u8r*&QdFTpZCt59Ey z?4_aAy|Bq8{}Uv;+@w#rx754;j>7Q)@yM6>Y@F@-{jyUYMa1ZY>*BGD?#r;TXs((` z2agMv4knx1U?e3X9rqX`qo6w4y*@Iy8tB5`^_gB|oeiJLJ6OIs3&})5S^vRr4QMC` zmyz3fB{Y=X^l$4@b{8d?=RkS_V0P*lYYpE$()Z!NOGNLLKVGtfs7Wd9kTIhjAS%He zi0XY4w~E`#7J72j2Xp-AN`Q_P$XVFGe}`KA31|s&uZ%Ggx-g$cU<-rZ?)}x(w+_Zp z$DUstr@aItd5dUYnMm~eG)*bhf9@$>w~KH6(r0v2R_Oy#$DdB7Op5x`P1+qT3GC*3 zTP{8SX6w%2%}cE$tD%?4hAl@v0Oe@47Ar>h=zdLM@#=}~n>fBJg%y;<7nHR7a`Mn} zgs!9W1+}_MyJ9j>ItGd6UXn>BKQ_(`&w4-r3TOj2CAQAA#o(^u(u(bx34W)iXN&c& z^(Ik~r^MEbI~Q(07NdQb0QVqTj54zaGgI+uU<)MJ)@1k1)%3;|*oe4YRWU4SZw_+T z#G#3H1%hK9J;b#5*r644SpO7H^>F zyy&p!bnYs5ed#xoAZpp#vJpv!Y{h~L`*h zk-cd8-J$B>u~dataM)GhtBM%eVqk1bH|nxmX{MdBW+XAkzBkBV&oju4@EdMJq*c zZtj-Qeh3bw7IZ%7A>;pv7i62&Q$m;MzVQvXl=l(!WZetpEya7msbsYxTS~mUxjj6> zVt7e1^bIlbEOmNkx#lk?Tc5Q+@T->;g(YLyUa!mV9u(4_#{*aG{MYKC4OBZ*`#$1W zK@pg~kfq#S2K@l?G8n`G1c-Thiw#0tey_C+M{#qoY5lA(Z4{cX812Bm@}1w*<=Jd@ zfyDlV><-@n>jf_YL*#1serpSdAjhu)IG`lT%t$A}vI9(1OH|0tx0_ZK$^>_p)=m0= zF_wushRc(E@&}7;^Vy?UuJYe)O+wwswvd~&BT5=0rnrytJcPabd|{itoCqMm#;53+ zBoAJaD?=7MuXJ`dTY)~#|io{kjtfeKC_pQA?KbSfyoT1{IAM!@I?saaYr)^ zRa>eSu?2|#O`_qpx=Aa>td4lem7QQQc<5$B?mnq3a#{qVq;=+AUZ`r7Cl0tBKn3~a zJuCk^Aih&Qs+iw`w6K(*y}7$$BY&%l#4)GZKn@~ZY@RiSk^5x|Pf`1@T+D~h;4Yw% z!BK?7dgkn=RyRJgtx?t}YcLVUumRlG@Ta+M6_8Qw`3LJ|?w&_PM8*W7rMd3z93_+J zpgd{6Pcr27f6e?>&Tp|Po}+{sih5O)-*atXQJvei9o z3uWuC)|N7%k+Uq6v9sH%66OMsS+sbUdhxJ_Y|I~X@PwM_Y69e3INDIE|}!I(P#OA40u z!vI=$r)H-P>OT=%{q1E~6pYyGQd;8jg@P8LXWg+rM^@u#MY$va{~{w)4lZVk~(nHUeLG%*XP z?I|IDFi3y8z23i}0y^+xx8TQy7D0;gL)Rgu)0vR1L>Zyvi@RJXvo@Y>bAN@1x@ zF(}SBC_3K`?0j3;)3~hYN;gRdvL1g}VlWF7U6-Hfyt(v7c6Cf@O85AEqWwdi@iV!@ zT+z*>s1dEHyH+SvcTGSmjqV^KRHMvP?{{p>BgI3mD5oYaQ+}FS)a>C!0lm(z@Tse)`~yZ6+6^ zO=K@#9dt<@cK)1`s>-_`4tgVXjMXNBAD}qO6?T_ZKD*>UKrFhR7EYHZ#&c~;Y@p81 z8)6g(_?3K9Li&*I>GQ;1VoROY4%xs4kTjq@?In`&H3^~vh{U(qu+*YyW+ol|gZ3&h z6nV~W`4R|+UkYQRH|vxnTln*gS`F&FvRS`;K0`Pgi3+pjXIw-9RlO^Jy=AXs-Fbfw zHVx)aV~2#_@O}6I1|6yuHPs%k7q-4j-poBO--uEZQF)r%=`=|$;l|(q7h%PZZf8tV zbK9OF_Wf^|gD5c=!Kz9t#dhK9YmjqDs)40&M5Fw$Fx;RHjY>Hse94E}+y^th+*y$j zh$_cX3q3@CPFr?Be{<1C5lR0YGig5463>K@)@uSme~ylHlOE+=89gw>x?)R%T^_6~ zi?k2`DUf%9ab`Z$pGax2msR%%&6mIp%Vyf>V} zDv|iCTjWGft__5j_(z+(W|#aHa8Qs>Py6oY&p9rLI!z0Dv^>CJpd=++m?r#AV=IkJ zE)Xt!6CXcN-2B-C>BzI{bN;JtT{oaU+`ckD@b^vr?AJKuIMlN?Imb8k9j%w&uO=b+?ngFW^h5HV|h!AkRXlG5DiW z3(LxJ+P+Js|8f3lM&SIty^~;pfD{+m`NYJDb>XP7o(EU09nn`FJu(I%z$SDL;)GOH zLvqecc!(C4V8C0H3?AyH@vWZ`LZf|L?`2ivoYs?R~pgy9~n}(AhyZ zQYZd%dL;_MJ*4mBAMc|{NPYvWKct$i-}jW!|VVC3R_Q>zLg*IMZ|W<<8}DHl@cI zMKcYp2xjM%Pn7#->MC*_VP5OWx#tZ>Au7~L!L_BOp)JN3DXn2nGt^_*$bfN_6HNg< zB(Y5utv%<^X#=9N-o{W$co4<#NmKN$>NM8@!l>mK))J--)6+=V0iIk^J}Pm&M8w+K z6w@*os=AYl4KT(<*st&m<7$raC5Zj7O1#P#G%D*JTkB#_H#r0hU0k-{!Ehpttyrgrj!kn#}Cr2$?YE zQ6SXzdUZhS&AQY2Ung1ljwR$wrdi$2!4RH8nU~Ptu4vO`P1CIRCub!i$^tFNL83)S zicU>b8IXM&C(<#qYWoRv785s{5ET)&B!p_P$k1R1n!U*wd$-9Mjw?jdZTgeiDmbLP zT=BPYDWM^8DH`$<`q^`m;aoO)siYnD&9GI@WNMy`auge3-sV0C``hcjUt3kN0RhNy zmq70v+@Q@ONfWM@Brv~2XXZ+@P8e=KX_{?p>QvwY|Mw$7_q2J2s*`p^p_=}4s{JZ~ zT>xRA{3>$9jRq+RmQ*<;jAMF`$H?|SNQ1e6XhRUUFczEyf>NdPKor?@c* zo^_3bphZ}tzACl`AJ|PGL11tEDIjMFwVn{fWQL<4Q=Rt5eXz0fDDj?NI6SO7dXBT! znfKB^5N%-b64EUc20@@I3_y`%B()B^Lzk45Jk90#C$)vB&$;+5uV`75rjm|n@ZiY*ZKdD8$GM3CPJI)r$4 zu*evwjh;z%QLewaJ(y@P6)i^ zW(NU<^id|djyC)4iZ5%cH7r>?6t>f@<k(0KnG>yq(ncO1yRmIWdh_vDjd%Yr4CJqE?{^&&3aUZJS(J%;j%J?gPlj;9VgjZRKbD1<5))xN0TiUhWdEdeu* z%DT*NbIU3?*niD$IeMhe@zX7?3VfM*f1|w_@+H@?FS`KWa)-bE1^jM z3eFo{wv=u`@m*7`a;wqu97agu5^$FXN(QA*TgtQeCNLoX_}(CQvDs2co}Q;r_f}3u z0gI4BqDJS@%JgJW+1sp-?^YWXQ!%@u5=}fo)hLeviF_H(hU^d!I?MP{QT8#u&fRXD z&fv}=Gs$byMof#7*)qUdB@)_xDi4oXD8Dz&9{PaG}w&%gQ@L98EZBJnCd{}-7CWDxVp;22fyV9 zF>EGo!FYry{^zy5pHOoUqISKsFoz*RUL$(EHWpkhFXMQYW>M_N6Xj)@4a@#5A;C}nv(;@rq}M! z)h*0b@2RkuFkjg0_;B|cA~Q^^!|=UriFGvvFnbvsUp2OL@9N@%Xi9g>w{QZ0Zx;Ou zS`hNOOG+D%;?+8L8|S43YZiPVbTa6g$uj+Dl$mamoC-O3)meR%W(pkImp?7L#j}pB zLT>`|rsUR`7{8*NYN?*&3xvv|taMkfU-^Q1)#E>QuU0S1vGOse1o<$Uk zh@NEd@r6ViOo`~+RZa$c2a2+zPbs}CkXg5KDaJcLg*!mAz0Ycfu&8vzQA#*{>B8+f z=ZcsYsG$R%Ao8OeYDl<+cqi?DeD4%Z$uVN$mr)3Hig2z1#Sd>4_marOGnXV1K1}-} zh!dea>KFm|EMArG{ezFyJ9STR@fdHry%}0O(Dry0DqbKJU4;6}_DTmQZ7p=}vh+~u zaMsxU0JQ{r1>*GK4g|y{IuJrJ;I-W``pB6k)TIr!^-g#ZmOh9u$Tcl8i8R#Z`BQs^ ziIR`$H%a3;v;VTgX2vFOedOIkMms(jo2-nQd#e4Wt%bv;yS#gla^@9{NI;-_4A(v5r$J*$g;b>%K~^4b;5BzJ zaY%2wh#9(Z_0=}p-^f~82nFBKj2>|>!PCaBh||?W>Tfe~=IZ@X~bP+ z>ApvjXUpd{Ky=OW^x6>4imP`0N-HZn$63rhsZ8qsG-j9-4BlLGZ&Q;jkQ{N=!Q#Ft zWMhf#)wy}D*U8~le-fmILSiPgbg51HX|Pu~y}i4y94w-2pKKJy2T~z%${$F9T@{G# z9n$MyJa2u)1WKVeizRt8`$ZjLwmRC~qBPoMDgdq-=FI3kAPD~=L+|;APH!HsX?&p# zH`2OXYeDA-v&+6J#*%wTIZvA58Wr? z2fARtQin^p2i)gkKm7K^-eV>E^%xdj@akgduqrC5($n?F$XTfqMG{TvFs~^f57WON z$`!DDw`pF0!4wx@qu+ZhmTntQv)%$4Eyo z9^76*`Yyt;L=<5tltB#a%pwXv5G{t98e!)H=iKkB`QFI!y%^r2FhrOnJBj`zmrOg8 zzY-iG|4`*Mjt^xam6N}V94f~sn$J+fyQ;O?%ss!uiWZ%Fyk^^_nBODdCR&>shz=m& zPWc#Z`MBzRc}koI1pAJm9gW~nugJyKLp}9#gdPgm3ga>CbVzZ07)@XL7w&5-XCawI z!!xXXp*n%Z4ivVv!*PrUoM+e=x6vig2E${*$BxK7Hp=5=cyWCE!^oq^$cG{hKJcNm z61E|y>|}+g=;JGyRs>NGXS?y4bsWH_e;xtN=ytlkZoZR$?czPf-pT2p zd`-l(M88?|Cz@fdhtxVaJcu{FS~f3DNPHrUL9C(y6J8{AB3Og{czjRKRYrup0l@6W z>#+sZ!8Ob90n=;ZK)Z%Wu0%O#Bu8a%QbMHZpK(?TP{TuvbVCE>=zq=JUyvyiXzW-Z zitjbVp@C5xkX#79qfgxXOEP%l-zdvLgTV9~{0tvDG%Cf<-gvA){`Tc>uJZB6&RG^Y z18JgVC;_EI@JZA;cY}xYEte3(oq{i%EvRjqy!-vWrmN1exR9!<7+@qko$$mT^(f0k zS=ST{(<=?cK`zbsi5+r2zGnqNMIL~06nHza)yQyRrvY)iwFzFHT2nT?M`)@P))Za| z==k)z%Gd`AFM^65Z(TN>UuFR@ivryQ!jWVfIyNCqBuZ6CKDXDs;%4K$c@&KhM3*?4 zi>TV5y9jNRhv=6L;iCvLd~4nictav9FM^LSfP+{#9#@_5S0d5xpe-RBq_M(h0Y&ef zm7w3?=s?Vnf{Tu!)bDi#xUjFjJPPR(`+|ITuI}|N^JysO-js?-5Y}~aZPO3jZB-|I zEK%bHYl8Nptm1amhbC`~PcU}^seyYD|2?aE9ikCqfA~V$)>?rdxmhnmr5)dDIr8@x z4mAe!dM7|jB#{?`=Rs6RDKBsk+Ld;o_59#HY*j6_5+u_me)ijaX6jg?XT)mU7hT?{W8x@=gzj= z^KDV~bq|WTHSTKIpC!y|IKDV|VB-^Bbd!Z1(6P7(p%-a4^Gj%a`VV*v-*c)#p#?3{ zaW&|YmWav2S|JIwPBH7TxK_r z(frA}7o)p1fh9TO7ye)mu>7!TwQcP9%K_q!=k}D=$if(WUhFsz$DMdleg2?N@SXpg zp;swuZJPQi{WHCz+UtG@DjCHIUYj!(Fb=S9i_+mc!_HM6SuRRfK^+5#WO!0uC>%m8ehxj#G;7JIZC%72 zCUh^Fh(eYEZEP5gV_>;JosRsUX>oRfhl8*oLhGb1 zU=RW{{33@c{i|zjcIQr&r*kUMF~qd|l~TZk_b_RRf+<1A@_WBmu~ndVrss0@)bRB# z`kz*nw;A-8m^FGam$CPdAdvw=Et~xD6q-4ol11Uf_})2Uy%($hWNgo320&F)(NXP zb|y9z=cBm-(SsRz7e1{|VI4p#ZPFH$P-2Z1BS4l>3?76jms~C)3S9=OE6{ z`wA)tt0j!ms=2DS;v!Z0)h$m#h5x+&*TdN8VZ_Sj?m|r>)Y=R)Y}9UN>>tbm)LOSn!zcHe}j;V_xfF$gjrpp}pW^}n_H z{^Ub@>yF^fkdQ%|imUlZ(`}+^hYh;?@_pY~+cc`iN_TwrL6?DtqgqBTly~^%-J$k# zzim&si|s3jOu%fSaiF8LyhwCTg%WP0n=~^j7Oy+_^V>lb_dq=0sDb?-3|eb@U5N5S zWt}x{`8Km6bKPE??y4Qdk4=XSVC92;Ny5Mt8W|6jdI4w8LX^JR=Qg6kUvD!p$0K&N z%U^~tP(`Qt3sWbkSf^Sa7MZvD&i|VHcpj+yE&n~8S?oTA4qyJ41PoJsf6 z<{T@>hU0k~O0biN>&Ycbr0)ca$vWUwFSV328W@x1q70UZyt7F8#lI*m`KaZ9dqm@y zHj38;)+QjNeVRaJ0U}J*TcN`Wc$wA8N+BFsOWSJ6g_3HKOzec?2CU!YmlVJGE)|d$ z$lwh05w($@%Z41hp-*`LhgNPy%nkLx`TIkaF@9pWiZOG+n2uuJYub>u6ZCuPf#jU#mSU$3aTm zisSgtKvb4$VS1PgiU}1A)rJ!8FA{ELN8!m9{6^BxpAv0tGDk|1sVg|o`+}~AToPp1 z>*1^~@ny>j3uARJeA&aLlgnCMRf~2J7UGq%qvSrq1urH94aT3SN+ep=atjoPRP{hk z;+(^2N0f2ZUqEm4*{&3V@jEx~6V_{hKv(`Z2!!DY$e_=TD{(RQ+Ahqs_$Q}{k!Y_} z%^ToGE2q=uF7)I&n>GzPjQZ2P6(PKo#DATJ?ua25Rkg~t zz=&fKdiT++KtPG)=TUV=MB3aQ#6T$#G&!LDXQW(_wNRvoqaD<5y2NL2-;=b>B$|)# zn}F#p*G_C}i;`LY;JhR!JA{E^oV+RwtlYo3e+#slFY8kii^sDdE9e9~xGvU+3`aRG zlPvm_Jeb+K5hNc{np-Pw?)pW0$t}W&L%~y4Txc}YO(A@X>a2U5E7IXaFxx&+9*M$l zUwMsd2GMqEVHuBPAMGHp*Hqbhf9yU0&}{+$9W?<_PV|aybb~1+#t_0)7IgahLIpqR z@+#DBq{^u$IfEMf(p6IZUZB9e-MfRf~>#>}gkJH_-)J z)cy-dD?A(LTSo~>6CBqiCCt$UE-@#%a0rpJn9ANg`f&9Em;iUjEU7@Z+2%{zrnW$- z9UUuLOY&Lh<~j5A&4rj2`jL(sVz;Y5O5l1q8M6!3(;-PmF!U#PdkUIYD4+bEF0cMQ zcr)@ubiESHi}(0<3ei!kW5ch^+MTT$zf?|WnislzF-EIx8%!PuDRa#e|JHYtrb~8m zACN}gIRTxliq3`IP~N;xAuc?)(!ZpA$Nj%?zjGa~QszC9AdW=@0I?6pRsac@IBKP+ zEeo?4bQ+WAA^CKOWX}Mep5kA*)@`y$nLo4(q8=5i(#9g&XFr^Hy4L+Ah8^@1bl(YP;ODTj0o zQ525BrB2J{e|tmRbj}0B<=gBA^VI%A1!*;RECa1iyS8XLkd2wXx-(@nJ4NQq^kGQ2 z1GIV77XSOI>=<=sCF#hHGjYgM{O_s$tkJn2eD*M(mRz}x=J$2k_vOL-H3bfB+sxAa zQ2~l=XgVP4UD!^P(C6Ydx)nRmVe-j?(V06#cbNB^WVvcFvq}>F5-(mOOL#J%9osrL zX6{{PP*+qduj?KTAQ;B zJeN6!I6`$5vWJppn$ceQF0-|hXJHi%Xd?Ckwj!rR(GG! z0e>Yw=!RBVV(8pnPKL0l!1xSSN(|RkT(lMv9&%KX=<-)r+zFNU{{9TEt}~SA!5I|x z_4%cu>!H@K3ccA^meM49{#6p`k;qNP8(9cDjLSurCtsysgItVI7BQMWu-b_N`frlN znBT*5Z_<%n;^#!|F%ks6RJ*3@Pv&eQ(PRZ(ZD-KKFfn6}%F(F0>(7zOP@)zWEe-q! z#Qx~S_9&L=8>H-mnds<*W9d5P4252t=KDEGW!iyOLpMC44AMRM|F({K0N;N&t(vzU z+y19JC+D1>SnCA;&BtKa(b9w5iZHqyovyyodYMZTa-!Si(*TVie(VZMHiV6$4BG2$ zadsbzQVhtsvv~RPZzN&e3Pn4_dPA7Qq`4FWQzv9yCf0G7{e0Apo~f)BX~NZ|+LDoN z*-=LLGNG^9!rskKbpS+$I53Ee zGLH$CR;q}o2*})u!jVxyjf}x^6d6JeMnJ?6<_G}-1V}=ryLMvFJ>Ng@J@>hfJj$z( z!vJ_FGS zV8YBGzU2ml?+YN+wdnH0Ah!KYWO)M$cyBZZ)x_BY`gr!8;(GO&R9zAF1{9zoYcU;R zP9M0jz+n&}OHs;?dze>!enD3@`TPI&O3vFMx*zpg7w3=Z@b+ToRFuWjx^0xN+!Dq= z^0FvkEk)c{Qa6L{Ab{LfLJbdO_=Vh)S9Y{Z%eAyNIznU47WDz(1R*&8aZL1s{evlG z_02KXunzu^&E3prYMnlb; zR9rnYURM>HL*>h^w3;BL=FCHw*+o6n-2x6$89CAfZ3Vqr7wc2C^S{ia!t9US)2k&) z4t?_{n9}Lqt~;9{sxY?#tZ8iQ4xO1xjh{mq%QdTenqgR(SIz&s4aeYj*E~00q7`_# znC<-*Xz28+6WsF`@u!=1zE7QlVlXL|aTE1R=^WzoE=V1BFY7IFCx(zUfllc)xsZCE z?6!d}vao3oDiix`RZHTows0N*tq^m!=zH3A*w6f|{0?Gr=G`2`7G)VHSIs_3g?)3> zMMw~0x6z+?y392jYG;kNt9~I2He7~^dk{}4+Zl@h*i6J#RoTv{|5DiSwclqpDS4jG zxRH9}PP-REcl{Y1$|kE#P?-Erh-~Jd$mPr*fO4PKxzlgFf;LUCHR0gw0x&sXjbQs$ zJ8ZoLtUEzT94AjT^4YKLrQyt^4)r^~D>U zpj*G_eZa|oaaKify|&H79qoSmhSz6;Kv1v_kWsGga|0pc5*m)$3oX-M+d|5K#(e+u9lx+L~#bLqlWNDvzInry*Y50^!SIP!R0d>z;@J7R~SR zDd3woe5tqzdbYGd)dUTsrCZwJHk(ol4i<6$&?BXYuiyVI9+tPVc}QrgfU%<;2i|tC zaR-kLB-*MKza6}J;hQrmuP*e#xK`QP=-+$(Sa;Q^;-A07ubZ714t~s5oP%PfYk!G;8diR%zzberf1I(; zv&Ri+T0seSj#bMPA2vpyuLR>PV74FcD-Pb%-Zu_DJzcsc_S+-{2%co5~&UYM< z!})WsHJhTy1^5z(SYo@jy@WkiMwv z^UXc_aa+N~W=cLNs1&4Td`I#>3UF8Q1>q5nSrGMxp?H17@~n{e+(+$55l*;9F;&8@ z)K(L*{2I_EsmTbvldwf_dirO`0avi53;1)(($QmPPyf9uv(oijMV~dBjuwy1;*HXr zV$=bIu;%vV{|038uYX2Ji*vy2`=`!>s>7Rzw2Z$v^XQeb$A$Tk8-%+1}aptwZy;AqugJ#V*=?MJ77fATu+aVJt~UhdRImMX7-i z59G>OLMzOd2faWgkHXm`mrhYrPjotjseGZja&F+88LlnRW&l-c_28Bd?K8^PuB@0=tGoKk2L)k=Q;sj*j^N#9Dkp-}c0~ z^V%xaU(fziV-;*5gv!Riq(f+L5wxgcMVR!&J%%knS5q)gwNTF)INKij;z+wb7hP0B z$Ne6_0a^Jq>QJQ(-6!urDD9thq&{<674~}VS(DcH)s8(MjMv2EYeEzD!?!~E%Ald8 z#mx(?dKN1sjZ2Neym}~`Ywr39w)z=QS0MP2VPk49l(j3vco_U0A?2V35AoleYr&# z@LYy=Sw(!uY>YZPR$3T!Q9tWpFRQdarC$OERYqJF%MwzIRsVOyimpE%?rQnj4$?`` zfggHb2J~}>ZnvD*A^&;-SMo00z1(RFbPN!U1rd8IgBZRhs&E{{&VeP^6Ljt6`G%Un z2?4tT0@CB@Y$Qd^#qtsxkR*~&qRIVv*6_H(y`P1^qD!E{2lL&SwICX z#z6CZv>AuuJ+f|?5X9STmvHP`Iu2M%Pjr^mTYxDbD|1ozx?>6xx<2h8-14{ei!*?5 z`~IQV4v?yXr*{LM#;idSt9p_D=JG}`q)0<_;Q*4wp+i>WdejvM3cym_dhN9S4d<-_ zZ2&bpu%~o+NWeyE5NNH8D$mgHv(WJQ?-VY;c^d&h1pRc)>K8#H>bGg4!aWIFQr0w! z8b^1p0_J6tON4NfR{ICz6|d|P@FA7I-@Aa@djQUcISwryp%NspvKYucXk=2sIzafe zZLIWfuoCCG4Ysj>uhdura7F4yU!_Fi&Hf#tQd0vbXm!GwJ}Z|Odl``5RD^&Lw%GC; z3*POv0S!C)02&?lK*u-&Z^#_20yHOw!ye$+j%>wNWBYfqhFTEen_*5(@#Y{&T90gFc{B9RyOf)+F2QUI0mWm7Qe19gf^Uq0jj_*RB(IC z(;`K%Y>;pah^+g^(kHy0`FFU%^F9oINWH~xBSd-znTLMxg0w2dohHWw*p$e{(brSLA4!;m_ZWj zk#5$NO{qUP6ayIAaY+BZ^whFn^nO;cIPir^5_{Vhf?Z7-tKi59Xgchb1#BBs^-yc^ zlm8aNxsGvoj%b+(eRe?Xq3V+!s7X{$kX2anGX69FMe)_$-)o(7DO&woJ%}-iTnPD? zL3Xyp$Q3}#*MU;dG7|>g0To7O{+tG!B&525CWEf^(3=seo=aregpiv1wnkfxCGkUZ zExa(WH+D*ityj*rJsG|=Fvpwcu^ZL@b+AV7q{^>X6^7Sd9)m;)`^s4riJ&MNx0)Af zBzFW#;o-=vhQ5yxCU1h+7XPG8L+_ogn1&1{|3B7d$A7HNcNQu^zMJ86iYSFZ$o#h1 zw^j`&*xZKpSSjFOWZS4g_9#pn(;L<5V|-pW(M?s{KJmou_|q6E1%vkET!V~#NZ)lW zmV`P`u=qN>cshSz>!BNWO#XRvSZgAl`)WzSaL_`}|J`iM$3lxKrD3Y~yN_3tK3Ez@ zP&TBQQqOlh?zv|1`|qB&ACx$+uo*6>*V(gokG_HR!H#fC;UuBfmn7Lgv{tfN-ZEqT znPLOH{8WVb#FPFk?E-`FyqLKd^&c)(MR_IzkO$;{rkMm>E25X zCcUoRi^Ey-U#ahn%;05vXA}jFC&!lGr)TS~A8_B$z@ihyk29zrC!Vwg8@BQJhvEL? zT?;P`>gebgY))nLKSWR?VZ)agc&-G8_re`PX)Xpfw70%bPxpN|MV)*<8F#GIAQh8Y ztWB)wA0v0|R?f^c#hP_4b$Uc_*M@|}-EJ>sT|ZKIGF;^GV_qOBCuMOF8^hCZP;Mu3 zs>%4VWUJJwnwndITsy6W&=p$=TjVWgvMXo91GkUc7pHx=dLz^4GNWa~pjK;{?{UZQ zlG>wm!z}H5Q+goNNTGhqcajfGn`+)->XAY>cgE;Po(Us};vmT{aQA$)stB!yg_pb~ zTn5vOb12PsYqJ#-SX)?}&#kjfIvuZf8KDFRLJIBXiexcPldg0?)BV*(0+NyK> z$}{qo^F12|u^9iz_mT2VPPd)1y$LI$PCGsg^R6nAhj5EI4a z&z~>Y-_AVa*u5#WrKYj5bz;J0Y3*9h@}m8X8xC(+T!TD&Mhh9GPc6F?65xVK9`roz znB|p`K6BJ8FR;hSxvFZFoOv=W8)tOJ<>JOt*O9p$4IVhF7ES()puBz{-%885w|7&@ ztA~t3!py@$Qf=++BrcoTSFIV%AC7J$@UadB*v1q|ZDi-K$9KtT;O+y7RP8p*s<_{u zrz>Yvy=$b)gf?MlXArAP&F<6k&F25e2s5LE>9_hDwJ$F(3xADaQZ@|DM{`c(o7>Sm z^|Nrs)XvUo5N4RhsTuXr`FS%($A(NI@-qoynnE68)HVJw5%%vScbq09d@=5-l!PX~79YysFU3PYXN=4B2 zrdr=Ofh71-UG;`?l#~Mr=7>5`VFo6X`l1K~&T%>MEGR>0a8&Me9({_fJ}wk)Ywr5k za6r0c>!_ww)t?t;QS|d|kN4W4wRrX{BZb1h*G<1Urv8PNO?BT?#ETM!`sm>7ZlaKb z=aASQV;^}tdW{=wchL;(I0HXsz-}D@7e_V5lV6i1{UrWUPGbryVKLd%<|sarFoDGx-JN3l7!pZcQ&;~-4>s@>FekfdVc#B z7K@~Lm1t|3NBV2Rw?cVnp>xK=qhX-4tJV5Q;ic@NXl^r-CJPsNOSmMMXBV}Gnc2bV zLwhKJPR`ENRi|i^VFMoW!tXhbWqS*|Oq`vK!QQp9rs=}zI_Ex|Nt<0|L93@eUNue$ znvnGLIE6~Y%;;rasj!$RFk-}s^c~cQ=~hbWp6AbR-+rL#g@&17Q5e)^kJL&C$; z3P)&DQ!`$XYz@UEE6|wr5~S$$horkKXAhmWb~jG`DM*c0R$1v2FiqJosFX$VewgFE zUgGX0nbm_W^`&u=$#BrF5E`qvq=dWiv6e%CMGQ}m5y#P&@EK$ZIi3@qj}_||5XI3w zy}jde6x^!7-D#Mc>ZRS;Ns*=d=w)HfZ`>#D(*u3EZr7Tf%A)1QLP68yjLgVQK-3`N`?%oo zIW5HG2Y*E-ZfV02AG&gs=kKtx= zW`b9vu$MCa${RHJW(z((s}kS%x?P#dS!f->#?5N@`6Z8$U%ttJeY4x)F-{Rg z+`O)j-cKDGy40zwW1u1yG8pg#m;!k@4Y)Mne#qHrcR=Y>c5SPcwa=yFp#kyQ`{?P0 z6s_i=I6ixD*2KI{(AST94ML?;S!tl=aJ+Xh_DetTjo{MeKXxTzqlG5A`8Z0iZUM73 zt#W?$g~;E(jPYv!V_LIPYcl7G3{ zIN9ZSufGQzNLhKsu5wT8X6EGNE=4OVFgzUt;cdZ;5OU|xaO)f7-`UsEYutxzO4B=TgZ+QY!OX_%$mt~h zRU0@?rS`;GKc!83!z5$XnDg^{&0_^1>s0ZJvz?t(8e2&}%bw9*xdO&Gcz&1E{PWg( zXC-MlI{KWpGw{m}tIK;!+uK0UEiPsvC7BT)M3x3d+I3-Jpr;f~ZBH-f;}A^kyd#qF zOx^sk){$soWKgI9eMVU3qa}%7P)HDNn2zvM!ytq9M=KX!aV_^W%evpoLSCiv*|Taurq$KS3(F$gQs38lau4(?hdQ}<$BC~=!f_t9S(B3nV5dg&Zdq`4D&Gu`!0v#P2{Qy9#ZcXWhuVp zIH%WU;|?i;C#|eNkPr|-Zf;&NgXM$G`dtEp!Vq@IX~3n)LCYW`)FrK~xkO(&4(=4xYwn3mJFs0^zNV?EG&C0=B>u)8>5F4 z2o~FwqxnLYd~9^CZe!G4N^DSug!g0>CR#l(6Jri@6Ih%B2yzGMs|3JNP?bq06DF&R zvna>AIaJg9>mXPmp|tizyC5}*UGXpuZl6$aL0oJK|G)?@NFmkecu{71X(@yT#l0+W zJNF;vkdyfYwAYuCl7fw0+94578%x0h1PQQK!C-|Z>IL6pa4sZT>-e}g{7esN28mJM zK_&|9@^P`Z0o3yQ1|xAFeNL@>3z!RlBS=9_eZ75UZw?Okl88ot%aU0{&AXIwrXH&n zoId4(VP}+Krzh6hT{}ZUI4J2oohx$3yS5i5Da|j;IrEoaCfCOSvTR)5tE7J5_4xP^ zWTic{!sU=;o0sm}*Z1MW>t-t@@K}}e^JAR)xVbn&pm3-e9CtA-P*SxnIHMz*zj`oA zzZGtF0o&=R-;h$waJEEE&SxLa7XJ&4P5q`*9SSWJ0YQ=D1>xdI$rz5wXcUXDV#>6^#}^_hZ90M$gCBa_iO}g_LzDyYG*a-{ ztG?PEt*$%U{rSDAZdIon@ul5Iq1n@`S21b_Vs2_WG`5lOux7bE$Cuq~CmtPeUzZO^1K+ldI)#m9q; z1xf~~U(H%*73faC+%u0tyn2CK%p4ZIJXOu&_4unJP8+EYh))zC+%b;z`Es^`xa1~% z%7SlXPkFq&N4R*SFZJCQQ&0N)-hFrtQiQxE$b=TUV;b0UQ2H{)RSAZm-M%U~KK_7` z+6U>+Wul{nmK5Gq{kY~&yb=Od zC%?u=Paog1Qc?pMBoz2i^G9EDilgUw?I_SQGSZUBksHab@(}DQr>c#&I^)OD@0X^m z!O?J|7X}X+85xaJA3Cu*Hw>Cr5`UJ9sX*3N7$R>8mjv$U^xZMVi<#!{^X$Ru&kw#3 zVF^Mn&^u)HnG)KmU@0B`z7JsuQymm1ca- z$7@g9GwDjPEyAqb%jjXE$O;K?!LVN_S!qXRN^bZ0M=w^TLuBwlB82lG1dK6s1F=6C z>z7!utBcEHp`x&8Mn|rqtjrVcgpZrkbJ*B5kM1_cCT0o>i23mDmjY@*cs1G8QY4^$ z1)txfD+_a?sDUFH>Qk<#!5e|(QrQIV;rs*fx0R^{V-vI%{&2EY4XMVLuf)>`ucLqH z|Ind~$F|XmQ#o*~IGF1lWC2|niF`4wQ^|t2aj5#ERNq808K}34+@9H!_pSFc9;zRt zO?aX;R{ua{H%8rINM@4xeT)?Te8EpEdFy}@;cplOy*dzg7yU{&lWY~u8zcZ$r%i;t zx%f1&ATpY1x?3JCf0RTa0WKJce*40h-Rzr-)D_t&i%&n)Md}vBN9b~A0zfIWp<7`! zpKhC=^aewJ8GnjVS0l0cNKk;;>3gG_3_5?a|f13WZT^U+iF zHc|C9RD*cJ&~CfDg2hij|Jc#)my}$8HL@nDdCf*@Wo_UYLbj=Z?3Ir+8t#j%J8ZWW1CFp@??=oG6~T*2z!Z<(CX z6m*s^^v&KC?hZd>PA;3ZXcqVN_fsKA1Og-^f^cgn_-<{5K~6*T021lhN|_Y!()Zh? zO*}bD#=YkAqa(OW9Ee0JpLzHMN%T-mTTFZVG_O}0URn(Omkx_AX3ZOwGzFu zE>>WQ!#Tja!_07J5v$QuX(O8m-M1H?4#a48BmaLJ!djXXmUzed=)${9&CFU~jyWDF zJXUg1t^1U`0s)D9NlDuU!#4Q&jT`oevzg9oKuBg`L92m)17{ZQ&L9YJF*DK!5X)Ex z!gg30aKhl|ihY;}OhQ0g|2`&<-jK*Q9c&#nl2_n8)fyqsMcpoCv8HCWj&}F3AW+F` zA^I>U7MHW^i-$d7;0cVIGOuWSve)iansrPAp|evrBsl6$FQD>)J_^JJfXV?c7;H{4 z$a8wA?3aLH_k(yfm>jd%oDxc7F@?k16q8!ei&=SeRaLdIsW`FQW`|u5{Vt(Yc2khG zY;Lvyrpj_c++$~_4&ol2UHhY-K7|d+2uqEPkMAm+8{Y8KbwyaA{J6ZK_@-s$;VsO&H|&Z3SXs zuW+oA;T0QcTwLt4X-(3@(!Jrd`!4nWGHt>cxaJB2K*3<9O4eLy&dtyF@`=90;kqP*B?|ICB!8S7)Lm?Tx{GkUg;j`Eg+ zxF>5cr6p}=^g8~pCms+cD|0pT>un+^` z7lrHU${)dtvvI-+T|R*oj`&_Rhc||lI`x&ak-!515&H6_S0f`PPx=z{S~lrfNL*ZY zj)wyeZ5{Y|m7E4#Ea5mPutQl^37S5Abo19eti1a7?}7gfT$?J3s)O%Lgk`_qtnmoI z^l@XmBAPE+Az0F=FvO{$SHqi-mhnclj2k?5eRJ>!^uc=+65xXQ?JnhZNeL)WXncGc zgxLN80Zg)Zj>>N?5a)u*=DNFi>bd1AY{bZ0XZQIN<|APOULFRkH=l$PnMxK@iJd(? z5nvxe-~+lYF~IQoa9j*j`#4kf>Rn)M6O z8=m*FnL?qyAjt};EW44>22ZT2sya42zc=y4xv&3i>cG1XPClm>y*Is4g1t3xx!@V7|z%jD_8jMH5OZ0j#&Z7&`VWatQ{b*>4_e zip6$r*1I_{7!c@*QH8JUc5hK#fBZ%!8NyLG#04l2Uj2`Yw|B0_G?7=~q<$3F= zjL%;rY>R^zRFas~U-@Pq&mvl4bIKdTzi{xWe-po}fs6+>AB=fLMNmjcOok{i5ivnv z6%d>8LTWdGoC7k2Q6ZOLW`RpTHxoxq>_*m_zZB$Vt3JF9lKZk<9J}Cs0$HUnyr_ zM0;y@{);uM!?sAkBPVy0!TRUs=b_iQy7u$?BP-`gY*H0?=W0&!nw8CO!$mXF9w~pE z`E;AtabG_|8u`f~)zuMWH%74*K^U1fXTaZpM{;x&(Kq~Y*pe4qF9@%>jy#yBzIA0U z6$T%{RRZrWSo*-8nE^`7J_Kap#hJS$C2{UjgYBu1fPpXdKaIa1)MzO#?t4GbsxDc` z6jp+m^G6EA*(f80*ZRe7BQqgW^Rl-5Ur9cjMA{DGp78iOVI+ZY3toU;Sj*civ3d3^ zG9)f0`^6azA^LdU@8Mzx356(-#BimQ0nfw`dHS{}Y$v3LP^_ol3UwEOlP4Y1YfIi3 zCY$`T`zR|yuTC~JI}QpP_%n6o;9;5I zI7F9crO5NdHjETD?dZ_Yp{!_XeFA90zLG%=HaY6p{g5@eRDZ!Bha5DXn2=2Ru}SGF zW|sB{%QesHa1ePwx@lnw3luCOd6qhb;b9?Q9C6KEyB+sa{GVNy#L=aM*3^480U1n; zdL`iVjZxWPgGx(tkZrGHlfX!ccM^HD0uG()+GlRkG1d(j=kd-TcquUX@KOl)r?koT zk_D!4D3yq8f|q1a514pJ=VaM4ONq%5=%9fFhhM+2lwN;imG6JVw;tSOcftpVj_m6O zsy8-%e=NPEL-lT0Gj(77I0~$ym zNJ69_uykuvQxQ{}lS4Q%!oS9x5AA3WElN>a%O}+KWex>cG)R@~O0#}70)H+Y8JU~z z2cz$8WK&GDFQy~nSCXMih?j3lrRukpv&@=@^pIJBwI6tKc4=vCRUPqWb8}&CZay-@ z%|pA>UQVjR(_jj8hr(N{~Qsjo3my{?~ai#Ve!dxI?7@#F-%Y7>PIRjYVg z)?qR#eX~zWDXXF2JFJ`>H#fKO(FH>=_I>VU;%P`30f%L5Y`p2lk%vX;-N+1L0%b__ z`F=dlZ+!ir=WQUoT0PBhirCE*>J1%8OIg!8#)k&|pS_chs}9_loFLJcZ>rKFAISzv zo#>c87xkFe%VN%lsEN0#ZS^5a^aP4YDDm3bdTtQ}7;-YhPnMC`HMz@M!UZG#v#7!# z-_}574N9Tp;3mG)QndnGw9)!CyM`GYjV`PXlAoFwM3ZnY>FTHL! z*rT-qPdK$zbsSP>R+`18=H}GJMLWn{!KgVi%1DJVAHJ8W@Sv4T#a+8yG~klmJUqsP z!}`w7y(G<}Io=sSyG0bIEd^kr1e?M zazCZzy~t1Z_Sb>qYlFeWG#Don(amPhuR^JC46n;OQdusk5XE&?BiCC-YTeTYZ*A;<_w;AZCQC!(+77AhD}~33PIO6C3s)e;%n1bn zHBm+=>Go>Vnw2#Zi!?SDg)5s=^i|e^v>0suTG{899RAg}9dAN5@O)s+R04?-XUA8C zV1+$^6AulG%N2KzAu2R6c|F08UTz>vzO){6+UKjA0Z+J8S8$v7BRt=QY>>Deq8D+ZkRXW$ zQ3N0wAwbD;5{_9X%cw%_LC|4h`zmRN3Bn#Z4K$T(ioSf=q*>aWp@;y)rZbW+AQK_c zc^i_aB_;ahCFVh_x8Z4{?B?BHT@LyNBR0#pH4rip6g?1$MR9#mB&~g{yZhWCdj||N z`st!sKZJJ-2!CICU1>i1YQ(pwJ5E&PD;9I5r(y`*R4aPn|F$W^g58n!1lhPPX~42UwLi5q2AF5rJIbLA?3mjGht( z2`H`KvFYOJ2PG`SMQqdZp9zyEzFzS~0=rj9TVz*^j5o-}O3}fn%VENlGF${8_|Fcz+qEC|7I57t1OtiHicYN^UZYwKI zQxmgJh@cS;f}{aJE;l^mK2F6Oi0=xb$te^wC2;Lz5>;8`>;14KTEPTtw3-9pSpb3h zDmUKXwaV!Wu>k$R`exbg%Caqf$XYg1@XvnfXVmULSIauD1e1WH*4Yr-9os-0^})P? zpdg%#_ZXj=!dgLzJsA>}s#~`*c?*VUYH(!ar7J3!Wak!SK^qSU5KV?5uawh}UaxvR zcXkArV!fPrFc20KUL>q9OCtb8ljjR-Ecx-RmjNYt#VUN8!QgmG`fLQo-@ z&pw34z|;P;G!(7OcL-yeQ7<6o`eedTY>!7V@(O#TxLa4PuSN7kXf^X{~Xaf-}Y@ z-|Gu=yeyWMn!mbi^We%a@=YYszln@7B6sZEsDID>iX6WDSXiT3A8Ro*Iyw*5MHQ9| z0$G4UrY2ZeGV2@m_8}o585~$Ll&fF^!Q`W#H*kfBgBl+gTvO#?*|I76fLkwNwkI?Y zaA2>Ifd>o;)m6yO9VFhI^8^>K`~w~`b>ooV!A#9C-Nm7;Hkaj_$dOz!H)poeB{$jQ z6_NArFcZ~rzzz_FqiAJm$)hp$X*!T3h+2W^LZ1mbMS#zY$}Op2i1r{mK4>iCv<4Fm zawrZ@ogC9!J*!U1sR9DM55qRe)y3S{$h@qWI5fN;uc&(C$R-B>UByt#Q|*#=KfSJ@ z05d!CHH6^+jk6>JDr;%1MR2cqoaA;098f+EDimN++Qj0sOUeujiv#UrV~&A=F~AgL z+2-=3aes5qGFvQ=yhvG%7Vg@=Q9Vi|6c13AmrwWf_DJhV78m^k1IMdlx$}Hx!I)S? z4UurD9KKJ%ST)rsls(W87s2*`^fVm(L<^+iAFNDp?KkHWB}8$H6gIX__^wPGs$(Gb9$07D+R)Rl_kcY)(Y8J*O0$YI07izYD=biR_`W^~Zpq{6FeEby_BNTG5 zQM1uAP)lJUm>SroP@kESl2TSt<{9p_lb#NhAogZ-d$YWkrF83J1Em$X(chXJv@!K{ zGtRwslSTIKx*mZ0Do)rjnemyLgxCf`U`zwp8BnNE$Or?^#R(_B&>Lwr={~nLBzhNb zrbgQ4eD`IUhufw-Qt$Q8-iffP+N#@67oS3U$?JT;0Y4ThIikFOFBAZ6TvhY<7S!5& z=zrG%nJObAWAp>ajro^B5lMjdzJ2um^d_aQ;*A)cUJ*RK6x>`OY>YAK2)-n)!Q~bZ z)z&0!N*PZkz^@Sc=`v}a6S^&+xXH}Uy_aiJnEp)n&$VjhK}bN;Kv1?iVXnFs7#+y(8mOiisOk)p#TpPbgFOP+w+k7qTK6x@ zCYP5AN=u$CNpYf;tbV4+YGh_&09@7*oft?0a5{Mr5*LBz$xpkVmX$?7n1xEECfkm} zQbI~;VYjqIrpLKjR+Cf~#w<>BuBDejhQZ5A{EP)+JbM6n9Fk)?SlZJR z2fjVPCaW|E`2ex#I`=wrb_W|tg4~U(Pttq=oPu|~Qg~@sTC~vEZy@*Uz?s~2jdfB~ zi+Lc+8O*j`cnV`|J8JA#TI%8g-J9S~;LdUuvrBY zdA9mBoXx44;71UVfOyp4B%qBcM}lM`V);b}O9(KD)qLN<#l^DnaxX~Q?c2AHg9)Ct!#uC5f{MfV z*udwsiTku^E&FwmFyc2*$HE{-kzWdQiQ18gO_JA1>oA>xRIU-{L|r?nTNvQz{IJ9< z+#THf+isyMd%DzDAIb+65=NqVk%IVsr@spKN z&F+!$R!v*WcYtmh^%z$*TBM2t9Wa$;A9PlAI$+4AE8=1|@|Li(P@e>~XjQknyVF6N zy~7tNg2+0~iNEp!HS;2v@DQukD-#)f$A-s1%}dyl1xb5>c%B4MLUa!^%Oy+P#axuD zm57NV^5|GHlU?6(ZYXFDSIw7vSeX!Rpd|U{|am#nl%!4*!`pe1qU2(=3tO>S50tOU6PjYrOJ&>n6B2wM5 zWeZ=#Cch?%NkJTjxImHv<%gqr+*1lf7acL>O~?4YSd{iTfS;?|!qV;)9D!)KklznJ zr}f+rd!~jl@ub#$#xHw($v@bjjuQ_RZ~2w0jqK8dzn*ACgP(mY6YM74N7uh_b^oU6=3o-^-?e3QH1sdii(Kn3O0#h zwGhj|gh`WQOAEeY8kv}*3n?>JOC~cch$`#}FDdc(M*@dkpu42)B-oWkI@jbiYwV(f z(b*`c3Gqs8Mc$MYXW=cSCJRIU*{^2R^QiudRA=E#g+j!P&X>=a$>2fSn=1P!I<~*) zj>f*5_+a{VTnn&TkmTuJwA0big=m7YJg4p#AFmDh;EqH=0dt^%QdUvXxHv*8Ca_`% zu)?W=l3D_*CC9sQHLvF)gYHEx0#K=6SG_lB)a{UR645@Bn zEY_8ecK$Lb)CzvnhWjW9beJ{5g<4nAFVgch#NTtMW_1U+b#AkQXqueAJ+f&3g-evT_Y3BzY!`Po;>MB%fq{Qufl z4if#CHv%vTXH~8|kf_tA8vc-f-DSLLrF8LsQAm&p0eoNg-&DSaoCf?z_NDM&w7$kq zvV@8BHwWb{Wv_tmt7H|1(*IljcTDm>ZV~1^-njig@0m4LO8C!z{x@#_zwm_ruiWB- ZocUIJR=xAFIW)u8hwcAS@#C3a{tsy3@NNJA literal 155999 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�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 diff --git a/docs/images/screenshots/02-hosts.png b/docs/images/screenshots/02-hosts.png index 94679a42a0e3732a66c80ad31abdbecefeeb3525..d39cf14fb02fc050fa4bfa829f1d526b0f632b19 100644 GIT binary patch literal 174906 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%0Al5@n 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

m zOTlOHyXm-^(*%;kJ><$4AouonXWY>JwV-!qCE$$O!{zNnAMmO zC)TKm#@3YH*fIbzSIN8CD!Vg^vPiUPbbu_fRShFX8=_GIXoiN+xCvL`yGH|*`cR&^43!=Yq!NrAAC+^XPx`(LEO|b(SDF)k4o^PKT zi2>!BvG$xPc7EImMAKKS6FZPQ>{?uT^P-bTqiByJ6+6;m5cD1Qdc+qeeVfO90nBQy zRu;5TYr?Wq6WvbdiebYx$-9^TnkC_VcHnmutW--7Qgblv$-%I(43I&BB}mDa6h3Z% z;hR22MZAJrh)uyu58%4U3Kw&WTjB!@E#-QXg0z<

sdypy_gOYfBUfWBH~j=hXR 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

uUu=fzQImlFAdP;Gz3a=IHTrX8 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

uNi%R;p!b{0uhX~ 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

m6e+7zt+u5I5cq2-!RqD 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 z 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 z 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 literal 347056 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ξ&nxej@l9^k-49cCTwUlNbTZW2j@ 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
V-2e}XZ0aAC`Qom$g%G;FAA}% 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 From 538b4bcde92b4773f85a68f188feed63b223c268 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:15:38 -0400 Subject: [PATCH 28/35] Show PIN immediately; update pairing flow and tests Make the pairing flow expose generated PINs immediately by setting PairingStage to pin_ready and updating status text. Remove the pre-check that aborted background pairing when reachability wasn't confirmed and use app::effective_host_port for pairing attempts. Update shell UI messaging accordingly. Adjust and extend unit tests to match the new behavior and add coverage for input mappings, host pairing parsing/validation, address resolution, and runtime network status handling. --- src/app/pairing_flow.cpp | 4 +- src/ui/shell_screen.cpp | 32 +------ tests/unit/app/client_state_test.cpp | 3 + tests/unit/app/pairing_flow_test.cpp | 4 +- tests/unit/input/navigation_input_test.cpp | 14 +++ tests/unit/network/host_pairing_test.cpp | 101 ++++++++++++++++++++ tests/unit/network/runtime_network_test.cpp | 33 +++++++ tests/unit/ui/shell_view_test.cpp | 16 ++-- 8 files changed, 165 insertions(+), 42 deletions(-) diff --git a/src/app/pairing_flow.cpp b/src/app/pairing_flow.cpp index 4bfc161..a4cf548 100644 --- a/src/app/pairing_flow.cpp +++ b/src/app/pairing_flow.cpp @@ -15,8 +15,8 @@ namespace app { std::string(targetAddress), targetPort, std::move(generatedPin), - PairingStage::idle, - "Checking whether the host is reachable before pairing begins.", + PairingStage::pin_ready, + "Enter the PIN on the host. Pairing will continue automatically.", }; return draft; } diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index b58d99e..e45fcf6 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -2979,39 +2979,11 @@ namespace { logging::info("pairing", "Discarded the previous background pairing attempt and started a fresh one"); } - std::string reachabilityMessage; - network::HostPairingServerInfo serverInfo {}; - network::PairingIdentity clientIdentity {}; - if (const network::PairingIdentity *clientIdentityPointer = try_load_saved_pairing_identity(&clientIdentity) ? &clientIdentity : nullptr; !test_tcp_host_connection(update.requests.pairingAddress, update.requests.pairingPort, clientIdentityPointer, &reachabilityMessage, &serverInfo)) { - for (app::HostRecord &host : state.hosts.items) { - if (host.address == update.requests.pairingAddress && app::effective_host_port(host.port) == app::effective_host_port(update.requests.pairingPort)) { - host.reachability = app::HostReachability::offline; - host.manualAddress = update.requests.pairingAddress; - break; - } - } - if (state.hosts.activeLoaded && app::host_matches_endpoint(state.hosts.active, update.requests.pairingAddress, update.requests.pairingPort)) { - state.hosts.active.reachability = app::HostReachability::offline; - state.hosts.active.manualAddress = update.requests.pairingAddress; - } - state.pairingDraft.stage = app::PairingStage::failed; - state.pairingDraft.generatedPin.clear(); - state.pairingDraft.statusMessage = reachabilityMessage.empty() ? "The host could not be reached for pairing." : reachabilityMessage; - state.shell.statusMessage = state.pairingDraft.statusMessage; - logging::warn("pairing", state.pairingDraft.statusMessage); - return; - } - - apply_server_info_to_host(state, update.requests.pairingAddress, update.requests.pairingPort, serverInfo); - if (state.hosts.dirty) { - persist_hosts(state); - } - auto attempt = std::make_unique(); reset_pairing_attempt(attempt.get()); attempt->request = { update.requests.pairingAddress, - update.requests.pairingPort, + app::effective_host_port(update.requests.pairingPort), update.requests.pairingPin, "MoonlightXboxOG", {}, @@ -3030,7 +3002,7 @@ namespace { task->activeAttempt = std::move(attempt); state.pairingDraft.stage = app::PairingStage::in_progress; - state.pairingDraft.statusMessage = "The host is reachable. Enter the code shown below on the host and keep this screen open for the result."; + 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)); } diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index c8cb068..eae5ab8 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -191,6 +191,9 @@ namespace { 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); diff --git a/tests/unit/app/pairing_flow_test.cpp b/tests/unit/app/pairing_flow_test.cpp index 548ec60..a88f008 100644 --- a/tests/unit/app/pairing_flow_test.cpp +++ b/tests/unit/app/pairing_flow_test.cpp @@ -22,9 +22,9 @@ namespace { 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::idle); + EXPECT_EQ(draft.stage, app::PairingStage::pin_ready); EXPECT_EQ(draft.generatedPin, "4821"); - EXPECT_EQ(draft.statusMessage, "Checking whether the host is reachable before pairing begins."); + EXPECT_EQ(draft.statusMessage, "Enter the PIN on the host. Pairing will continue automatically."); } TEST(PairingFlowTest, AcceptsOnlyFourDigitPins) { diff --git a/tests/unit/input/navigation_input_test.cpp b/tests/unit/input/navigation_input_test.cpp index 9d0c6ea..b425235 100644 --- a/tests/unit/input/navigation_input_test.cpp +++ b/tests/unit/input/navigation_input_test.cpp @@ -12,12 +12,18 @@ 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) { @@ -25,19 +31,27 @@ namespace { 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/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index 6bd8cc9..a0f196a 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -42,6 +42,18 @@ namespace { } } + 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)); @@ -107,6 +119,44 @@ namespace { 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 = "" @@ -172,6 +222,14 @@ namespace { 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"()"; @@ -204,6 +262,24 @@ namespace { 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; @@ -234,7 +310,32 @@ namespace { 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()); + } + } // namespace diff --git a/tests/unit/network/runtime_network_test.cpp b/tests/unit/network/runtime_network_test.cpp index a9048d8..53644db 100644 --- a/tests/unit/network/runtime_network_test.cpp +++ b/tests/unit/network/runtime_network_test.cpp @@ -80,4 +80,37 @@ namespace { 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/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index 52d037d..c8d1d91 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -389,25 +389,25 @@ namespace { EXPECT_EQ(viewModel.notification.content.message, "Deleted saved file moonlight.log"); } - TEST(ShellViewTest, HidesThePairingPinUntilReachabilityHasBeenConfirmed) { + 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::idle, - "Checking whether the host is reachable before pairing begins." + 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(), 2U); + 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], "Checking whether the host is reachable before showing a PIN."); - for (const std::string &line : viewModel.content.bodyLines) { - EXPECT_EQ(line.find("PIN:"), std::string::npos); - } + 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); } From f21eccf05f8b494af672697b365c161a3a5c071f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:37:37 -0400 Subject: [PATCH 29/35] Add scripted HTTP test handler for host pairing Introduce a HostPairingHttpTestHandler and associated request/response structs to allow tests to script and intercept host-pairing HTTP/TLS exchanges. Add global test handler (g_host_pairing_http_test_handler) and set/clear functions, and wire the handler into host_pairing's transport path to short-circuit real network calls when present. Refinement: improve pairing failure message construction to preserve meaningful details. Add comprehensive unit tests and test helpers (scripted exchanges, crypto helpers, scoped handler) to validate pairing, server-info, app-list and asset flows. Also include necessary and OpenSSL utilities for the new tests. --- src/network/host_pairing.cpp | 41 +- src/network/host_pairing.h | 42 ++ tests/unit/network/host_pairing_test.cpp | 724 +++++++++++++++++++++++ 3 files changed, 806 insertions(+), 1 deletion(-) diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index af2b297..0f618d1 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -117,6 +118,7 @@ namespace { 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 g_host_pairing_http_test_handler; ///< Optional scripted transport used by host-native unit tests. struct WsaGuard { WsaGuard(): @@ -1725,6 +1727,30 @@ namespace { return append_cancelled_pairing_error(errorMessage); } + if (g_host_pairing_http_test_handler) { + network::testing::HostPairingHttpTestRequest testRequest { + address, + port, + std::string(pathAndQuery), + useTls, + tlsClientIdentity, + std::string(expectedTlsCertificatePem), + }; + network::testing::HostPairingHttpTestResponse testResponse {}; + std::string testError; + if (!g_host_pairing_http_test_handler(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"); @@ -1934,7 +1960,8 @@ namespace { return {false, false, "Pairing failed"}; } - session->result.message = "Pairing failed during " + std::string(phase) + ": " + session->errorMessage; + const std::string detail = !session->errorMessage.empty() ? session->errorMessage : (session->result.message.empty() ? "Pairing failed" : session->result.message); + session->result.message = "Pairing failed during " + std::string(phase) + ": " + detail; trace_pairing_detail(session->result.message); return session->result; } @@ -2615,4 +2642,16 @@ namespace network { return session.result; } + namespace testing { + + void set_host_pairing_http_test_handler(HostPairingHttpTestHandler handler) { + g_host_pairing_http_test_handler = std::move(handler); + } + + void clear_host_pairing_http_test_handler() { + g_host_pairing_http_test_handler = {}; + } + + } // namespace testing + } // namespace network diff --git a/src/network/host_pairing.h b/src/network/host_pairing.h index 35002ad..6f167d6 100644 --- a/src/network/host_pairing.h +++ b/src/network/host_pairing.h @@ -7,6 +7,7 @@ // standard includes #include #include +#include #include #include #include @@ -210,4 +211,45 @@ namespace network { */ 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/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index a0f196a..990ee8b 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -6,16 +6,228 @@ #include "src/network/host_pairing.h" // standard includes +#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_++]; + 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; + }; + + 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::string hex_encode_bytes(const unsigned char *data, std::size_t size) { + static constexpr char kHexDigits[] = "0123456789abcdef"; + + std::string output(size * 2U, '\0'); + for (std::size_t index = 0; index < size; ++index) { + output[index * 2U] = kHexDigits[(data[index] >> 4U) & 0x0F]; + output[(index * 2U) + 1U] = kHexDigits[data[index] & 0x0F]; + } + return output; + } + + std::string hex_encode_text(std::string_view text) { + return hex_encode_bytes(reinterpret_cast(text.data()), text.size()); + } + + 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; + } + + 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) { + unsigned char upper = 0; + unsigned char lower = 0; + EXPECT_TRUE(hex_value(text[index], &upper)); + EXPECT_TRUE(hex_value(text[index + 1U], &lower)); + bytes.push_back(static_cast((upper << 4U) | lower)); + } + return bytes; + } + + std::vector sha256_digest(const unsigned char *data, std::size_t size) { + std::array digestBuffer {}; + unsigned int digestSize = 0U; + EXPECT_EQ(EVP_Digest(data, size, digestBuffer.data(), &digestSize, EVP_sha256(), nullptr), 1); + return {digestBuffer.begin(), digestBuffer.begin() + static_cast(digestSize)}; + } + + std::vector derive_pairing_aes_key(std::string_view saltHex, std::string_view pin) { + std::vector source = hex_decode_text(saltHex); + source.insert(source.end(), pin.begin(), pin.end()); + 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); + EXPECT_EQ(EVP_EncryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, key.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, plaintext.data(), static_cast(plaintext.size())), 1); + ciphertext.resize(static_cast(ciphertextSize)); + return ciphertext; + } + + 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); + EXPECT_EQ(EVP_DigestSignUpdate(context.get(), data.data(), data.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(signature.data(), signature.size()); + } + + 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; + } + TEST(HostPairingTest, CreatesAValidClientIdentity) { std::string errorMessage; const network::PairingIdentity identity = network::create_pairing_identity(&errorMessage); @@ -338,4 +550,516 @@ namespace { 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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, + "", + }, + }); + ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { + return handler(request, response, errorMessage, cancelRequested); + }); + + 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 *) { + 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(16U, 0x2AU); + 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: + ADD_FAILURE() << "Unexpected extra pairing request"; + return false; + } + }); + + 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; + ScopedHostPairingHttpTestHandler guard([&](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *) { + switch (callCount++) { + case 0U: + EXPECT_FALSE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + 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(clientIdentity.certificatePem)); + 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); + std::vector challengePlaintext(48U); + for (std::size_t index = 0; index < challengePlaintext.size(); ++index) { + challengePlaintext[index] = static_cast(index + 1U); + } + 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: + { + std::vector serverSecret(16U, 0x5AU); + 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, 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, clientIdentity.uniqueId); + EXPECT_EQ(request.expectedTlsCertificatePem, serverIdentity.certificatePem); + EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "phrase"), "pairchallenge"); + response->statusCode = 200; + response->body = make_pair_phase_response("1"); + return true; + default: + if (errorMessage != nullptr) { + *errorMessage = "Unexpected extra pairing request"; + } + ADD_FAILURE() << "Unexpected extra pairing request"; + return false; + } + }); + + 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 From 68e2531fd21e89b15fe8b7b32bcb09b418f38331 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Apr 2026 20:53:25 -0400 Subject: [PATCH 30/35] Remove obsolete planning and research docs Delete two documentation files that are no longer needed: docs/sunshine-pairing-research.md and docs/working-client-plan.md. These removals clean up outdated design notes and planning artifacts from the repository. --- docs/sunshine-pairing-research.md | 49 --------------- docs/working-client-plan.md | 101 ------------------------------ 2 files changed, 150 deletions(-) delete mode 100644 docs/sunshine-pairing-research.md delete mode 100644 docs/working-client-plan.md diff --git a/docs/sunshine-pairing-research.md b/docs/sunshine-pairing-research.md deleted file mode 100644 index b2c27d4..0000000 --- a/docs/sunshine-pairing-research.md +++ /dev/null @@ -1,49 +0,0 @@ -# Sunshine pairing research notes - -## Current findings - -- The vendored `third-party/moonlight-common-c` tree exposes streaming, connection testing, and input APIs through `Limelight.h`. -- The current public API surface does not expose a ready-made host pairing entry point. -- The library clearly expects host metadata from `/serverinfo` and session startup through `/launch` and `/resume`, but pairing is not currently wrapped by an app-facing helper in this tree. -- The current shell UI was previously pretending pairing existed. That is why the pair action remains disabled until a real backend is added. - -## Practical implication for this project - -A real Sunshine pairing flow in this codebase should be implemented as a project-owned adapter instead of another reducer-only placeholder. - -## Recommended adapter boundary - -Create a narrow module that owns these responsibilities: - -1. Open an HTTPS connection to the target host. -2. Query `/serverinfo` and validate the host response. -3. Create or load the client certificate/key material used for pairing. -4. Start the Sunshine pairing request with the generated four-digit PIN. -5. Poll or continue the handshake until Sunshine reports success or failure. -6. Persist the resulting client identity and update the saved host record to `paired` only after host-confirmed success. - -## Proposed implementation order - -1. Add a platform-neutral `src/network/sunshine_pairing.h/.cpp` adapter interface. -2. Implement host-native parsing and state tests for the adapter responses before wiring it into SDL. -3. Use the vendored OpenSSL already present in the Xbox build to handle TLS and certificate material. -4. Add reducer events for: - - pairing requested; - - pairing started; - - PIN ready; - - pairing succeeded; - - pairing failed; - - pairing cancelled. -5. Re-enable the `Pair Selected Host` and `Start Pairing` actions only after the adapter can report real host-backed success. - -## Non-goals for the first pairing slice - -- LAN discovery -- unpair support -- host app-list browsing -- session launch or resume changes beyond what pairing needs - -## Why this note exists - -This project is at the point where the UI shell is ready for a real backend, but `moonlight-common-c` does not currently give this repository a drop-in pairing call. The next change should therefore add a small, testable Sunshine-specific adapter rather than extending the placeholder reducer state again. - diff --git a/docs/working-client-plan.md b/docs/working-client-plan.md deleted file mode 100644 index 3000954..0000000 --- a/docs/working-client-plan.md +++ /dev/null @@ -1,101 +0,0 @@ -# Working Moonlight client plan - -## Goals - -Turn `Moonlight-XboxOG` into a usable Moonlight client for the original Xbox by building the client in small, testable milestones. The near-term work should focus on host-native coverage, controller-first UI flows, structured logging, pairing, host management, and a streaming pipeline that can later connect the Xbox runtime to `moonlight-common-c`. - -## Architecture decisions - -### Streaming core - -- Use the vendored `third-party/moonlight-common-c` codebase as the transport, pairing, RTSP, ENet, control, and input foundation. -- Treat `moonlight-embedded` as a reference implementation, especially for host discovery, pairing, and session setup. -- Keep the first shipping codec target narrow: H.264 video plus stereo Opus audio. -- Delay FFmpeg integration until there is a clear Xbox-native decode gap that cannot be filled with a lighter custom H.264 path. - -### UI stack - -- Build a project-owned retained-mode UI layer on top of `SDL2`. -- Use `SDL_ttf`, which already exists in the vendored `nxdk` tree, for text rendering. -- Prefer a local icon atlas and project-owned widgets over adding a heavy UI submodule. -- Keep the UI model platform-neutral so controller navigation, focus, and menu state can be covered by host-native gtests. - -### Input model - -- Make the controller the primary navigation path. -- Map keyboard input into the same abstract UI commands so the host-native build and emulator workflows behave the same way. -- Treat mouse support as optional until nxdk input support is validated for the relevant devices. -- Allow a controller-driven virtual cursor mode later for streamed desktop interactions. - -### Logging and observability - -- Use structured log entries with severity, category, and message fields. -- Keep a ring buffer for on-screen diagnostics and crash-adjacent inspection. -- Mirror accepted log entries to the platform debug console. -- Build the statistics overlay from typed telemetry snapshots instead of formatting strings at capture sites. - -### xemu networking - -- Explicitly support launcher-controlled networking modes. -- Keep the default user-mode network path for simple outbound connectivity. -- Add tap networking support for LAN discovery and broadcast-sensitive workflows. -- Keep the xemu runtime state inside `.local/xemu` so pairing files, EEPROM data, and launcher config stay reproducible per workspace. - -## Milestones - -### M0: Test-first core shell - -- Expand the host-native unit test surface with platform-neutral modules under `src/app/`, `src/input/`, `src/logging/`, and `src/streaming/`. -- Build the retained menu model, keyboard and controller command mapping, and overlay formatting in isolation. -- Add a proper logging core and use it from startup and future runtime services. - -### M1: Rendered home shell on Xbox - -- Render a real home screen with text, focus states, and controller navigation. -- Add placeholder screens for Hosts, Add Host, and Settings. -- Show the new logging buffer and stats overlay from the Xbox runtime. - -### M2: Host records, discovery, and pairing - -- Add persistent host records and pairing state. -- Start with manual IP entry and PIN pairing. -- Add LAN discovery after the manual path is working in both xemu and on hardware. -- Cover parsing, persistence, and state transitions with host-native tests. - -### M3: Session control plane - -- Query host capabilities, app lists, and current sessions. -- Support launch, resume, and quit flows. -- Add connection preflight checks using `moonlight-common-c` port helpers and surface actionable log messages in the UI. - -### M4: Streaming pipeline - -- Connect `moonlight-common-c` callbacks to Xbox-specific video, audio, and input backends. -- Start with H.264 and stereo Opus. -- Add frame pacing, reconnect handling, controller rumble, and proper cleanup. - -### M5: User-visible polish - -- Replace placeholder menus with full host and app detail screens. -- Flesh out settings, overlay customization, input tuning, and controller hotkeys. -- Add pause and status overlays, stream problem notifications, and better recovery flows. - -## Testing strategy - -- Keep all non-rendering business logic platform-neutral and covered by gtests. -- Prefer reducers, models, and adapters that can be exercised without SDL or nxdk. -- Add targeted emulator smoke tests for launcher behavior and runtime boot flow. -- Add parser, persistence, and connection-state tests before integrating network or pairing code into the Xbox runtime. -- Use xemu only for integration coverage after host-native tests already lock in behavior. - -## Initial implementation in this change - -This changeset starts M0 by adding: - -- a structured in-memory logger; -- controller and keyboard command mapping for menus; -- a retained menu model that skips disabled entries and reports activations; -- an initial top-level client shell state machine for Home, Hosts, Add Host, and Settings; -- a typed streaming statistics overlay formatter; -- launcher groundwork for explicit xemu networking configuration and portable runtime state. - From 753e931f64baf9398c94df08fbe8d9a78bca95e9 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:58:49 -0400 Subject: [PATCH 31/35] Add extensive unit tests and utilities Add and expand unit tests across the codebase to improve coverage and validate edge cases. Highlights: - Add many new tests in tests/unit/app/* to cover connection test handling, pairing validation and flow, host/app context actions, settings placeholder activation, confirmation modal behavior, saved files and status clearing; also adjust path literals to raw string form for Windows paths. - Add tests in tests/unit/host_records_test.cpp to validate display name restrictions, percent-encoding of cached app fields, matching endpoints against resolved http/https ports, port parsing, and stable enum string names. - Extend settings storage tests to cover missing files, mixed-case logging values, cleanup of unknown/legacy keys, and parse/type error warnings. - Add logging tests for reading unterminated log files, runtime file sink path accessor, formatting helpers, additional sink minimum-level behavior, zero-capacity fallback, and startup console helpers; also introduce write_raw_text_file helper. - Add a new tests/unit/platform/filesystem_utils_test.cpp covering path joining, parent/file name helpers, directory creation, file size checks, and prefix comparisons (platform-specific behavior). - Extend startup tests (client identity, cover art cache, saved files) to trim trailing newlines, warn on missing cert/key, save/load nested cover art, build cache keys, list nested cover-art files, and validate delete behavior for empty paths. - Update UI shell view tests for rendering saved file sizes, app badges, port keypad display, pair-host reachability messages, and overlay fallback text; adjust expectations where extra body lines are present. Also add small test helpers (write_text_file / write_raw_text_file) and minor test adjustments to reflect expected UI/content changes. These changes are focused on tests only and do not modify production logic. --- tests/unit/app/client_state_test.cpp | 170 +++++++++++++++++- tests/unit/app/host_records_test.cpp | 60 +++++++ tests/unit/app/settings_storage_test.cpp | 78 ++++++++ tests/unit/logging/log_file_test.cpp | 27 +++ tests/unit/logging/logger_test.cpp | 79 ++++++++ tests/unit/platform/filesystem_utils_test.cpp | 86 +++++++++ .../startup/client_identity_storage_test.cpp | 37 ++++ tests/unit/startup/cover_art_cache_test.cpp | 29 +++ tests/unit/startup/saved_files_test.cpp | 31 ++++ tests/unit/ui/shell_view_test.cpp | 99 +++++++++- 10 files changed, 691 insertions(+), 5 deletions(-) create mode 100644 tests/unit/platform/filesystem_utils_test.cpp diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index eae5ab8..e60544d 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -623,7 +623,7 @@ namespace { ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); app::replace_saved_files(state, { - {"E:\\UDATA\\12345678\\moonlight.log", "moonlight.log", 128U}, + {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); @@ -829,6 +829,174 @@ namespace { 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, { diff --git a/tests/unit/app/host_records_test.cpp b/tests/unit/app/host_records_test.cpp index 8a85088..ee051a2 100644 --- a/tests/unit/app/host_records_test.cpp +++ b/tests/unit/app/host_records_test.cpp @@ -45,6 +45,17 @@ namespace { 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}, @@ -100,6 +111,30 @@ namespace { 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" @@ -129,6 +164,22 @@ namespace { 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; @@ -142,6 +193,15 @@ namespace { 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/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index 3938ce7..ca87480 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -59,6 +59,17 @@ namespace { 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, @@ -98,4 +109,71 @@ namespace { 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/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp index 7a5dbbe..67b39d8 100644 --- a/tests/unit/logging/log_file_test.cpp +++ b/tests/unit/logging/log_file_test.cpp @@ -17,6 +17,13 @@ namespace { + void write_raw_text_file(const std::string &path, const std::string &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); } @@ -114,4 +121,24 @@ namespace { 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 index bd7536a..44d5c8d 100644 --- a/tests/unit/logging/logger_test.cpp +++ b/tests/unit/logging/logger_test.cpp @@ -28,6 +28,12 @@ namespace { 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}; @@ -122,6 +128,36 @@ namespace { 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; @@ -142,6 +178,23 @@ namespace { 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); @@ -168,4 +221,30 @@ namespace { 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/platform/filesystem_utils_test.cpp b/tests/unit/platform/filesystem_utils_test.cpp new file mode 100644 index 0000000..9c2c184 --- /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, const std::string &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/startup/client_identity_storage_test.cpp b/tests/unit/startup/client_identity_storage_test.cpp index 1956352..08a3df5 100644 --- a/tests/unit/startup/client_identity_storage_test.cpp +++ b/tests/unit/startup/client_identity_storage_test.cpp @@ -7,6 +7,7 @@ // standard includes #include +#include // lib includes #include @@ -16,6 +17,13 @@ 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 { @@ -95,4 +103,33 @@ namespace { 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 index 7416c6e..6a2bd8d 100644 --- a/tests/unit/startup/cover_art_cache_test.cpp +++ b/tests/unit/startup/cover_art_cache_test.cpp @@ -78,4 +78,33 @@ namespace { 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/saved_files_test.cpp b/tests/unit/startup/saved_files_test.cpp index b4636d5..aea3d44 100644 --- a/tests/unit/startup/saved_files_test.cpp +++ b/tests/unit/startup/saved_files_test.cpp @@ -36,6 +36,8 @@ namespace { 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, @@ -48,9 +50,11 @@ namespace { 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); @@ -58,6 +62,7 @@ namespace { 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); @@ -123,4 +128,30 @@ namespace { 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/ui/shell_view_test.cpp b/tests/unit/ui/shell_view_test.cpp index c8d1d91..d2296d0 100644 --- a/tests/unit/ui/shell_view_test.cpp +++ b/tests/unit/ui/shell_view_test.cpp @@ -91,7 +91,7 @@ namespace { const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); EXPECT_EQ(viewModel.frame.pageTitle, "Add Host"); - ASSERT_GE(viewModel.content.bodyLines.size(), 4U); + 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"); @@ -109,7 +109,7 @@ namespace { 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, "E:\\UDATA\\12345678\\moonlight.log"); + app::set_log_file_path(state, R"(E:\UDATA\12345678\moonlight.log)"); const ui::ShellViewModel viewModel = ui::build_shell_view_model(state, {}); @@ -137,7 +137,7 @@ namespace { state.settings.selectedCategory = app::SettingsCategory::reset; app::replace_saved_files(state, { - {"E:\\UDATA\\12345678\\pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", "pairing\\a-very-long-file-name-that-needs-the-detail-pane.bin", 1536U}, + {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")); @@ -318,7 +318,7 @@ namespace { TEST(ShellViewTest, BuildsDedicatedLogViewerModalState) { app::ClientState state = app::create_initial_state(); - app::set_log_file_path(state, "E:\\UDATA\\12345678\\moonlight.log"); + 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", @@ -342,6 +342,68 @@ namespace { 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; @@ -411,6 +473,25 @@ namespace { 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; @@ -452,4 +533,14 @@ namespace { 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 From 037d466bc37fb5ba55aa2e81d18eaf1d978436e4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:12:36 -0400 Subject: [PATCH 32/35] Refactor host pairing tests to use std::byte Replace the global test handler with a function-scoped static (host_pairing_http_test_handler) and update callers to use it, avoiding the previous global g_host_pairing_http_test_handler. Convert test utilities and data paths to use std::byte-based buffers and helper converters for OpenSSL interop (openssl_bytes), update hex encoding/decoding to use std::from_chars, and add helpers (filled_bytes, sequential_bytes). Consolidate repeated scripted pairing request logic into handle_short_challenge_pairing_request and handle_successful_pairing_request handlers and add make_host_pairing_http_test_handler to simplify test setup. Also change a couple of test helpers to accept std::string_view for content and adjust scripts/run-xemu.sh to assign then export APPDATA/LOCALAPPDATA in separate steps. --- scripts/run-xemu.sh | 6 +- src/network/host_pairing.cpp | 23 +- tests/unit/logging/log_file_test.cpp | 2 +- tests/unit/network/host_pairing_test.cpp | 342 ++++++++++-------- tests/unit/platform/filesystem_utils_test.cpp | 2 +- 5 files changed, 211 insertions(+), 164 deletions(-) diff --git a/scripts/run-xemu.sh b/scripts/run-xemu.sh index c184542..a0c5226 100644 --- a/scripts/run-xemu.sh +++ b/scripts/run-xemu.sh @@ -90,10 +90,12 @@ write_xemu_config() { prepare_xemu_runtime_environment() { if is_windows; then if [[ -n "${XEMU_APPDATA:-}" ]]; then - export APPDATA="$(to_native_path "$XEMU_APPDATA")" + APPDATA="$(to_native_path "$XEMU_APPDATA")" + export APPDATA fi if [[ -n "${XEMU_LOCALAPPDATA:-}" ]]; then - export LOCALAPPDATA="$(to_native_path "$XEMU_LOCALAPPDATA")" + LOCALAPPDATA="$(to_native_path "$XEMU_LOCALAPPDATA")" + export LOCALAPPDATA fi return 0 fi diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 0f618d1..2813787 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -118,7 +118,11 @@ namespace { 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 g_host_pairing_http_test_handler; ///< Optional scripted transport used by host-native unit tests. + + 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(): @@ -1727,7 +1731,8 @@ namespace { return append_cancelled_pairing_error(errorMessage); } - if (g_host_pairing_http_test_handler) { + network::testing::HostPairingHttpTestHandler &testHandler = host_pairing_http_test_handler(); + if (testHandler) { network::testing::HostPairingHttpTestRequest testRequest { address, port, @@ -1737,8 +1742,7 @@ namespace { std::string(expectedTlsCertificatePem), }; network::testing::HostPairingHttpTestResponse testResponse {}; - std::string testError; - if (!g_host_pairing_http_test_handler(testRequest, &testResponse, &testError, cancelRequested)) { + if (std::string testError; !testHandler(testRequest, &testResponse, &testError, cancelRequested)) { return append_error(errorMessage, testError.empty() ? "The scripted host pairing request failed" : testError); } @@ -1960,7 +1964,12 @@ namespace { return {false, false, "Pairing failed"}; } - const std::string detail = !session->errorMessage.empty() ? session->errorMessage : (session->result.message.empty() ? "Pairing failed" : session->result.message); + 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; @@ -2645,11 +2654,11 @@ namespace network { namespace testing { void set_host_pairing_http_test_handler(HostPairingHttpTestHandler handler) { - g_host_pairing_http_test_handler = std::move(handler); + host_pairing_http_test_handler() = std::move(handler); } void clear_host_pairing_http_test_handler() { - g_host_pairing_http_test_handler = {}; + host_pairing_http_test_handler() = {}; } } // namespace testing diff --git a/tests/unit/logging/log_file_test.cpp b/tests/unit/logging/log_file_test.cpp index 67b39d8..414459d 100644 --- a/tests/unit/logging/log_file_test.cpp +++ b/tests/unit/logging/log_file_test.cpp @@ -17,7 +17,7 @@ namespace { - void write_raw_text_file(const std::string &path, const std::string &content) { + 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()); diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index 990ee8b..ce92e37 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -8,9 +8,13 @@ // standard includes #include #include +#include +#include #include #include +#include #include +#include #include // lib includes @@ -65,7 +69,8 @@ namespace { return false; } - const ScriptedHttpExchange &exchange = exchanges_[index_++]; + const ScriptedHttpExchange &exchange = exchanges_[index_]; + ++index_; if (exchange.validateRequest) { exchange.validateRequest(request); } @@ -95,6 +100,13 @@ namespace { 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) { @@ -123,78 +135,82 @@ namespace { return std::string(pathAndQuery.substr(valueStart, valueEnd == std::string_view::npos ? std::string_view::npos : valueEnd - valueStart)); } - std::string hex_encode_bytes(const unsigned char *data, std::size_t size) { + const unsigned char *openssl_bytes(const std::byte *data) { + return reinterpret_cast(data); + } + + unsigned char *openssl_bytes(std::byte *data) { + return reinterpret_cast(data); + } + + std::string hex_encode_bytes(const std::byte *data, std::size_t size) { static constexpr char kHexDigits[] = "0123456789abcdef"; - std::string output(size * 2U, '\0'); + std::string output; + output.reserve(size * 2U); for (std::size_t index = 0; index < size; ++index) { - output[index * 2U] = kHexDigits[(data[index] >> 4U) & 0x0F]; - output[(index * 2U) + 1U] = kHexDigits[data[index] & 0x0F]; + 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) { - return hex_encode_bytes(reinterpret_cast(text.data()), text.size()); - } - - 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; + return hex_encode_bytes(reinterpret_cast(text.data()), text.size()); } - std::vector hex_decode_text(std::string_view text) { - std::vector bytes; + 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) { - unsigned char upper = 0; - unsigned char lower = 0; - EXPECT_TRUE(hex_value(text[index], &upper)); - EXPECT_TRUE(hex_value(text[index + 1U], &lower)); - bytes.push_back(static_cast((upper << 4U) | lower)); + char encodedByte[] = {text[index], text[index + 1U], '\0'}; + unsigned int value = 0U; + const std::from_chars_result decodeResult = std::from_chars(encodedByte, encodedByte + 2, value, 16); + EXPECT_EQ(decodeResult.ec, std::errc()); + EXPECT_EQ(decodeResult.ptr, encodedByte + 2); + bytes.push_back(static_cast(value)); } return bytes; } - std::vector sha256_digest(const unsigned char *data, std::size_t size) { + std::vector sha256_digest(const std::byte *data, std::size_t size) { std::array digestBuffer {}; unsigned int digestSize = 0U; - EXPECT_EQ(EVP_Digest(data, size, digestBuffer.data(), &digestSize, EVP_sha256(), nullptr), 1); - return {digestBuffer.begin(), digestBuffer.begin() + static_cast(digestSize)}; + EXPECT_EQ(EVP_Digest(openssl_bytes(data), size, digestBuffer.data(), &digestSize, EVP_sha256(), nullptr), 1); + + std::vector digest; + digest.reserve(digestSize); + for (unsigned int index = 0; index < digestSize; ++index) { + digest.push_back(static_cast(digestBuffer[index])); + } + return digest; } - std::vector derive_pairing_aes_key(std::string_view saltHex, std::string_view pin) { - std::vector source = hex_decode_text(saltHex); - source.insert(source.end(), pin.begin(), pin.end()); + 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::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); - EXPECT_EQ(EVP_EncryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, key.data(), nullptr), 1); + EXPECT_EQ(EVP_EncryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, openssl_bytes(key.data()), nullptr), 1); EXPECT_EQ(EVP_CIPHER_CTX_set_padding(context.get(), 0), 1); - std::vector ciphertext(plaintext.size() + 16U); + std::vector ciphertext(plaintext.size() + 16U); int ciphertextSize = 0; - EXPECT_EQ(EVP_EncryptUpdate(context.get(), ciphertext.data(), &ciphertextSize, plaintext.data(), static_cast(plaintext.size())), 1); + EXPECT_EQ(EVP_EncryptUpdate(context.get(), openssl_bytes(ciphertext.data()), &ciphertextSize, openssl_bytes(plaintext.data()), static_cast(plaintext.size())), 1); ciphertext.resize(static_cast(ciphertextSize)); return ciphertext; } - std::string sign_sha256_hex(const std::vector &data, std::string_view privateKeyPem) { + 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)); @@ -203,14 +219,26 @@ namespace { 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); - EXPECT_EQ(EVP_DigestSignUpdate(context.get(), data.data(), data.size()), 1); + EXPECT_EQ(EVP_DigestSignUpdate(context.get(), openssl_bytes(data.data()), data.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(signature.data(), signature.size()); + 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") { @@ -228,6 +256,114 @@ namespace { 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); + } + } + + bool handle_successful_pairing_request( + std::size_t *callCount, + std::string *saltHex, + std::string_view pin, + const network::PairingIdentity &clientIdentity, + const network::PairingIdentity &serverIdentity, + const HostPairingHttpTestRequest &request, + HostPairingHttpTestResponse *response, + std::string *errorMessage + ) { + switch ((*callCount)++) { + case 0U: + EXPECT_FALSE(request.useTls); + EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + 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(clientIdentity.certificatePem)); + *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 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, 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, clientIdentity.uniqueId); + EXPECT_EQ(request.expectedTlsCertificatePem, 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); @@ -573,9 +709,7 @@ namespace { make_server_info_xml(false, 47984U, 47990U, "Fallback Host", "fallback-host"), }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); network::HostPairingServerInfo serverInfo {}; std::string errorMessage; @@ -614,9 +748,7 @@ namespace { make_server_info_xml(true, 47989U, 47990U, "Authorized Host", "authorized-host"), }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); network::HostPairingServerInfo serverInfo {}; std::string errorMessage; @@ -648,9 +780,7 @@ namespace { "", }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); network::HostPairingServerInfo serverInfo {}; std::string errorMessage; @@ -679,9 +809,7 @@ namespace { "response", }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); network::HostPairingServerInfo serverInfo {}; std::string errorMessage; @@ -725,9 +853,7 @@ namespace { "", }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); std::vector apps; std::string errorMessage; @@ -749,12 +875,10 @@ namespace { {}, true, 200, - "", + R"()", }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); std::vector apps; std::string errorMessage; @@ -778,9 +902,7 @@ namespace { "Steam101Desktop1021", }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); std::vector apps; network::HostPairingServerInfo serverInfo {}; @@ -827,9 +949,7 @@ namespace { std::string("\x89PNG", 4), }, }); - ScopedHostPairingHttpTestHandler guard([&handler](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *cancelRequested) { - return handler(request, response, errorMessage, cancelRequested); - }); + ScopedHostPairingHttpTestHandler guard(make_host_pairing_http_test_handler(&handler)); std::vector assetBytes; std::string errorMessage; @@ -936,30 +1056,7 @@ namespace { std::size_t callCount = 0U; std::string saltHex; ScopedHostPairingHttpTestHandler guard([&](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *, const std::atomic *) { - 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(16U, 0x2AU); - 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: - ADD_FAILURE() << "Unexpected extra pairing request"; - return false; - } + return handle_short_challenge_pairing_request(&callCount, &saltHex, pin, serverIdentity, request, response); }); const network::HostPairingResult result = network::pair_host({ @@ -984,68 +1081,7 @@ namespace { std::size_t callCount = 0U; std::string saltHex; ScopedHostPairingHttpTestHandler guard([&](const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage, const std::atomic *) { - switch (callCount++) { - case 0U: - EXPECT_FALSE(request.useTls); - EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + 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(clientIdentity.certificatePem)); - 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); - std::vector challengePlaintext(48U); - for (std::size_t index = 0; index < challengePlaintext.size(); ++index) { - challengePlaintext[index] = static_cast(index + 1U); - } - 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: - { - std::vector serverSecret(16U, 0x5AU); - 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, 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, clientIdentity.uniqueId); - EXPECT_EQ(request.expectedTlsCertificatePem, serverIdentity.certificatePem); - EXPECT_EQ(extract_query_parameter(request.pathAndQuery, "phrase"), "pairchallenge"); - response->statusCode = 200; - response->body = make_pair_phase_response("1"); - return true; - default: - if (errorMessage != nullptr) { - *errorMessage = "Unexpected extra pairing request"; - } - ADD_FAILURE() << "Unexpected extra pairing request"; - return false; - } + return handle_successful_pairing_request(&callCount, &saltHex, pin, clientIdentity, serverIdentity, request, response, errorMessage); }); const network::HostPairingResult result = network::pair_host({ diff --git a/tests/unit/platform/filesystem_utils_test.cpp b/tests/unit/platform/filesystem_utils_test.cpp index 9c2c184..74f554e 100644 --- a/tests/unit/platform/filesystem_utils_test.cpp +++ b/tests/unit/platform/filesystem_utils_test.cpp @@ -19,7 +19,7 @@ namespace { - void write_test_file(const std::string &path, const std::string &content) { + 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()); From 204d41d522e4a984463064eec06f8562365605d1 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:34:00 -0400 Subject: [PATCH 33/35] Refactor byte helpers and pairing test handler Replace unsafe reinterpret_cast helpers with explicit conversion functions between std::byte and unsigned char for OpenSSL calls, and update functions (sha256_digest, aes_128_ecb_encrypt, sign_sha256_hex) to use those conversions. Simplify hex encoding/decoding to avoid C-style arrays and from_chars pointer issues. Introduce SuccessfulPairingScriptContext to bundle pairing test parameters and update handle_successful_pairing_request and its test usage accordingly. Also use an if-init statement for the HostPairingHttpTestHandler lookup in host_pairing.cpp for clearer scoping. These changes improve safety, clarity, and correctness of the tests and OpenSSL interactions. --- src/network/host_pairing.cpp | 3 +- tests/unit/network/host_pairing_test.cpp | 98 +++++++++++++++--------- 2 files changed, 63 insertions(+), 38 deletions(-) diff --git a/src/network/host_pairing.cpp b/src/network/host_pairing.cpp index 2813787..7e06a2d 100644 --- a/src/network/host_pairing.cpp +++ b/src/network/host_pairing.cpp @@ -1731,8 +1731,7 @@ namespace { return append_cancelled_pairing_error(errorMessage); } - network::testing::HostPairingHttpTestHandler &testHandler = host_pairing_http_test_handler(); - if (testHandler) { + if (const network::testing::HostPairingHttpTestHandler &testHandler = host_pairing_http_test_handler(); testHandler) { network::testing::HostPairingHttpTestRequest testRequest { address, port, diff --git a/tests/unit/network/host_pairing_test.cpp b/tests/unit/network/host_pairing_test.cpp index ce92e37..e56f946 100644 --- a/tests/unit/network/host_pairing_test.cpp +++ b/tests/unit/network/host_pairing_test.cpp @@ -135,12 +135,22 @@ namespace { return std::string(pathAndQuery.substr(valueStart, valueEnd == std::string_view::npos ? std::string_view::npos : valueEnd - valueStart)); } - const unsigned char *openssl_bytes(const std::byte *data) { - return reinterpret_cast(data); + 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; } - unsigned char *openssl_bytes(std::byte *data) { - return reinterpret_cast(data); + 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) { @@ -157,7 +167,14 @@ namespace { } std::string hex_encode_text(std::string_view text) { - return hex_encode_bytes(reinterpret_cast(text.data()), text.size()); + 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) { @@ -165,11 +182,11 @@ namespace { EXPECT_EQ(text.size() % 2U, 0U); bytes.reserve(text.size() / 2U); for (std::size_t index = 0; index + 1U < text.size(); index += 2U) { - char encodedByte[] = {text[index], text[index + 1U], '\0'}; + const std::string encodedByte(text.substr(index, 2U)); unsigned int value = 0U; - const std::from_chars_result decodeResult = std::from_chars(encodedByte, encodedByte + 2, value, 16); + 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 + 2); + EXPECT_EQ(decodeResult.ptr, encodedByte.data() + encodedByte.size()); bytes.push_back(static_cast(value)); } return bytes; @@ -178,14 +195,9 @@ namespace { std::vector sha256_digest(const std::byte *data, std::size_t size) { std::array digestBuffer {}; unsigned int digestSize = 0U; - EXPECT_EQ(EVP_Digest(openssl_bytes(data), size, digestBuffer.data(), &digestSize, EVP_sha256(), nullptr), 1); - - std::vector digest; - digest.reserve(digestSize); - for (unsigned int index = 0; index < digestSize; ++index) { - digest.push_back(static_cast(digestBuffer[index])); - } - return digest; + 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) { @@ -200,14 +212,16 @@ namespace { 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); - EXPECT_EQ(EVP_EncryptInit_ex(context.get(), EVP_aes_128_ecb(), nullptr, openssl_bytes(key.data()), nullptr), 1); + 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); + std::vector ciphertext(plaintext.size() + 16U); int ciphertextSize = 0; - EXPECT_EQ(EVP_EncryptUpdate(context.get(), openssl_bytes(ciphertext.data()), &ciphertextSize, openssl_bytes(plaintext.data()), static_cast(plaintext.size())), 1); + EXPECT_EQ(EVP_EncryptUpdate(context.get(), ciphertext.data(), &ciphertextSize, unsignedPlaintext.data(), static_cast(unsignedPlaintext.size())), 1); ciphertext.resize(static_cast(ciphertextSize)); - return ciphertext; + return to_std_bytes(ciphertext.data(), ciphertext.size()); } std::string sign_sha256_hex(const std::vector &data, std::string_view privateKeyPem) { @@ -219,7 +233,8 @@ namespace { 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); - EXPECT_EQ(EVP_DigestSignUpdate(context.get(), openssl_bytes(data.data()), data.size()), 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); @@ -297,33 +312,37 @@ namespace { } } + 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( - std::size_t *callCount, - std::string *saltHex, - std::string_view pin, - const network::PairingIdentity &clientIdentity, - const network::PairingIdentity &serverIdentity, + const SuccessfulPairingScriptContext &contextData, const HostPairingHttpTestRequest &request, HostPairingHttpTestResponse *response, std::string *errorMessage ) { - switch ((*callCount)++) { + switch ((*contextData.callCount)++) { case 0U: EXPECT_FALSE(request.useTls); - EXPECT_NE(request.pathAndQuery.find("/serverinfo?uniqueid=" + clientIdentity.uniqueId), std::string::npos); + 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(clientIdentity.certificatePem)); - *saltHex = extract_query_parameter(request.pathAndQuery, "salt"); + 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(serverIdentity.certificatePem)); + 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(*saltHex, pin); + 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()); @@ -336,7 +355,7 @@ namespace { 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, serverIdentity.privateKeyPem)); + 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: @@ -353,8 +372,8 @@ namespace { ADD_FAILURE() << "Expected a TLS client identity during pairchallenge"; return false; } - EXPECT_EQ(request.tlsClientIdentity->uniqueId, clientIdentity.uniqueId); - EXPECT_EQ(request.expectedTlsCertificatePem, serverIdentity.certificatePem); + 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"); @@ -1080,8 +1099,15 @@ namespace { 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(&callCount, &saltHex, pin, clientIdentity, serverIdentity, request, response, errorMessage); + return handle_successful_pairing_request(scriptContext, request, response, errorMessage); }); const network::HostPairingResult result = network::pair_host({ From 7b0af9d758a1be5f941f046a1f66564fd8090bd4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:47:52 -0400 Subject: [PATCH 34/35] Refactor OpenSSL CMake helpers, rename macro Improve GetOpenSSL.cmake by normalizing variable names and adding shell helper functions: _moonlight_shell_quote and _moonlight_join_shell_command to safely quote and join arguments for shell/MSYS2 use. _moonlight_to_msys_path was adjusted to use the new normalized variable names. Rename the common-dependencies macro to MOONLIGHT_PREPARE_COMMON_DEPENDENCIES (and update its invocation in xbox-build.cmake) to standardize naming and clarify intent. --- cmake/modules/GetOpenSSL.cmake | 19 +++++++++++-------- cmake/moonlight-dependencies.cmake | 3 ++- cmake/xbox-build.cmake | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/cmake/modules/GetOpenSSL.cmake b/cmake/modules/GetOpenSSL.cmake index 12e0a2e..8074599 100644 --- a/cmake/modules/GetOpenSSL.cmake +++ b/cmake/modules/GetOpenSSL.cmake @@ -24,31 +24,34 @@ set(MOONLIGHT_OPENSSL_EXTERNAL_TARGET "openssl_external_${MOONLIGHT_OPENSSL_PLAT 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) + file(TO_CMAKE_PATH "${path}" normalized_path) - if(_normalized_path MATCHES "^([A-Za-z]):/(.*)$") + if(normalized_path MATCHES "^([A-Za-z]):/(.*)$") string(TOLOWER "${CMAKE_MATCH_1}" _drive) - set(_normalized_path "/${_drive}/${CMAKE_MATCH_2}") + set(normalized_path "/${_drive}/${CMAKE_MATCH_2}") endif() - set(${out_var} "${_normalized_path}" PARENT_SCOPE) + 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) + set(quoted_args) foreach(arg IN LISTS ARGN) _moonlight_shell_quote(_quoted_arg "${arg}") - list(APPEND _quoted_args "${_quoted_arg}") + list(APPEND quoted_args "${_quoted_arg}") endforeach() - list(JOIN _quoted_args " " _command) - set(${out_var} "${_command}" PARENT_SCOPE) + list(JOIN quoted_args " " command) + set(${out_var} "${command}" PARENT_SCOPE) endfunction() file(MAKE_DIRECTORY "${OPENSSL_BUILD_DIR}") diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index 592a353..5ab1553 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -1,6 +1,7 @@ include_guard(GLOBAL) -macro(moonlight_prepare_common_dependencies) +# Prepare dependencies that are common to multiple Moonlight components +macro(MOONLIGHT_PREPARE_COMMON_DEPENDENCIES) include(GetOpenSSL REQUIRED) if(NOT TARGET moonlight-openssl) diff --git a/cmake/xbox-build.cmake b/cmake/xbox-build.cmake index e896b43..73043d1 100644 --- a/cmake/xbox-build.cmake +++ b/cmake/xbox-build.cmake @@ -49,7 +49,7 @@ endif() set(CMAKE_CXX_FLAGS_RELEASE "-O2") set(CMAKE_C_FLAGS_RELEASE "-O2") -moonlight_prepare_common_dependencies() +MOONLIGHT_PREPARE_COMMON_DEPENDENCIES() add_executable(${CMAKE_PROJECT_NAME} ${MOONLIGHT_SOURCES} From e25ea15fd94f1188f0f592d345fc5dc54a269277 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:58:50 -0400 Subject: [PATCH 35/35] Refactor MSYS2 detection into helper functions Split and reorganize MSYS2 detection logic in cmake/msys2.cmake into smaller helper functions. Centralized caching of MOONLIGHT_MSYS2_ROOT via _moonlight_set_detected_msys2_root and added helpers to build candidate lists (_moonlight_get_configured_msys2_root_candidates, _moonlight_get_default_msys2_root_candidates), to try candidate roots (_moonlight_try_msys2_root_candidates), to probe from given tool paths (_moonlight_try_msys2_root_from_tools), to probe PATH for common tools (_moonlight_try_msys2_root_from_path_tools), and to check hinted installation roots (_moonlight_try_hinted_msys2_root). The main moonlight_detect_windows_msys2_root was rewritten to orchestrate these helpers and keep behavior while cleaning up responsibility and removing duplicated cache writes. Also minor formatting change in tests/CMakeLists.txt to wrap the fatal error message across lines for readability. --- cmake/msys2.cmake | 119 ++++++++++++++++++++++++++++++++----------- tests/CMakeLists.txt | 3 +- 2 files changed, 92 insertions(+), 30 deletions(-) diff --git a/cmake/msys2.cmake b/cmake/msys2.cmake index 38fad93..78a2ae4 100644 --- a/cmake/msys2.cmake +++ b/cmake/msys2.cmake @@ -56,12 +56,13 @@ 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}") @@ -73,24 +74,51 @@ function(moonlight_detect_windows_msys2_root out_var) list(APPEND candidate_roots "$ENV{MSYS2_ROOT}") endif() - foreach(candidate_root IN LISTS candidate_roots) + 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() + list(APPEND candidate_roots + "C:/msys64" + "C:/tools/msys64" + ) + + 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() - foreach(tool_path IN ITEMS "${CMAKE_COMMAND}" "${CMAKE_MAKE_PROGRAM}") + 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(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() + +# 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 @@ -105,30 +133,17 @@ function(moonlight_detect_windows_msys2_root out_var) find_program(_tool_path NAMES ${tool_name}) _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() - if(DEFINED ENV{SystemDrive} AND NOT "$ENV{SystemDrive}" STREQUAL "") - list(APPEND candidate_roots "$ENV{SystemDrive}/msys64") - endif() - list(APPEND candidate_roots - "C:/msys64" - "C:/tools/msys64" - ) - - foreach(candidate_root IN LISTS candidate_roots) - _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(${out_var} "" PARENT_SCOPE) +endfunction() - set(program_hints ${candidate_roots}) +# 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 @@ -137,7 +152,6 @@ 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() @@ -150,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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 059dd40..dec535f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,7 +16,8 @@ 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") + message(FATAL_ERROR + "tests/CMakeLists.txt requires the shared Moonlight dependency targets from the top-level configure") endif() set(TEST_COVERAGE_COMPILE_OPTIONS)