diff --git a/README.md b/README.md index 904abb205..abdbbe18f 100644 --- a/README.md +++ b/README.md @@ -4,8 +4,6 @@ Vibepollo is an AI‑enhanced version of Apollo, a popular remote streaming application. It intends to integrate all scripts from myself (Nonary) and more. - - ## Key Features * **Display Setting Automation** @@ -35,6 +33,9 @@ Vibepollo is an AI‑enhanced version of Apollo, a popular remote streaming appl * **Lossless Scaling & NVIDIA Smooth Motion** Vibepollo can automatically apply optimal Lossless Scaling settings to generate frames for any application. On RTX 40‑series and newer GPUs, you can optionally enable **NVIDIA Smooth Motion** for better performance and image quality (while Lossless Scaling remains more customizable). +* **Remote Microphone Passthrough** + Accept redirected client microphone audio from compatible Moonlight or Artemis builds, decode it on the host, and render it into Steam Streaming Microphone on Windows so host apps can use `Microphone (Steam Streaming Microphone)` as their microphone source. Setup and debugging notes are in [docs/remote_microphone.md](docs/remote_microphone.md). + * **API Token Management** Access tokens can be tightly scoped—down to specific methods—so external scripts don’t need full administrative rights. This improves security while keeping automation flexible. diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index 5bbddb760..0bb8a7d53 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -1,244 +1,248 @@ -# windows specific compile definitions - -add_compile_definitions(SUNSHINE_PLATFORM="windows") - -enable_language(RC) -set(CMAKE_RC_COMPILER windres) -set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static") - -# gcc complains about misleading indentation in some mingw includes -list(APPEND SUNSHINE_COMPILE_OPTIONS -Wno-misleading-indentation) - -# see gcc bug 98723 -add_definitions(-DUSE_BOOST_REGEX) - -# curl -add_definitions(-DCURL_STATICLIB) -include_directories(SYSTEM ${CURL_STATIC_INCLUDE_DIRS}) -link_directories(${CURL_STATIC_LIBRARY_DIRS}) - -# miniupnpc -add_definitions(-DMINIUPNP_STATICLIB) - -# extra tools/binaries for audio/display devices -add_subdirectory(tools) # todo - this is temporary, only tools for Windows are needed, for now - -# nvidia -include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/nvapi-open-source-sdk") -file(GLOB NVPREFS_FILES CONFIGURE_DEPENDS - "${CMAKE_SOURCE_DIR}/third-party/nvapi-open-source-sdk/*.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/nvprefs/*.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/nvprefs/*.h") - -# vigem -include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include") -include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/sudovda") - -# apollo icon -if(NOT DEFINED PROJECT_ICON_PATH) - set(PROJECT_ICON_PATH "${CMAKE_SOURCE_DIR}/apollo.ico") -endif() - -list(APPEND SUNSHINE_DEFINITIONS PROJECT_APP_USER_MODEL_ID="${WINDOWS_APP_USER_MODEL_ID}") - -# Generate Windows fixed FILEVERSION metadata at build time. The generator is -# intentionally run for every build so dirty/local builds and post-tag rebuilds -# can advance the fourth version field without requiring a fresh CMake configure. -set(SUNSHINE_WINDOWS_VERSIONINFO_DIR "${CMAKE_BINARY_DIR}/generated_versioninfo") -set(SUNSHINE_WINDOWS_VERSIONINFO_HEADER "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/windows_versioninfo_generated.h") -set(SUNSHINE_WINDOWS_VERSIONINFO_CACHE "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/windows_versioninfo_generated.cache") -set(SUNSHINE_WINDOWS_VERSIONINFO_STAMP "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/windows_versioninfo_generated.stamp") - -add_custom_target(generate_windows_versioninfo - COMMAND ${CMAKE_COMMAND} -E make_directory "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}" - COMMAND ${CMAKE_COMMAND} - "-DOUTPUT_FILE=${SUNSHINE_WINDOWS_VERSIONINFO_HEADER}" - "-DCACHE_FILE=${SUNSHINE_WINDOWS_VERSIONINFO_CACHE}" - "-DSOURCE_DIR=${CMAKE_SOURCE_DIR}" - "-DPROJECT_VERSION_FULL=${PROJECT_VERSION_FULL}" - "-DPROJECT_VERSION_MAJOR=${PROJECT_VERSION_MAJOR}" - "-DPROJECT_VERSION_MINOR=${PROJECT_VERSION_MINOR}" - "-DPROJECT_VERSION_PATCH=${PROJECT_VERSION_PATCH}" - "-DPROJECT_VERSION_PRERELEASE=${PROJECT_VERSION_PRERELEASE}" - -P "${CMAKE_SOURCE_DIR}/cmake/prep/emit_windows_versioninfo.cmake" - COMMAND ${CMAKE_COMMAND} -E touch "${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}" - BYPRODUCTS - "${SUNSHINE_WINDOWS_VERSIONINFO_HEADER}" - "${SUNSHINE_WINDOWS_VERSIONINFO_CACHE}" - "${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}" - COMMENT "Generating Windows VERSIONINFO header" - VERBATIM -) - -set_source_files_properties("${SUNSHINE_WINDOWS_VERSIONINFO_HEADER}" PROPERTIES GENERATED TRUE) -set_source_files_properties("${CMAKE_SOURCE_DIR}/src/platform/windows/windows.rc" PROPERTIES - OBJECT_DEPENDS "${SUNSHINE_WINDOWS_VERSIONINFO_HEADER};${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}") - -# Create a separate object library for the RC file with minimal includes -add_library(sunshine_rc_object OBJECT "${CMAKE_SOURCE_DIR}/src/platform/windows/windows.rc") -add_dependencies(sunshine_rc_object generate_windows_versioninfo) - -# Set minimal properties for RC compilation - only what's needed for the resource file -# Otherwise compilation can fail due to "line too long" errors -set_target_properties(sunshine_rc_object PROPERTIES - COMPILE_DEFINITIONS "PROJECT_ICON_PATH=${PROJECT_ICON_PATH};PROJECT_NAME=${PROJECT_NAME};PROJECT_VENDOR=${SUNSHINE_PUBLISHER_NAME};PROJECT_VERSION=${PROJECT_VERSION_FULL};PROJECT_VERSION_MAJOR=${PROJECT_VERSION_MAJOR};PROJECT_VERSION_MINOR=${PROJECT_VERSION_MINOR};PROJECT_VERSION_PATCH=${PROJECT_VERSION_PATCH}" # cmake-lint: disable=C0301 - INCLUDE_DIRECTORIES "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}" -) - -function(sunshine_add_windows_versioninfo target_name) - if(NOT TARGET "${target_name}") - message(FATAL_ERROR "sunshine_add_windows_versioninfo: target not found: ${target_name}") - endif() - - string(MAKE_C_IDENTIFIER "${target_name}" _target_id) - set(_rc_target "sunshine_${_target_id}_rc_object") - set(_rc_file "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/${_target_id}_versioninfo.rc") - - if(NOT TARGET "${_rc_target}") - set(_versioninfo_file_description "${target_name}") - set(_versioninfo_internal_name "${target_name}") - set(_versioninfo_original_filename "${target_name}.exe") - set(_versioninfo_product_name "${target_name}") - configure_file( - "${CMAKE_SOURCE_DIR}/src/platform/windows/tool_version.rc.in" - "${_rc_file}" - @ONLY - ) - set_source_files_properties("${_rc_file}" PROPERTIES - GENERATED TRUE - OBJECT_DEPENDS "${SUNSHINE_WINDOWS_VERSIONINFO_HEADER};${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}" - ) - add_library("${_rc_target}" OBJECT "${_rc_file}") - add_dependencies("${_rc_target}" generate_windows_versioninfo) - set_target_properties("${_rc_target}" PROPERTIES - INCLUDE_DIRECTORIES "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}" - ) - endif() - - target_sources("${target_name}" PRIVATE "$") -endfunction() - -foreach(_sunshine_versioned_tool IN ITEMS - dxgi-info - audio-info - sunshinesvc - playnite-launcher - sunshine_wgc_capture - sunshine_display_helper) - if(TARGET "${_sunshine_versioned_tool}") - sunshine_add_windows_versioninfo("${_sunshine_versioned_tool}") - endif() -endforeach() -unset(_sunshine_versioned_tool) - -# ViGEmBus version -set(VIGEMBUS_PACKAGED_V "1.21.442") -set(VIGEMBUS_PACKAGED_V_2 "${VIGEMBUS_PACKAGED_V}.0") -list(APPEND SUNSHINE_DEFINITIONS VIGEMBUS_PACKAGED_VERSION="${VIGEMBUS_PACKAGED_V_2}") - -set(PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/src/platform/windows/publish.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/host_stats.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/pipes.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/pipes.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/misc_utils.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/misc_utils.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/process_handler.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/process_handler.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/display_settings_client.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/display_settings_client.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_coordinator.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_coordinator.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_request_helpers.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_request_helpers.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_integration.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_integration.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_session_deferral.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_session_deferral.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_watchdog.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_watchdog.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_cleanup.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_cleanup.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/hotkey_manager.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/hotkey_manager.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_ipc.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_ipc.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_protocol.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_protocol.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_sync.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_sync.cpp" - "${CMAKE_SOURCE_DIR}/src/config_playnite.h" - "${CMAKE_SOURCE_DIR}/src/config_playnite.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_integration.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_integration.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/input.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_base.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_ram.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_vram.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/display_wgc.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/audio.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_legacy.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_legacy.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/utils.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/utils.cpp" - "${CMAKE_SOURCE_DIR}/third-party/sudovda/sudovda-ioctl.h" - "${CMAKE_SOURCE_DIR}/third-party/sudovda/sudovda.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter_nvcp.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter_nvcp.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/lossless_scaling_paths.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/image_convert.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/image_convert.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/rtss_integration.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/rtss_integration.cpp" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/ipc_session.h" - "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/ipc_session.cpp" - "${CMAKE_SOURCE_DIR}/tools/playnite_launcher/focus_utils.cpp" - "${CMAKE_SOURCE_DIR}/tools/playnite_launcher/lossless_scaling.cpp" - "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/src/ViGEmClient.cpp" - "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/Client.h" - "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/Common.h" - "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/Util.h" - "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/km/BusShared.h" - ${NVPREFS_FILES}) - -set(OPENSSL_LIBRARIES - libssl.a - libcrypto.a) - -list(PREPEND PLATFORM_LIBRARIES - ${CURL_STATIC_LIBRARIES} - avrt - d3d11 - D3DCompiler - dwmapi - dxgi - iphlpapi - ksuser - libssp.a - libstdc++.a - libwinpthread.a - minhook::minhook - ntdll - pdh - setupapi - shlwapi - shell32 - crypt32 - synchronization.lib - Windowscodecs - userenv - ws2_32 - wsock32 -) - -if(SUNSHINE_ENABLE_TRAY) - list(APPEND PLATFORM_TARGET_FILES - "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") -endif() +# windows specific compile definitions + +add_compile_definitions(SUNSHINE_PLATFORM="windows") + +enable_language(RC) +set(CMAKE_RC_COMPILER windres) +set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -static") + +# gcc complains about misleading indentation in some mingw includes +list(APPEND SUNSHINE_COMPILE_OPTIONS -Wno-misleading-indentation) + +# see gcc bug 98723 +add_definitions(-DUSE_BOOST_REGEX) + +# curl +add_definitions(-DCURL_STATICLIB) +include_directories(SYSTEM ${CURL_STATIC_INCLUDE_DIRS}) +link_directories(${CURL_STATIC_LIBRARY_DIRS}) + +# miniupnpc +add_definitions(-DMINIUPNP_STATICLIB) + +# extra tools/binaries for audio/display devices +add_subdirectory(tools) # todo - this is temporary, only tools for Windows are needed, for now + +# nvidia +include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/nvapi-open-source-sdk") +file(GLOB NVPREFS_FILES CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/third-party/nvapi-open-source-sdk/*.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/nvprefs/*.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/nvprefs/*.h") + +# vigem +include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include") +include_directories(SYSTEM "${CMAKE_SOURCE_DIR}/third-party/sudovda") + +# apollo icon +if(NOT DEFINED PROJECT_ICON_PATH) + set(PROJECT_ICON_PATH "${CMAKE_SOURCE_DIR}/apollo.ico") +endif() + +list(APPEND SUNSHINE_DEFINITIONS PROJECT_APP_USER_MODEL_ID="${WINDOWS_APP_USER_MODEL_ID}") + +# Generate Windows fixed FILEVERSION metadata at build time. The generator is +# intentionally run for every build so dirty/local builds and post-tag rebuilds +# can advance the fourth version field without requiring a fresh CMake configure. +set(SUNSHINE_WINDOWS_VERSIONINFO_DIR "${CMAKE_BINARY_DIR}/generated_versioninfo") +set(SUNSHINE_WINDOWS_VERSIONINFO_HEADER "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/windows_versioninfo_generated.h") +set(SUNSHINE_WINDOWS_VERSIONINFO_CACHE "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/windows_versioninfo_generated.cache") +set(SUNSHINE_WINDOWS_VERSIONINFO_STAMP "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/windows_versioninfo_generated.stamp") + +add_custom_target(generate_windows_versioninfo + COMMAND ${CMAKE_COMMAND} -E make_directory "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}" + COMMAND ${CMAKE_COMMAND} + "-DOUTPUT_FILE=${SUNSHINE_WINDOWS_VERSIONINFO_HEADER}" + "-DCACHE_FILE=${SUNSHINE_WINDOWS_VERSIONINFO_CACHE}" + "-DSOURCE_DIR=${CMAKE_SOURCE_DIR}" + "-DPROJECT_VERSION_FULL=${PROJECT_VERSION_FULL}" + "-DPROJECT_VERSION_MAJOR=${PROJECT_VERSION_MAJOR}" + "-DPROJECT_VERSION_MINOR=${PROJECT_VERSION_MINOR}" + "-DPROJECT_VERSION_PATCH=${PROJECT_VERSION_PATCH}" + "-DPROJECT_VERSION_PRERELEASE=${PROJECT_VERSION_PRERELEASE}" + -P "${CMAKE_SOURCE_DIR}/cmake/prep/emit_windows_versioninfo.cmake" + COMMAND ${CMAKE_COMMAND} -E touch "${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}" + BYPRODUCTS + "${SUNSHINE_WINDOWS_VERSIONINFO_HEADER}" + "${SUNSHINE_WINDOWS_VERSIONINFO_CACHE}" + "${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}" + COMMENT "Generating Windows VERSIONINFO header" + VERBATIM +) + +set_source_files_properties("${SUNSHINE_WINDOWS_VERSIONINFO_HEADER}" PROPERTIES GENERATED TRUE) +set_source_files_properties("${CMAKE_SOURCE_DIR}/src/platform/windows/windows.rc" PROPERTIES + OBJECT_DEPENDS "${SUNSHINE_WINDOWS_VERSIONINFO_HEADER};${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}") + +# Create a separate object library for the RC file with minimal includes +add_library(sunshine_rc_object OBJECT "${CMAKE_SOURCE_DIR}/src/platform/windows/windows.rc") +add_dependencies(sunshine_rc_object generate_windows_versioninfo) + +# Set minimal properties for RC compilation - only what's needed for the resource file +# Otherwise compilation can fail due to "line too long" errors +set_target_properties(sunshine_rc_object PROPERTIES + COMPILE_DEFINITIONS "PROJECT_ICON_PATH=${PROJECT_ICON_PATH};PROJECT_NAME=${PROJECT_NAME};PROJECT_VENDOR=${SUNSHINE_PUBLISHER_NAME};PROJECT_VERSION=${PROJECT_VERSION_FULL};PROJECT_VERSION_MAJOR=${PROJECT_VERSION_MAJOR};PROJECT_VERSION_MINOR=${PROJECT_VERSION_MINOR};PROJECT_VERSION_PATCH=${PROJECT_VERSION_PATCH}" # cmake-lint: disable=C0301 + INCLUDE_DIRECTORIES "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}" +) + +function(sunshine_add_windows_versioninfo target_name) + if(NOT TARGET "${target_name}") + message(FATAL_ERROR "sunshine_add_windows_versioninfo: target not found: ${target_name}") + endif() + + string(MAKE_C_IDENTIFIER "${target_name}" _target_id) + set(_rc_target "sunshine_${_target_id}_rc_object") + set(_rc_file "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}/${_target_id}_versioninfo.rc") + + if(NOT TARGET "${_rc_target}") + set(_versioninfo_file_description "${target_name}") + set(_versioninfo_internal_name "${target_name}") + set(_versioninfo_original_filename "${target_name}.exe") + set(_versioninfo_product_name "${target_name}") + configure_file( + "${CMAKE_SOURCE_DIR}/src/platform/windows/tool_version.rc.in" + "${_rc_file}" + @ONLY + ) + set_source_files_properties("${_rc_file}" PROPERTIES + GENERATED TRUE + OBJECT_DEPENDS "${SUNSHINE_WINDOWS_VERSIONINFO_HEADER};${SUNSHINE_WINDOWS_VERSIONINFO_STAMP}" + ) + add_library("${_rc_target}" OBJECT "${_rc_file}") + add_dependencies("${_rc_target}" generate_windows_versioninfo) + set_target_properties("${_rc_target}" PROPERTIES + INCLUDE_DIRECTORIES "${SUNSHINE_WINDOWS_VERSIONINFO_DIR}" + ) + endif() + + target_sources("${target_name}" PRIVATE "$") +endfunction() + +foreach(_sunshine_versioned_tool IN ITEMS + dxgi-info + audio-info + sunshinesvc + playnite-launcher + sunshine_wgc_capture + sunshine_display_helper) + if(TARGET "${_sunshine_versioned_tool}") + sunshine_add_windows_versioninfo("${_sunshine_versioned_tool}") + endif() +endforeach() +unset(_sunshine_versioned_tool) + +# ViGEmBus version +set(VIGEMBUS_PACKAGED_V "1.21.442") +set(VIGEMBUS_PACKAGED_V_2 "${VIGEMBUS_PACKAGED_V}.0") +list(APPEND SUNSHINE_DEFINITIONS VIGEMBUS_PACKAGED_VERSION="${VIGEMBUS_PACKAGED_V_2}") + +set(PLATFORM_TARGET_FILES + "${CMAKE_SOURCE_DIR}/src/platform/windows/publish.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/host_stats.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/pipes.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/pipes.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/misc_utils.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/misc_utils.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/process_handler.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/process_handler.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/display_settings_client.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/display_settings_client.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_coordinator.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_coordinator.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_request_helpers.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_request_helpers.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_integration.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_integration.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_session_deferral.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_session_deferral.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_watchdog.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_helper_watchdog.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_cleanup.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_cleanup.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/hotkey_manager.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/hotkey_manager.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_ipc.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_ipc.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_protocol.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_protocol.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_sync.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_sync.cpp" + "${CMAKE_SOURCE_DIR}/src/config_playnite.h" + "${CMAKE_SOURCE_DIR}/src/config_playnite.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_integration.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/playnite_integration.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/input.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_base.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_ram.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_vram.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/display_wgc.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/audio.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/vibepollo_vmic.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/vibepollo_vmic.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/mic_write.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/mic_write.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_legacy.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/virtual_display_legacy.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/utils.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/utils.cpp" + "${CMAKE_SOURCE_DIR}/third-party/sudovda/sudovda-ioctl.h" + "${CMAKE_SOURCE_DIR}/third-party/sudovda/sudovda.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter_nvcp.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/frame_limiter_nvcp.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/lossless_scaling_paths.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/image_convert.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/image_convert.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/rtss_integration.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/rtss_integration.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/ipc_session.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/ipc/ipc_session.cpp" + "${CMAKE_SOURCE_DIR}/tools/playnite_launcher/focus_utils.cpp" + "${CMAKE_SOURCE_DIR}/tools/playnite_launcher/lossless_scaling.cpp" + "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/src/ViGEmClient.cpp" + "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/Client.h" + "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/Common.h" + "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/Util.h" + "${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/include/ViGEm/km/BusShared.h" + ${NVPREFS_FILES}) + +set(OPENSSL_LIBRARIES + libssl.a + libcrypto.a) + +list(PREPEND PLATFORM_LIBRARIES + ${CURL_STATIC_LIBRARIES} + avrt + d3d11 + D3DCompiler + dwmapi + dxgi + iphlpapi + ksuser + libssp.a + libstdc++.a + libwinpthread.a + minhook::minhook + ntdll + pdh + setupapi + shlwapi + shell32 + crypt32 + synchronization.lib + Windowscodecs + userenv + ws2_32 + wsock32 +) + +if(SUNSHINE_ENABLE_TRAY) + list(APPEND PLATFORM_TARGET_FILES + "${CMAKE_SOURCE_DIR}/third-party/tray/src/tray_windows.c") +endif() diff --git a/cmake/dependencies/Boost_Sunshine.cmake b/cmake/dependencies/Boost_Sunshine.cmake index 1ea68c02e..4600e76f3 100644 --- a/cmake/dependencies/Boost_Sunshine.cmake +++ b/cmake/dependencies/Boost_Sunshine.cmake @@ -1,104 +1,105 @@ -# -# Loads the boost library giving the priority to the system package first, with a fallback to FetchContent. -# -include_guard(GLOBAL) - -set(BOOST_VERSION "1.89.0") -set(BOOST_COMPONENTS - filesystem - locale - log - process - program_options - process - system -) -# system is not used by Sunshine, but by Simple-Web-Server, added here for convenience - -# algorithm, preprocessor, scope, and uuid are not used by Sunshine, but by libdisplaydevice, added here for convenience -if(WIN32) - list(APPEND BOOST_COMPONENTS - algorithm - preprocessor - scope - uuid - ) -endif() - -if(BOOST_USE_STATIC) - set(Boost_USE_STATIC_LIBS ON) # cmake-lint: disable=C0103 -endif() - -if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.30") - cmake_policy(SET CMP0167 NEW) # Get BoostConfig.cmake from upstream -endif() -find_package(Boost CONFIG ${BOOST_VERSION} COMPONENTS ${BOOST_COMPONENTS}) -if(NOT Boost_FOUND) - message(STATUS "Boost v${BOOST_VERSION} package not found in the system. Falling back to FetchContent.") - include(FetchContent) - - if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0") - cmake_policy(SET CMP0135 NEW) # Avoid warning about DOWNLOAD_EXTRACT_TIMESTAMP in CMake 3.24 - endif() - if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.31.0") - cmake_policy(SET CMP0174 NEW) # Handle empty variables - endif() - - # more components required for compiling boost targets - list(APPEND BOOST_COMPONENTS - asio - crc - format - property_tree) - - set(BOOST_ENABLE_CMAKE ON) - - # Limit boost to the required libraries only - set(BOOST_INCLUDE_LIBRARIES ${BOOST_COMPONENTS}) - set(BOOST_URL "https://github.com/boostorg/boost/releases/download/boost-${BOOST_VERSION}/boost-${BOOST_VERSION}-cmake.tar.xz") # cmake-lint: disable=C0301 - set(BOOST_HASH "SHA256=67acec02d0d118b5de9eb441f5fb707b3a1cdd884be00ca24b9a73c995511f74") - - if(CMAKE_VERSION VERSION_LESS "3.24.0") - FetchContent_Declare( - Boost - URL ${BOOST_URL} - URL_HASH ${BOOST_HASH} - ) - elseif(APPLE AND CMAKE_VERSION VERSION_GREATER_EQUAL "3.25.0") - # add SYSTEM to FetchContent_Declare, this fails on debian bookworm - FetchContent_Declare( - Boost - URL ${BOOST_URL} - URL_HASH ${BOOST_HASH} - SYSTEM # requires CMake 3.25+ - OVERRIDE_FIND_PACKAGE # requires CMake 3.24+, but we have a macro to handle it for other versions - ) - elseif(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0") - FetchContent_Declare( - Boost - URL ${BOOST_URL} - URL_HASH ${BOOST_HASH} - OVERRIDE_FIND_PACKAGE # requires CMake 3.24+, but we have a macro to handle it for other versions - ) - endif() - - FetchContent_MakeAvailable(Boost) - set(FETCH_CONTENT_BOOST_USED TRUE) - - set(Boost_FOUND TRUE) # cmake-lint: disable=C0103 - set(Boost_INCLUDE_DIRS # cmake-lint: disable=C0103 - "$") - - if(WIN32) - # Windows build is failing to create .h file in this directory - file(MAKE_DIRECTORY ${Boost_BINARY_DIR}/libs/log/src/windows) - endif() - - set(Boost_LIBRARIES "") # cmake-lint: disable=C0103 - foreach(component ${BOOST_COMPONENTS}) - list(APPEND Boost_LIBRARIES "Boost::${component}") - endforeach() -endif() - -message(STATUS "Boost include dirs: ${Boost_INCLUDE_DIRS}") -message(STATUS "Boost libraries: ${Boost_LIBRARIES}") +# +# Loads the boost library giving the priority to the system package first, with a fallback to FetchContent. +# +include_guard(GLOBAL) + +set(BOOST_MIN_VERSION "1.89.0") +set(BOOST_FETCH_VERSION "1.89.0") +set(BOOST_COMPONENTS + filesystem + locale + log + process + program_options + process + system +) +# system is not used by Sunshine, but by Simple-Web-Server, added here for convenience + +# algorithm, preprocessor, scope, and uuid are not used by Sunshine, but by libdisplaydevice, added here for convenience +if(WIN32) + list(APPEND BOOST_COMPONENTS + algorithm + preprocessor + scope + uuid + ) +endif() + +if(BOOST_USE_STATIC) + set(Boost_USE_STATIC_LIBS ON) # cmake-lint: disable=C0103 +endif() + +if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.30") + cmake_policy(SET CMP0167 NEW) # Get BoostConfig.cmake from upstream +endif() +find_package(Boost CONFIG ${BOOST_MIN_VERSION} COMPONENTS ${BOOST_COMPONENTS}) +if(NOT Boost_FOUND) + message(STATUS "Boost v${BOOST_MIN_VERSION}+ package not found in the system. Falling back to FetchContent.") + include(FetchContent) + + if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0") + cmake_policy(SET CMP0135 NEW) # Avoid warning about DOWNLOAD_EXTRACT_TIMESTAMP in CMake 3.24 + endif() + if (CMAKE_VERSION VERSION_GREATER_EQUAL "3.31.0") + cmake_policy(SET CMP0174 NEW) # Handle empty variables + endif() + + # more components required for compiling boost targets + list(APPEND BOOST_COMPONENTS + asio + crc + format + property_tree) + + set(BOOST_ENABLE_CMAKE ON) + + # Limit boost to the required libraries only + set(BOOST_INCLUDE_LIBRARIES ${BOOST_COMPONENTS}) + set(BOOST_URL "https://github.com/boostorg/boost/releases/download/boost-${BOOST_FETCH_VERSION}/boost-${BOOST_FETCH_VERSION}-cmake.tar.xz") # cmake-lint: disable=C0301 + set(BOOST_HASH "SHA256=67acec02d0d118b5de9eb441f5fb707b3a1cdd884be00ca24b9a73c995511f74") + + if(CMAKE_VERSION VERSION_LESS "3.24.0") + FetchContent_Declare( + Boost + URL ${BOOST_URL} + URL_HASH ${BOOST_HASH} + ) + elseif(APPLE AND CMAKE_VERSION VERSION_GREATER_EQUAL "3.25.0") + # add SYSTEM to FetchContent_Declare, this fails on debian bookworm + FetchContent_Declare( + Boost + URL ${BOOST_URL} + URL_HASH ${BOOST_HASH} + SYSTEM # requires CMake 3.25+ + OVERRIDE_FIND_PACKAGE # requires CMake 3.24+, but we have a macro to handle it for other versions + ) + elseif(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24.0") + FetchContent_Declare( + Boost + URL ${BOOST_URL} + URL_HASH ${BOOST_HASH} + OVERRIDE_FIND_PACKAGE # requires CMake 3.24+, but we have a macro to handle it for other versions + ) + endif() + + FetchContent_MakeAvailable(Boost) + set(FETCH_CONTENT_BOOST_USED TRUE) + + set(Boost_FOUND TRUE) # cmake-lint: disable=C0103 + set(Boost_INCLUDE_DIRS # cmake-lint: disable=C0103 + "$") + + if(WIN32) + # Windows build is failing to create .h file in this directory + file(MAKE_DIRECTORY ${Boost_BINARY_DIR}/libs/log/src/windows) + endif() + + set(Boost_LIBRARIES "") # cmake-lint: disable=C0103 + foreach(component ${BOOST_COMPONENTS}) + list(APPEND Boost_LIBRARIES "Boost::${component}") + endforeach() +endif() + +message(STATUS "Boost include dirs: ${Boost_INCLUDE_DIRS}") +message(STATUS "Boost libraries: ${Boost_LIBRARIES}") diff --git a/cmake/targets/common.cmake b/cmake/targets/common.cmake index 3d455ffad..680b3911d 100644 --- a/cmake/targets/common.cmake +++ b/cmake/targets/common.cmake @@ -1,161 +1,173 @@ -# common target definitions -# this file will also load platform specific macros - -add_executable(sunshine ${SUNSHINE_TARGET_FILES}) -foreach(dep ${SUNSHINE_TARGET_DEPENDENCIES}) - add_dependencies(sunshine ${dep}) # compile these before sunshine -endforeach() - -# platform specific target definitions -if(WIN32) - include(${CMAKE_MODULE_PATH}/targets/windows.cmake) -elseif(UNIX) - include(${CMAKE_MODULE_PATH}/targets/unix.cmake) - - if(APPLE) - include(${CMAKE_MODULE_PATH}/targets/macos.cmake) - else() - include(${CMAKE_MODULE_PATH}/targets/linux.cmake) - endif() -endif() - -# todo - is this necessary? ... for anything except linux? -if(NOT DEFINED CMAKE_CUDA_STANDARD) - set(CMAKE_CUDA_STANDARD 17) - set(CMAKE_CUDA_STANDARD_REQUIRED ON) -endif() - -target_link_libraries(sunshine ${SUNSHINE_EXTERNAL_LIBRARIES} ${EXTRA_LIBS}) -target_compile_definitions(sunshine PUBLIC ${SUNSHINE_DEFINITIONS}) - -# Logging integration flags are provided via SUNSHINE_DEFINITIONS to avoid duplicates -set_target_properties(sunshine PROPERTIES CXX_STANDARD 23 - VERSION ${PROJECT_VERSION} - SOVERSION ${PROJECT_VERSION_MAJOR}) - -# CLion complains about unknown flags after running cmake, and cannot add symbols to the index for cuda files -if(CUDA_INHERIT_COMPILE_OPTIONS) - foreach(flag IN LISTS SUNSHINE_COMPILE_OPTIONS) - list(APPEND SUNSHINE_COMPILE_OPTIONS_CUDA "$<$:--compiler-options=${flag}>") - endforeach() -endif() - -target_compile_options(sunshine PRIVATE $<$:${SUNSHINE_COMPILE_OPTIONS}>;$<$:${SUNSHINE_COMPILE_OPTIONS_CUDA};-std=c++17>) # cmake-lint: disable=C0301 - -# Homebrew build fails the vite build if we set these environment variables -if(${SUNSHINE_BUILD_HOMEBREW}) - set(NPM_SOURCE_ASSETS_DIR "") - set(NPM_ASSETS_DIR "") - set(NPM_BUILD_HOMEBREW "true") -else() - set(NPM_SOURCE_ASSETS_DIR ${SUNSHINE_SOURCE_ASSETS_DIR}) - set(NPM_ASSETS_DIR ${CMAKE_BINARY_DIR}) - set(NPM_BUILD_HOMEBREW "") -endif() - -# Web UI source dir (where package.json lives) -# Default layout: ${CMAKE_SOURCE_DIR}/src_assets/common/assets/web -set(WEB_UI_DIR "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web") - -#WebUI build -find_program(NPM npm REQUIRED) - -set(NPM_INSTALL_FLAGS - --ignore-scripts - --no-audit - --no-fund - --loglevel=error -) -if (NPM_OFFLINE) - list(APPEND NPM_INSTALL_FLAGS --offline) -endif() - -# Choose web UI build mode based on active CMake configuration. -# In Debug config, build Vite in "debug" mode to enable Vue devtools. -# In other configs, build production assets. -set(NPM_BUILD_COMMAND_RUN "run") -set(NPM_BUILD_COMMAND_ARG "$,build:debug,build>") -set(NPM_BUILD_ENV "$,NODE_ENV=development,>") - -# Some Node versions support enabling source-map support; keep empty if not needed -set(NPM_BUILD_NODE_OPTIONS "") - -add_custom_target(web-ui ALL - WORKING_DIRECTORY "${WEB_UI_DIR}" - COMMENT "Installing NPM dependencies and building the Web UI" - COMMAND "$<$:cmd;/C>" "${NPM}" ci ${NPM_INSTALL_FLAGS} - COMMAND "${CMAKE_COMMAND}" -E env - "SUNSHINE_BUILD_HOMEBREW=${NPM_BUILD_HOMEBREW}" - "SUNSHINE_SOURCE_ASSETS_DIR=${NPM_SOURCE_ASSETS_DIR}" - "SUNSHINE_ASSETS_DIR=${NPM_ASSETS_DIR}" - "${NPM_BUILD_ENV}" - "${NPM_BUILD_NODE_OPTIONS}" - "$<$:cmd;/C>" "${NPM}" ${NPM_BUILD_COMMAND_RUN} ${NPM_BUILD_COMMAND_ARG} # cmake-lint: disable=C0301 - COMMAND_EXPAND_LISTS - VERBATIM) - -# docs -if(BUILD_DOCS) - add_subdirectory(third-party/doxyconfig docs) -endif() - -# tests -if(BUILD_TESTS) - add_subdirectory(tests) -endif() - -# custom compile flags, must be after adding tests - -if (NOT BUILD_TESTS) - set(TEST_DIR "") -else() - set(TEST_DIR "${CMAKE_SOURCE_DIR}/tests") -endif() - -# src/upnp -set_source_files_properties("${CMAKE_SOURCE_DIR}/src/upnp.cpp" - DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" - PROPERTIES COMPILE_FLAGS -Wno-pedantic) - -# GNU/MinGW needs bigobj for confighttp.cpp (exceeds COFF section limit) -if(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") - set_source_files_properties("${CMAKE_SOURCE_DIR}/src/confighttp.cpp" - DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" - PROPERTIES COMPILE_FLAGS "-Wa,-mbig-obj") -endif() - -# third-party/nanors -set_source_files_properties("${CMAKE_SOURCE_DIR}/src/rswrapper.c" - DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" - PROPERTIES COMPILE_FLAGS "-ftree-vectorize -funroll-loops") - -# third-party/ViGEmClient -set(VIGEM_COMPILE_FLAGS "") -string(APPEND VIGEM_COMPILE_FLAGS "-Wno-unknown-pragmas ") -string(APPEND VIGEM_COMPILE_FLAGS "-Wno-misleading-indentation ") -string(APPEND VIGEM_COMPILE_FLAGS "-Wno-class-memaccess ") -string(APPEND VIGEM_COMPILE_FLAGS "-Wno-unused-function ") -string(APPEND VIGEM_COMPILE_FLAGS "-Wno-unused-variable ") -set_source_files_properties("${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/src/ViGEmClient.cpp" - DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" - PROPERTIES - COMPILE_DEFINITIONS "UNICODE=1;ERROR_INVALID_DEVICE_OBJECT_PARAMETER=650" - COMPILE_FLAGS ${VIGEM_COMPILE_FLAGS}) - -# src/nvhttp -string(TOUPPER "x${CMAKE_BUILD_TYPE}" BUILD_TYPE) -if("${BUILD_TYPE}" STREQUAL "XDEBUG") - if(WIN32) - if (NOT BUILD_TESTS) - set_source_files_properties("${CMAKE_SOURCE_DIR}/src/nvhttp.cpp" - DIRECTORY "${CMAKE_SOURCE_DIR}" - PROPERTIES COMPILE_FLAGS -O2) - else() - set_source_files_properties("${CMAKE_SOURCE_DIR}/src/nvhttp.cpp" - DIRECTORY "${CMAKE_SOURCE_DIR}" "${CMAKE_SOURCE_DIR}/tests" - PROPERTIES COMPILE_FLAGS -O2) - endif() - endif() -else() - add_definitions(-DNDEBUG) -endif() +# common target definitions +# this file will also load platform specific macros + +add_executable(sunshine ${SUNSHINE_TARGET_FILES}) +foreach(dep ${SUNSHINE_TARGET_DEPENDENCIES}) + add_dependencies(sunshine ${dep}) # compile these before sunshine +endforeach() + +# platform specific target definitions +if(WIN32) + include(${CMAKE_MODULE_PATH}/targets/windows.cmake) +elseif(UNIX) + include(${CMAKE_MODULE_PATH}/targets/unix.cmake) + + if(APPLE) + include(${CMAKE_MODULE_PATH}/targets/macos.cmake) + else() + include(${CMAKE_MODULE_PATH}/targets/linux.cmake) + endif() +endif() + +# todo - is this necessary? ... for anything except linux? +if(NOT DEFINED CMAKE_CUDA_STANDARD) + set(CMAKE_CUDA_STANDARD 17) + set(CMAKE_CUDA_STANDARD_REQUIRED ON) +endif() + +target_link_libraries(sunshine ${SUNSHINE_EXTERNAL_LIBRARIES} ${EXTRA_LIBS}) +target_compile_definitions(sunshine PUBLIC ${SUNSHINE_DEFINITIONS}) + +# Logging integration flags are provided via SUNSHINE_DEFINITIONS to avoid duplicates +set_target_properties(sunshine PROPERTIES CXX_STANDARD 23 + VERSION ${PROJECT_VERSION} + SOVERSION ${PROJECT_VERSION_MAJOR}) + +# CLion complains about unknown flags after running cmake, and cannot add symbols to the index for cuda files +if(CUDA_INHERIT_COMPILE_OPTIONS) + foreach(flag IN LISTS SUNSHINE_COMPILE_OPTIONS) + list(APPEND SUNSHINE_COMPILE_OPTIONS_CUDA "$<$:--compiler-options=${flag}>") + endforeach() +endif() + +target_compile_options(sunshine PRIVATE $<$:${SUNSHINE_COMPILE_OPTIONS}>;$<$:${SUNSHINE_COMPILE_OPTIONS_CUDA};-std=c++17>) # cmake-lint: disable=C0301 + +# Homebrew build fails the vite build if we set these environment variables +if(${SUNSHINE_BUILD_HOMEBREW}) + set(NPM_SOURCE_ASSETS_DIR "") + set(NPM_ASSETS_DIR "") + set(NPM_BUILD_HOMEBREW "true") +else() + set(NPM_SOURCE_ASSETS_DIR ${SUNSHINE_SOURCE_ASSETS_DIR}) + set(NPM_ASSETS_DIR ${CMAKE_BINARY_DIR}) + set(NPM_BUILD_HOMEBREW "") +endif() + +# Web UI source dir (where package.json lives) +# Default layout: ${CMAKE_SOURCE_DIR}/src_assets/common/assets/web +set(WEB_UI_DIR "${SUNSHINE_SOURCE_ASSETS_DIR}/common/assets/web") + +#WebUI build +if(WIN32) + unset(NODE CACHE) + unset(NPM_CLI CACHE) + find_program(NODE node.exe REQUIRED) + find_file(NPM_CLI npm-cli.js + PATHS + "${CMAKE_PREFIX_PATH}" + "C:/msys64/ucrt64/lib/node_modules/npm/bin" + REQUIRED) + set(NPM "${NODE}" "${NPM_CLI}") +else() + find_program(NPM npm REQUIRED) +endif() + +set(NPM_INSTALL_FLAGS + --ignore-scripts + --no-audit + --no-fund + --loglevel=error +) +if (NPM_OFFLINE) + list(APPEND NPM_INSTALL_FLAGS --offline) +endif() + +# Choose web UI build mode based on active CMake configuration. +# In Debug config, build Vite in "debug" mode to enable Vue devtools. +# In other configs, build production assets. +set(NPM_BUILD_COMMAND_RUN "run") +set(NPM_BUILD_COMMAND_ARG "$,build:debug,build>") +set(NPM_BUILD_ENV "$,NODE_ENV=development,>") + +# Some Node versions support enabling source-map support; keep empty if not needed +set(NPM_BUILD_NODE_OPTIONS "") + +add_custom_target(web-ui ALL + WORKING_DIRECTORY "${WEB_UI_DIR}" + COMMENT "Installing NPM dependencies and building the Web UI" + COMMAND "$<$:cmd;/C>" "${NPM}" ci ${NPM_INSTALL_FLAGS} + COMMAND "${CMAKE_COMMAND}" -E env + "SUNSHINE_BUILD_HOMEBREW=${NPM_BUILD_HOMEBREW}" + "SUNSHINE_SOURCE_ASSETS_DIR=${NPM_SOURCE_ASSETS_DIR}" + "SUNSHINE_ASSETS_DIR=${NPM_ASSETS_DIR}" + "${NPM_BUILD_ENV}" + "${NPM_BUILD_NODE_OPTIONS}" + "$<$:cmd;/C>" "${NPM}" ${NPM_BUILD_COMMAND_RUN} ${NPM_BUILD_COMMAND_ARG} # cmake-lint: disable=C0301 + COMMAND_EXPAND_LISTS + VERBATIM) + +# docs +if(BUILD_DOCS) + add_subdirectory(third-party/doxyconfig docs) +endif() + +# tests +if(BUILD_TESTS) + add_subdirectory(tests) +endif() + +# custom compile flags, must be after adding tests + +if (NOT BUILD_TESTS) + set(TEST_DIR "") +else() + set(TEST_DIR "${CMAKE_SOURCE_DIR}/tests") +endif() + +# src/upnp +set_source_files_properties("${CMAKE_SOURCE_DIR}/src/upnp.cpp" + DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" + PROPERTIES COMPILE_FLAGS -Wno-pedantic) + +# GNU/MinGW needs bigobj for confighttp.cpp (exceeds COFF section limit) +if(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + set_source_files_properties("${CMAKE_SOURCE_DIR}/src/confighttp.cpp" + DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" + PROPERTIES COMPILE_FLAGS "-Wa,-mbig-obj") +endif() + +# third-party/nanors +set_source_files_properties("${CMAKE_SOURCE_DIR}/src/rswrapper.c" + DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" + PROPERTIES COMPILE_FLAGS "-ftree-vectorize -funroll-loops") + +# third-party/ViGEmClient +set(VIGEM_COMPILE_FLAGS "") +string(APPEND VIGEM_COMPILE_FLAGS "-Wno-unknown-pragmas ") +string(APPEND VIGEM_COMPILE_FLAGS "-Wno-misleading-indentation ") +string(APPEND VIGEM_COMPILE_FLAGS "-Wno-class-memaccess ") +string(APPEND VIGEM_COMPILE_FLAGS "-Wno-unused-function ") +string(APPEND VIGEM_COMPILE_FLAGS "-Wno-unused-variable ") +set_source_files_properties("${CMAKE_SOURCE_DIR}/third-party/ViGEmClient/src/ViGEmClient.cpp" + DIRECTORY "${CMAKE_SOURCE_DIR}" "${TEST_DIR}" + PROPERTIES + COMPILE_DEFINITIONS "UNICODE=1;ERROR_INVALID_DEVICE_OBJECT_PARAMETER=650" + COMPILE_FLAGS ${VIGEM_COMPILE_FLAGS}) + +# src/nvhttp +string(TOUPPER "x${CMAKE_BUILD_TYPE}" BUILD_TYPE) +if("${BUILD_TYPE}" STREQUAL "XDEBUG") + if(WIN32) + if (NOT BUILD_TESTS) + set_source_files_properties("${CMAKE_SOURCE_DIR}/src/nvhttp.cpp" + DIRECTORY "${CMAKE_SOURCE_DIR}" + PROPERTIES COMPILE_FLAGS -O2) + else() + set_source_files_properties("${CMAKE_SOURCE_DIR}/src/nvhttp.cpp" + DIRECTORY "${CMAKE_SOURCE_DIR}" "${CMAKE_SOURCE_DIR}/tests" + PROPERTIES COMPILE_FLAGS -O2) + endif() + endif() +else() + add_definitions(-DNDEBUG) +endif() diff --git a/docs/configuration.md b/docs/configuration.md index 0669a9a59..0e46aa73e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -851,6 +851,79 @@ editing the `conf` file in a text editor. Use the examples as reference. +### mic_backend + + + + + + + + + + + + + + +
Description + Select how Vibepollo exposes redirected client microphone audio on Windows. + In this fork, Windows microphone redirection is standardized on the `steam_streaming_microphone` backend. + Vibepollo renders decoded client microphone audio into the Steam playback endpoint, and host applications + should select the paired `Microphone (Steam Streaming Microphone)` recording device. + @note{This option is currently only used on Windows hosts.} +
Default@code{} + steam_streaming_microphone + @endcode
Example@code{} + mic_backend = steam_streaming_microphone + @endcode
+ +### mic_device + + + + + + + + + + + + + + +
Description + The host-side device used for redirected client microphone audio. + On Windows, Vibepollo currently auto-detects the Steam Streaming Microphone render endpoint and this value is typically left unset. + On Linux and macOS this should point at the virtual device Vibepollo writes into. +
DefaultUnset.
Example (Linux)@code{} + mic_device = sunshine-mic + @endcode
+ +### stream_mic + + + + + + + + + + + + + + +
Description + Whether Vibepollo should accept redirected client microphone audio and inject it into a host-side microphone backend. +
Default@code{} + disabled + @endcode
Example@code{} + stream_mic = enabled + @endcode
+ ### install_steam_audio_drivers diff --git a/docs/remote_microphone.md b/docs/remote_microphone.md new file mode 100644 index 000000000..8ba315ef1 --- /dev/null +++ b/docs/remote_microphone.md @@ -0,0 +1,75 @@ +# Remote Microphone Support + +This feature adds a working host-side remote microphone path for Vibepollo, focused on Windows hosts and Steam Streaming Microphone integration. + +## Overview + +The microphone path is: + +1. A compatible Moonlight or Artemis client captures local microphone audio. +2. The client sends encrypted or unencrypted microphone packets to Vibepollo on the dedicated microphone stream. +3. Vibepollo receives the packets, decrypts them when needed, and decodes the Opus frames on the host. +4. Vibepollo renders the decoded PCM into the Steam playback endpoint `Speakers (Steam Streaming Microphone)`. +5. Host applications consume that audio from the paired capture endpoint `Microphone (Steam Streaming Microphone)`. + +This keeps the host-side application flow simple: Vibepollo writes into Steam Streaming Microphone, and games, chat apps, or capture tools use `Microphone (Steam Streaming Microphone)` as the microphone. + +## What Changed + +The working implementation includes: + +- Dedicated microphone session handling in the stream path, including packet receive, optional decryption, and per-session lifecycle management. +- Windows microphone backend initialization and teardown that stays alive for the full remote microphone session. +- A Steam-backed Windows microphone path that auto-detects the Steam microphone render/capture pair, normalizes only that pair to `2ch, 32-bit, 48000 Hz` when microphone streaming starts, decodes Opus microphone frames as mono float `48 kHz`, and writes them into the Steam microphone render buffer using a `float32` shared-mode render client. +- Host-side recovery for recoverable WASAPI failures such as device invalidation or audio service restarts. +- A Remote Microphone Debug panel in the troubleshooting UI that shows packet arrival, decode status, render status, signal detection, counters, and recent mic events. + +## Key Files + +- `src/stream.cpp`: microphone socket handling, session startup/shutdown, and packet routing. +- `src/audio.cpp`: shared microphone debug state and persistent audio context ownership for the redirect device. +- `src/platform/windows/audio.cpp`: Windows microphone backend selection and redirect device ownership. +- `src/platform/windows/vibepollo_vmic.cpp`: Steam Streaming Microphone backend wrapper. +- `src/platform/windows/mic_write.cpp`: device discovery, WASAPI initialization, Opus decode, and Steam Streaming Microphone rendering. +- `src_assets/common/assets/web/Troubleshooting.vue`: Remote Microphone Debug UI. + +## Windows Requirements + +- Install the Steam audio drivers on the host. +- Ensure the playback endpoint `Speakers (Steam Streaming Microphone)` exists and is enabled. +- In host applications, select `Microphone (Steam Streaming Microphone)` as the microphone/recording source. +- Enable `stream_mic` in Vibepollo. +- Use a client build that supports microphone redirection and encrypted microphone transport. + +## Compatible Client Builds + +Microphone passthrough requires matching client support: + +- Desktop: [logabell/moonlight-qt-mic](https://github.com/logabell/moonlight-qt-mic) +- Android (Artemis): [logabell/moonlight-android](https://github.com/logabell/moonlight-android) +- Shared protocol library: [logabell/moonlight-common-c-mic](https://github.com/logabell/moonlight-common-c-mic) + +## Configuration Notes + +- `stream_mic` enables the host microphone redirect path. +- `mic_backend` defaults to `steam_streaming_microphone` on Windows in this fork. +- On Windows, Vibepollo auto-detects the Steam Streaming Microphone pair and normalizes only those microphone endpoints to `2ch, 32-bit, 48000 Hz` automatically instead of requiring a manual device-properties change. +- `mic_device` is mainly relevant on non-Windows platforms. The Windows path currently targets Steam Streaming Microphone automatically. +- Redirected microphone transport is always required to negotiate encrypted microphone packets. If the client falls back to plaintext microphone transport, Apollo disables microphone passthrough for that session instead of accepting unencrypted microphone packets. + +## Debugging + +The Troubleshooting page on Windows exposes a Remote Microphone Debug panel that shows: + +- whether the client is sending packets +- whether Vibepollo is decoding microphone frames +- whether Vibepollo is rendering into Steam Streaming Microphone +- whether non-silent input is being detected +- which endpoint mix format Vibepollo discovered +- which render and capture device formats are currently active +- which render format Vibepollo actually initialized +- whether the recommended Steam microphone format is active or had to be enforced +- how mono input is mapped to the host channels +- the most recent mic errors and recent mic events + +This view is intended to quickly separate client capture problems from host decode/render problems. diff --git a/src/audio.cpp b/src/audio.cpp index 716ed7bfa..18fff2e38 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -3,6 +3,10 @@ * @brief Definitions for audio capture and encoding. */ // standard includes +#include +#include +#include +#include #include // lib includes @@ -23,6 +27,52 @@ namespace audio { using opus_t = util::safe_ptr; using sample_queue_t = std::shared_ptr>>; + namespace { + struct mic_debug_state_t { + std::mutex mutex; + mic_debug_snapshot_t snapshot; + std::chrono::steady_clock::time_point last_packet_time {}; + std::chrono::steady_clock::time_point last_decode_time {}; + std::chrono::steady_clock::time_point last_render_time {}; + bool has_last_packet_time {false}; + bool has_last_decode_time {false}; + bool has_last_render_time {false}; + std::deque recent_events; + }; + + mic_debug_state_t &mic_debug_state() { + static mic_debug_state_t state; + return state; + } + + void append_mic_event(mic_debug_state_t &state, const std::string &message) { + const auto now = std::chrono::system_clock::now(); + const auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tm {}; +#ifdef _WIN32 + localtime_s(&tm, &tt); +#else + localtime_r(&tt, &tm); +#endif + char timestamp[16] {}; + std::strftime(timestamp, sizeof(timestamp), "%H:%M:%S", &tm); + state.recent_events.push_front(std::string {timestamp} + " " + message); + while (state.recent_events.size() > 12) { + state.recent_events.pop_back(); + } + } + + void set_mic_state_locked(mic_debug_state_t &state, const std::string &status) { + state.snapshot.state = status; + append_mic_event(state, status); + } + + audio_ctx_ref_t &mic_redirect_audio_ctx() { + static audio_ctx_ref_t ref; + return ref; + } + } // namespace + static int start_audio_control(audio_ctx_t &ctx); static void stop_audio_control(audio_ctx_t &); static void apply_surround_params(opus_stream_config_t &stream, const stream_params_t ¶ms); @@ -287,6 +337,249 @@ namespace audio { return ctx.control->is_sink_available(sink); } + int init_mic_redirect_device() { + auto &held_ref = mic_redirect_audio_ctx(); + if (!held_ref) { + held_ref = get_audio_ctx_ref(); + } + + auto &ref = held_ref; + if (!ref || !ref->control) { + mic_debug_on_backend_error("Audio control is unavailable; microphone redirection could not initialize"); + return -1; + } + + return ref->control->init_mic_redirect_device(); + } + + void release_mic_redirect_device() { + auto &ref = mic_redirect_audio_ctx(); + if (!ref || !ref->control) { + ref = {}; + return; + } + + ref->control->release_mic_redirect_device(); + ref = {}; + } + + int write_mic_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) { + auto &held_ref = mic_redirect_audio_ctx(); + auto ref = held_ref ? held_ref : get_audio_ctx_ref(); + if (!ref || !ref->control) { + BOOST_LOG(warning) << "Client microphone packet rejected before decode because audio control is unavailable" + << " [seq=" << sequence_number << ", ts=" << timestamp << ", len=" << len << ']'; + mic_debug_on_packet_dropped(sequence_number, "Audio control is unavailable while writing microphone data"); + return -1; + } + + return ref->control->write_mic_data(data, len, sequence_number, timestamp); + } + + mic_debug_snapshot_t get_mic_debug_snapshot() { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + + auto snapshot = state.snapshot; + const auto now = std::chrono::steady_clock::now(); + if (state.has_last_packet_time) { + snapshot.last_packet_age_ms = std::chrono::duration_cast(now - state.last_packet_time).count(); + } + if (state.has_last_decode_time) { + snapshot.last_decode_age_ms = std::chrono::duration_cast(now - state.last_decode_time).count(); + } + if (state.has_last_render_time) { + snapshot.last_render_age_ms = std::chrono::duration_cast(now - state.last_render_time).count(); + } + snapshot.recent_events.assign(state.recent_events.begin(), state.recent_events.end()); + snapshot.signal_detected = snapshot.last_input_level >= 0.02 && snapshot.last_decode_age_ms >= 0 && snapshot.last_decode_age_ms < 3000; + return snapshot; + } + + void mic_debug_on_session_start(const std::string &client_name, bool encryption_enabled) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot = {}; + state.snapshot.session_active = true; + state.snapshot.mic_requested = true; + state.snapshot.encryption_enabled = encryption_enabled; + state.snapshot.client_name = client_name; + state.snapshot.state = "Microphone redirection negotiated; waiting for client audio"; + state.snapshot.last_packet_age_ms = -1; + state.snapshot.last_decode_age_ms = -1; + state.snapshot.last_render_age_ms = -1; + state.has_last_packet_time = false; + state.has_last_decode_time = false; + state.has_last_render_time = false; + state.recent_events.clear(); + append_mic_event(state, "Microphone redirection negotiated for client [" + client_name + "]"); + } + + void mic_debug_on_session_stop(const std::string &reason) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.session_active = false; + state.snapshot.decode_active = false; + state.snapshot.render_active = false; + state.snapshot.signal_detected = false; + state.snapshot.state = reason.empty() ? "No active remote microphone session" : reason; + append_mic_event(state, reason.empty() ? "Remote microphone session ended" : reason); + } + + void mic_debug_on_backend_initialized(const std::string &backend_name) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.backend_initialized = true; + state.snapshot.backend_name = backend_name; + state.snapshot.last_error.clear(); + append_mic_event(state, "Microphone backend ready: " + backend_name); + } + + void mic_debug_on_backend_target(const std::string &target_device_name, int channels, std::uint32_t sample_rate) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.target_device_name = target_device_name; + state.snapshot.state = "Rendering client microphone into " + target_device_name; + append_mic_event(state, "Using host render target [" + target_device_name + "] at " + std::to_string(channels) + "ch/" + std::to_string(sample_rate) + "Hz"); + } + + void mic_debug_on_backend_format(const std::string &endpoint_mix_format, + const std::string &render_format, + bool resampling_active, + const std::string &channel_mapping) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.endpoint_mix_format = endpoint_mix_format; + state.snapshot.render_format = render_format; + state.snapshot.resampling_active = resampling_active; + state.snapshot.channel_mapping = channel_mapping; + append_mic_event( + state, + "Endpoint mix format [" + endpoint_mix_format + "], render format [" + render_format + + "], resampling " + (resampling_active ? "enabled" : "disabled") + ); + } + + void mic_debug_on_backend_endpoint_formats(const std::string &render_device_format, + const std::string &capture_device_name, + const std::string &capture_endpoint_mix_format, + const std::string &capture_device_format, + bool recommended_format_enforced, + bool recommended_format_active) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.render_device_format = render_device_format; + state.snapshot.capture_device_name = capture_device_name; + state.snapshot.capture_endpoint_mix_format = capture_endpoint_mix_format; + state.snapshot.capture_device_format = capture_device_format; + state.snapshot.recommended_format_enforced = recommended_format_enforced; + state.snapshot.recommended_format_active = recommended_format_active; + append_mic_event( + state, + "Render device format [" + render_device_format + "], capture device [" + capture_device_name + + "], capture mix [" + capture_endpoint_mix_format + "], recommended format " + + (recommended_format_active ? "active" : "inactive") + + (recommended_format_enforced ? " (enforced)" : "") + ); + } + + void mic_debug_on_backend_error(const std::string &message) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.last_error = message; + state.snapshot.render_active = false; + state.snapshot.state = message; + append_mic_event(state, message); + } + + void mic_debug_on_packet_received(std::uint16_t sequence_number, std::size_t payload_len) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.first_packet_received = true; + state.snapshot.packets_received++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_payload_size = payload_len; + state.last_packet_time = std::chrono::steady_clock::now(); + state.has_last_packet_time = true; + if (state.snapshot.packets_received == 1) { + set_mic_state_locked(state, "Receiving microphone packets from Moonlight"); + } + } + + void mic_debug_on_packet_decrypt_error(std::uint16_t sequence_number, const std::string &message) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.decrypt_errors++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_error = message; + state.snapshot.state = message; + append_mic_event(state, message); + } + + void mic_debug_on_packet_dropped(std::uint16_t sequence_number, const std::string &message) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.packets_dropped++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_error = message; + state.snapshot.state = message; + append_mic_event(state, message); + } + + void mic_debug_on_packet_decoded(std::uint16_t sequence_number, double normalized_level, bool silent) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.decode_active = true; + state.snapshot.packets_decoded++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_input_level = normalized_level; + state.snapshot.last_error.clear(); + if (silent) { + state.snapshot.silent_packets++; + } + state.last_decode_time = std::chrono::steady_clock::now(); + state.has_last_decode_time = true; + if (state.snapshot.packets_decoded == 1) { + set_mic_state_locked(state, "Apollo decoded microphone audio from Moonlight"); + } + } + + void mic_debug_on_packet_rendered(std::uint16_t sequence_number, double normalized_level, bool silent) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.packets_rendered++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_render_level = normalized_level; + state.snapshot.render_active = true; + state.snapshot.last_error.clear(); + state.last_render_time = std::chrono::steady_clock::now(); + state.has_last_render_time = true; + if (state.snapshot.packets_rendered == 1) { + set_mic_state_locked(state, "Apollo is rendering microphone audio into Steam Streaming Microphone"); + } + } + + void mic_debug_on_decode_error(std::uint16_t sequence_number, const std::string &message) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.decode_errors++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_error = message; + state.snapshot.state = message; + append_mic_event(state, message); + } + + void mic_debug_on_render_error(std::uint16_t sequence_number, const std::string &message) { + auto &state = mic_debug_state(); + std::lock_guard lock(state.mutex); + state.snapshot.render_errors++; + state.snapshot.last_sequence_number = sequence_number; + state.snapshot.last_error = message; + state.snapshot.render_active = false; + state.snapshot.state = message; + append_mic_event(state, message); + } + int map_stream(int channels, bool quality) { int shift = quality ? 1 : 0; switch (channels) { diff --git a/src/audio.h b/src/audio.h index 1db35f17d..a1dfd2646 100644 --- a/src/audio.h +++ b/src/audio.h @@ -9,7 +9,11 @@ #include "thread_safe.h" #include "utility.h" +#include #include +#include +#include +#include namespace audio { enum stream_config_e : int { @@ -78,6 +82,48 @@ namespace audio { using packet_t = std::pair; using audio_ctx_ref_t = safe::shared_t::ptr_t; + struct mic_debug_snapshot_t { + bool session_active {}; + bool mic_requested {}; + bool encryption_enabled {}; + bool backend_initialized {}; + bool first_packet_received {}; + bool decode_active {}; + bool render_active {}; + bool signal_detected {}; + std::uint64_t packets_received {}; + std::uint64_t packets_decoded {}; + std::uint64_t packets_rendered {}; + std::uint64_t packets_dropped {}; + std::uint64_t decrypt_errors {}; + std::uint64_t decode_errors {}; + std::uint64_t render_errors {}; + std::uint64_t silent_packets {}; + std::uint16_t last_sequence_number {}; + std::size_t last_payload_size {}; + double last_input_level {}; + double last_render_level {}; + std::int64_t last_packet_age_ms {-1}; + std::int64_t last_decode_age_ms {-1}; + std::int64_t last_render_age_ms {-1}; + std::string client_name; + std::string backend_name; + std::string target_device_name; + std::string endpoint_mix_format; + std::string render_device_format; + std::string render_format; + std::string capture_device_name; + std::string capture_endpoint_mix_format; + std::string capture_device_format; + std::string channel_mapping; + std::string state; + std::string last_error; + bool resampling_active {}; + bool recommended_format_enforced {}; + bool recommended_format_active {}; + std::vector recent_events; + }; + void capture(safe::mail_t mail, config_t config, void *channel_data); /** @@ -107,4 +153,27 @@ namespace audio { * @examples_end */ bool is_audio_ctx_sink_available(const audio_ctx_t &ctx); + int init_mic_redirect_device(); + void release_mic_redirect_device(); + int write_mic_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp); + mic_debug_snapshot_t get_mic_debug_snapshot(); + void mic_debug_on_session_start(const std::string &client_name, bool encryption_enabled); + void mic_debug_on_session_stop(const std::string &reason = {}); + void mic_debug_on_backend_initialized(const std::string &backend_name); + void mic_debug_on_backend_target(const std::string &target_device_name, int channels, std::uint32_t sample_rate); + void mic_debug_on_backend_format(const std::string &endpoint_mix_format, const std::string &render_format, bool resampling_active, const std::string &channel_mapping); + void mic_debug_on_backend_endpoint_formats(const std::string &render_device_format, + const std::string &capture_device_name, + const std::string &capture_endpoint_mix_format, + const std::string &capture_device_format, + bool recommended_format_enforced, + bool recommended_format_active); + void mic_debug_on_backend_error(const std::string &message); + void mic_debug_on_packet_received(std::uint16_t sequence_number, std::size_t payload_len); + void mic_debug_on_packet_decrypt_error(std::uint16_t sequence_number, const std::string &message); + void mic_debug_on_packet_dropped(std::uint16_t sequence_number, const std::string &message); + void mic_debug_on_packet_decoded(std::uint16_t sequence_number, double normalized_level, bool silent); + void mic_debug_on_packet_rendered(std::uint16_t sequence_number, double normalized_level, bool silent); + void mic_debug_on_decode_error(std::uint16_t sequence_number, const std::string &message); + void mic_debug_on_render_error(std::uint16_t sequence_number, const std::string &message); } // namespace audio diff --git a/src/config.cpp b/src/config.cpp index 108415a83..009d996c6 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -838,7 +838,10 @@ namespace config { audio_t audio { {}, // audio_sink {}, // virtual_sink + "steam_streaming_microphone", // mic_backend + {}, // mic_device true, // stream audio + false, // stream microphone true, // install_steam_drivers true, // keep_sink_default true, // auto_capture @@ -1702,7 +1705,10 @@ namespace config { string_f(vars, "audio_sink", audio.sink); string_f(vars, "virtual_sink", audio.virtual_sink); + string_f(vars, "mic_backend", audio.mic_backend); + string_f(vars, "mic_device", audio.mic_device); bool_f(vars, "stream_audio", audio.stream); + bool_f(vars, "stream_mic", audio.stream_mic); bool_f(vars, "install_steam_audio_drivers", audio.install_steam_drivers); bool_f(vars, "keep_sink_default", audio.keep_default); bool_f(vars, "auto_capture_sink", audio.auto_capture); diff --git a/src/config.h b/src/config.h index a2bb82398..cc85cc6f8 100644 --- a/src/config.h +++ b/src/config.h @@ -189,7 +189,10 @@ namespace config { struct audio_t { std::string sink; std::string virtual_sink; + std::string mic_backend; + std::string mic_device; bool stream; + bool stream_mic; bool install_steam_drivers; bool keep_default; bool auto_capture; diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 9f851d62a..0ec0b0e38 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1,4998 +1,5055 @@ -/** - * @file src/confighttp.cpp - * @brief Definitions for the Web UI Config HTTPS server. - * - * @todo Authentication, better handling of routes common to nvhttp, cleanup - */ -#define BOOST_BIND_GLOBAL_PLACEHOLDERS - -// standard includes -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// lib includes -#include -#include -#include -#include -#include -#include -#include - -// local includes -#include "config.h" -#include "confighttp.h" -#include "crypto.h" -#include "file_handler.h" -#include "globals.h" -#include "http_auth.h" -#include "httpcommon.h" -#include "platform/common.h" -#ifdef _WIN32 - #include "src/platform/windows/image_convert.h" - -#endif -#include "logging.h" -#include "network.h" -#include "nvhttp.h" -#include "platform/common.h" -#include "rtsp.h" -#include "session_history.h" -#include "stream.h" -#include "host_stats.h" -#include "webrtc_stream.h" - -#ifdef _WIN32 - #include "platform/windows/virtual_display_cleanup.h" -#endif - -#include -#if defined(_WIN32) - #include "platform/windows/misc.h" - #include "src/platform/windows/ipc/misc_utils.h" - #include "src/platform/windows/playnite_integration.h" - #include "src/platform/windows/playnite_sync.h" - - #include -#endif -#ifdef uuid_t - #undef uuid_t -#endif -#if defined(_WIN32) - #include "platform/windows/misc.h" - - #include - #include - #include -#endif -#include "display_helper_integration.h" -#include "process.h" -#include "utility.h" -#include "uuid.h" - -#ifdef _WIN32 - #include "platform/windows/utils.h" -#endif - -using namespace std::literals; -namespace pt = boost::property_tree; - -namespace confighttp { - // Global MIME type lookup used for static file responses - const std::map mime_types = { - {"css", "text/css"}, - {"gif", "image/gif"}, - {"htm", "text/html"}, - {"html", "text/html"}, - {"ico", "image/x-icon"}, - {"jpeg", "image/jpeg"}, - {"jpg", "image/jpeg"}, - {"js", "application/javascript"}, - {"json", "application/json"}, - {"png", "image/png"}, - {"webp", "image/webp"}, - {"svg", "image/svg+xml"}, - {"ttf", "font/ttf"}, - {"txt", "text/plain"}, - {"woff2", "font/woff2"}, - {"xml", "text/xml"}, - }; - - // Helper: sort apps by their 'name' field, if present - static void sort_apps_by_name(nlohmann::json &file_tree) { - try { - if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { - return; - } - auto &apps_node = file_tree["apps"]; - std::sort(apps_node.begin(), apps_node.end(), [](const nlohmann::json &a, const nlohmann::json &b) { - try { - return a.at("name").get() < b.at("name").get(); - } catch (...) { - return false; - } - }); - } catch (...) {} - } - - bool refresh_client_apps_cache(nlohmann::json &file_tree, bool sort_by_name) { - try { - if (sort_by_name) { - sort_apps_by_name(file_tree); - } - file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); - proc::refresh(config::stream.file_apps, false); - return true; - } catch (const std::exception &e) { - BOOST_LOG(warning) << "refresh_client_apps_cache: failed: " << e.what(); - } catch (...) { - BOOST_LOG(warning) << "refresh_client_apps_cache: failed (unknown)"; - } - return false; - } - namespace fs = std::filesystem; - using enum confighttp::StatusCode; - - static std::string trim_copy(const std::string &input) { - auto begin = input.begin(); - auto end = input.end(); - while (begin != end && std::isspace(static_cast(*begin))) { - ++begin; - } - while (end != begin && std::isspace(static_cast(*(end - 1)))) { - --end; - } - return std::string {begin, end}; - } - - static bool file_is_regular(const fs::path &path) { - if (path.empty()) { - return false; - } - std::error_code ec; - return fs::exists(path, ec) && fs::is_regular_file(path, ec); - } - - static bool resolve_cover_path_for_uuid(const std::string &uuid, fs::path &out_path) { - if (uuid.empty()) { - return false; - } - - try { - std::string content = file_handler::read_file(config::stream.file_apps.c_str()); - nlohmann::json file_tree = nlohmann::json::parse(content); - if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { - return false; - } - - const fs::path cover_dir = fs::path(platf::appdata()) / "covers"; - const fs::path config_dir = fs::path(config::stream.file_apps).parent_path(); - const fs::path assets_dir = fs::path(SUNSHINE_ASSETS_DIR); - - for (const auto &entry : file_tree["apps"]) { - if (!entry.is_object()) { - continue; - } - if (!entry.contains("uuid") || !entry["uuid"].is_string()) { - continue; - } - if (entry["uuid"].get() != uuid) { - continue; - } - - std::string image_path; - if (entry.contains("image-path") && entry["image-path"].is_string()) { - image_path = entry["image-path"].get(); - } - std::string playnite_id; - if (entry.contains("playnite-id") && entry["playnite-id"].is_string()) { - playnite_id = entry["playnite-id"].get(); - } - - std::vector candidates; - std::unordered_set seen; - auto push_candidate = [&](fs::path candidate) { - if (candidate.empty()) { - return; - } - auto normalized = candidate.lexically_normal(); - std::string key = normalized.generic_string(); -#ifdef _WIN32 - std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); -#endif - if (!seen.insert(key).second) { - return; - } - candidates.emplace_back(std::move(normalized)); - }; - - auto trimmed = trim_copy(image_path); - auto normalized_path = trimmed; - std::replace(normalized_path.begin(), normalized_path.end(), '\\', '/'); - - if (!trimmed.empty()) { - fs::path direct(trimmed); - push_candidate(direct); - if (!direct.is_absolute()) { - if (!normalized_path.empty() && normalized_path.rfind("./", 0) == 0) { - fs::path rel(normalized_path.substr(2)); - push_candidate(config_dir / rel); - push_candidate(assets_dir / rel); - } - push_candidate(config_dir / direct); - push_candidate(assets_dir / direct); - if (normalized_path.rfind("covers/", 0) == 0) { - fs::path rel(normalized_path.substr(7)); - push_candidate(cover_dir / rel); - } - if (normalized_path.rfind("./covers/", 0) == 0) { - fs::path rel(normalized_path.substr(9)); - push_candidate(cover_dir / rel); - } - } - } - - static const std::array fallback_exts {".png", ".jpg", ".jpeg", ".webp"}; - for (const char *ext : fallback_exts) { - push_candidate(cover_dir / (uuid + ext)); - } - if (!playnite_id.empty()) { - push_candidate(cover_dir / (std::string("playnite_") + playnite_id + ".png")); - } - - for (const auto &candidate : candidates) { - if (file_is_regular(candidate)) { - out_path = candidate; - return true; - } - } - - fs::path fallback = assets_dir / "box.png"; - if (file_is_regular(fallback)) { - out_path = fallback; - return true; - } - - return false; - } - } catch (const std::exception &e) { - BOOST_LOG(warning) << "resolve_cover_path_for_uuid: failed for uuid '" << uuid << "': " << e.what(); - } catch (...) { - BOOST_LOG(warning) << "resolve_cover_path_for_uuid: failed for uuid '" << uuid << "': unknown error"; - } - return false; - } - - using https_server_t = SimpleWeb::Server; - using args_t = SimpleWeb::CaseInsensitiveMultimap; - using resp_https_t = std::shared_ptr::Response>; - using req_https_t = std::shared_ptr::Request>; - - bool is_token_route_eligible(std::string_view path) { - return path.rfind("/api/", 0) == 0 && path.rfind("/api/auth/", 0) != 0; - } - - std::vector ordered_methods_for_catalog(const std::set> &methods) { - static constexpr std::array preferred_order = { - "GET", - "POST", - "PUT", - "PATCH", - "DELETE" - }; - - std::vector ordered; - ordered.reserve(methods.size()); - - for (const auto method : preferred_order) { - if (methods.contains(std::string(method))) { - ordered.emplace_back(method); - } - } - - for (const auto &method : methods) { - if (std::find(preferred_order.begin(), preferred_order.end(), method) == preferred_order.end()) { - ordered.push_back(method); - } - } - - return ordered; - } - - namespace { - using token_route_methods_t = std::map>, std::less<>>; - - std::mutex token_route_catalog_mutex; - token_route_methods_t token_route_catalog; - - std::string normalize_route_pattern(std::string pattern) { - if (!pattern.empty() && pattern.front() == '^') { - pattern.erase(pattern.begin()); - } - if (!pattern.empty() && pattern.back() == '$') { - pattern.pop_back(); - } - return pattern; - } - - void clear_token_route_catalog() { - std::scoped_lock lock(token_route_catalog_mutex); - token_route_catalog.clear(); - } - - void record_token_route(std::string path, std::string method) { - if (!is_token_route_eligible(path)) { - return; - } - boost::to_upper(method); - std::scoped_lock lock(token_route_catalog_mutex); - token_route_catalog[std::move(path)].insert(std::move(method)); - } - - token_route_methods_t snapshot_token_route_catalog() { - std::scoped_lock lock(token_route_catalog_mutex); - return token_route_catalog; - } - - bool has_active_stream_sessions() { - return rtsp_stream::session_count() > 0 || webrtc_stream::has_active_sessions(); - } - - bool can_hot_apply_during_session(const std::set &keys) { - if (keys.empty()) { - return false; - } - - for (const auto &key : keys) { - if (key.rfind("playnite_", 0) == 0) { - continue; - } - - if (key == "session_history_enabled") { - return false; - } - - if (key == "session_history_ttl_days" || - key == "session_history_db_size_limit_mb") { - continue; - } - - return false; - } - - return true; - } - - } // namespace - - // Forward declaration for error helper implemented later - void bad_request(resp_https_t response, req_https_t request, const std::string &error_message); - void getAppCover(resp_https_t response, req_https_t request); - -#ifdef _WIN32 - // Forward declarations for Playnite handlers implemented in confighttp_playnite.cpp - void getPlayniteStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void installPlaynite(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void uninstallPlaynite(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void getPlayniteGames(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void getPlayniteCategories(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void postPlayniteForceSync(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void postPlayniteLaunch(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - // Helper to keep confighttp.cpp free of Playnite details - void enhance_app_with_playnite_cover(nlohmann::json &input_tree); - // New: download Playnite-related logs as a ZIP - - // RTSS status endpoint (Windows-only) - void getRtssStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void getLosslessScalingStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void downloadPlayniteLogs(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void getCrashDumpStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void postCrashDumpDismiss(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void getCrashBundleManifest(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - void downloadCrashBundle(std::shared_ptr::Response> response, std::shared_ptr::Request> request); - // Display helper: export current OS state as golden restore snapshot - void postExportGoldenDisplay(resp_https_t response, req_https_t request); - // Helper log readers (Windows-only) - bool is_helper_log_source(const std::string &source); - bool read_helper_log(const std::string &source, std::string &out); -#endif - - enum class op_e { - ADD, ///< Add client - REMOVE ///< Remove client - }; - - // SESSION COOKIE - std::string sessionCookie; - static std::chrono::time_point cookie_creation_time; - - /** - * @brief Log the request details. - * @param request The HTTP request object. - */ - void print_req(const req_https_t &request) { - BOOST_LOG(debug) << "HTTP "sv << request->method << ' ' << request->path; - - if (!request->header.empty()) { - BOOST_LOG(verbose) << "Headers:"sv; - for (auto &[name, val] : request->header) { - BOOST_LOG(verbose) << name << " -- " - << (name == "Authorization" ? "CREDENTIALS REDACTED" : val); - } - } - - auto query = request->parse_query_string(); - if (!query.empty()) { - BOOST_LOG(verbose) << "Query Params:"sv; - for (auto &[name, val] : query) { - BOOST_LOG(verbose) << name << " -- " << val; - } - } - } - - /** - * @brief Get the CORS origin for localhost (no wildcard). - * @return The CORS origin string. - */ - static std::string get_cors_origin() { - std::uint16_t https_port = net::map_port(PORT_HTTPS); - return std::format("https://localhost:{}", https_port); - } - - /** - * @brief Helper to add CORS headers for API responses. - * @param headers The headers to add CORS to. - */ - void add_cors_headers(SimpleWeb::CaseInsensitiveMultimap &headers) { - headers.emplace("Access-Control-Allow-Origin", get_cors_origin()); - headers.emplace("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - headers.emplace("Access-Control-Allow-Headers", "Content-Type, Authorization"); - } - - /** - * @brief Send a response. - * @param response The HTTP response object. - * @param output_tree The JSON tree to send. - */ - void send_response(resp_https_t response, const nlohmann::json &output_tree) { - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json; charset=utf-8"); - add_cors_headers(headers); - response->write(success_ok, output_tree.dump(), headers); - } - - nlohmann::json load_webrtc_ice_servers() { - auto env = std::getenv("SUNSHINE_WEBRTC_ICE_SERVERS"); - if (!env || !*env) { - return nlohmann::json::array(); - } - - try { - auto parsed = nlohmann::json::parse(env); - if (parsed.is_array()) { - return parsed; - } - } catch (const std::exception &e) { - BOOST_LOG(warning) << "WebRTC: invalid SUNSHINE_WEBRTC_ICE_SERVERS: "sv << e.what(); - } - - return nlohmann::json::array(); - } - - nlohmann::json webrtc_session_to_json(const webrtc_stream::SessionState &state) { - nlohmann::json output; - output["id"] = state.id; - output["audio"] = state.audio; - output["video"] = state.video; - output["encoded"] = state.encoded; - output["audio_packets"] = state.audio_packets; - output["video_packets"] = state.video_packets; - output["audio_dropped"] = state.audio_dropped; - output["video_dropped"] = state.video_dropped; - output["audio_queue_frames"] = state.audio_queue_frames; - output["video_queue_frames"] = state.video_queue_frames; - output["video_inflight_frames"] = state.video_inflight_frames; - output["has_remote_offer"] = state.has_remote_offer; - output["has_local_answer"] = state.has_local_answer; - output["ice_candidates"] = state.ice_candidates; - output["width"] = state.width ? nlohmann::json(*state.width) : nlohmann::json(nullptr); - output["height"] = state.height ? nlohmann::json(*state.height) : nlohmann::json(nullptr); - output["fps"] = state.fps ? nlohmann::json(*state.fps) : nlohmann::json(nullptr); - output["bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); - // WebRTC has no FEC/audio adjustment, so the requested bitrate is the same as the encoder bitrate. - output["requested_bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); - output["encoder_bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); - output["codec"] = state.codec ? nlohmann::json(stream::canonical_codec_name(*state.codec)) : nlohmann::json(nullptr); - output["hdr"] = state.hdr ? nlohmann::json(*state.hdr) : nlohmann::json(nullptr); - output["yuv444"] = state.yuv444 ? nlohmann::json(*state.yuv444) : nlohmann::json(false); - output["stream_gpu_model"] = state.stream_gpu_model ? nlohmann::json(*state.stream_gpu_model) : nlohmann::json(nullptr); - output["audio_channels"] = state.audio_channels ? nlohmann::json(*state.audio_channels) : nlohmann::json(nullptr); - output["audio_codec"] = state.audio_codec ? nlohmann::json(*state.audio_codec) : nlohmann::json(nullptr); - output["profile"] = state.profile ? nlohmann::json(*state.profile) : nlohmann::json(nullptr); - output["video_pacing_mode"] = state.video_pacing_mode ? nlohmann::json(*state.video_pacing_mode) : nlohmann::json(nullptr); - output["video_pacing_slack_ms"] = state.video_pacing_slack_ms ? nlohmann::json(*state.video_pacing_slack_ms) : nlohmann::json(nullptr); - output["video_max_frame_age_ms"] = state.video_max_frame_age_ms ? nlohmann::json(*state.video_max_frame_age_ms) : nlohmann::json(nullptr); - output["last_audio_bytes"] = state.last_audio_bytes; - output["last_video_bytes"] = state.last_video_bytes; - output["video_bytes_total"] = state.video_bytes_total; - output["audio_bytes_total"] = state.audio_bytes_total; - output["bytes_sent"] = state.video_bytes_total + state.audio_bytes_total; - output["last_video_idr"] = state.last_video_idr; - output["last_video_frame_index"] = state.last_video_frame_index; - - auto now = std::chrono::steady_clock::now(); - auto age_or_null = [&now](const std::optional &tp) -> nlohmann::json { - if (!tp) { - return nullptr; - } - return std::chrono::duration_cast(now - *tp).count(); - }; - - output["last_audio_age_ms"] = age_or_null(state.last_audio_time); - output["last_video_age_ms"] = age_or_null(state.last_video_time); - return output; - } - - double round_to(double value, double factor) { - return std::round(value * factor) / factor; - } - - nlohmann::json rtsp_session_to_json(const stream::session_info_t &info) { - nlohmann::json output; - output["uuid"] = info.uuid; - output["device_name"] = info.device_name; - output["width"] = info.width; - output["height"] = info.height; - output["fps"] = info.fps; - output["encoder_bitrate_kbps"] = info.encoder_bitrate_kbps; - output["requested_bitrate_kbps"] = info.requested_bitrate_kbps; - output["video_format"] = info.video_format; - output["codec"] = stream::canonical_codec_name(stream::video_format_name(info.video_format)); - output["hdr"] = info.dynamic_range != 0; - output["yuv444"] = info.yuv444; - output["audio_channels"] = info.audio_channels; - output["stream_gpu_model"] = info.stream_gpu_model; - output["state"] = info.state; - output["frames_sent"] = info.frames_sent; - output["packets_sent"] = info.packets_sent; - output["bytes_sent"] = info.bytes_sent; - output["idr_requests"] = info.idr_requests; - output["invalidate_ref_count"] = info.invalidate_ref_count; - output["client_reported_losses"] = info.client_reported_losses; - output["encode_latency_ms"] = round_to(info.encode_latency_ms, 10.0); - output["last_frame_index"] = info.last_frame_index; - output["uptime_seconds"] = round_to(info.uptime_seconds, 10.0); - return output; - } - - nlohmann::json host_stats_to_json(const platf::host_stats_t &stats) { - nlohmann::json output; - output["cpu_percent"] = stats.cpu_percent; - output["cpu_temp_c"] = stats.cpu_temp_c; - output["ram_used_bytes"] = stats.ram_used_bytes; - output["ram_total_bytes"] = stats.ram_total_bytes; - output["ram_percent"] = stats.ram_total_bytes > 0 - ? (static_cast(stats.ram_used_bytes) * 100.0 / - static_cast(stats.ram_total_bytes)) - : 0.0; - output["gpu_percent"] = stats.gpu_percent; - output["gpu_encoder_percent"] = stats.gpu_encoder_percent; - output["gpu_temp_c"] = stats.gpu_temp_c; - const auto vram_used_bytes = - stats.vram_total_bytes > 0 && stats.vram_used_bytes > stats.vram_total_bytes ? - stats.vram_total_bytes : - stats.vram_used_bytes; - output["vram_used_bytes"] = vram_used_bytes; - output["vram_total_bytes"] = stats.vram_total_bytes; - output["vram_percent"] = stats.vram_total_bytes > 0 - ? (static_cast(vram_used_bytes) * 100.0 / - static_cast(stats.vram_total_bytes)) - : 0.0; - output["net_rx_bps"] = stats.net_rx_bps; - output["net_tx_bps"] = stats.net_tx_bps; - return output; - } - - nlohmann::json host_info_to_json(const platf::host_info_t &info) { - nlohmann::json output; - output["cpu_model"] = info.cpu_model; - output["gpu_model"] = info.gpu_model; - output["cpu_logical_cores"] = info.cpu_logical_cores; - output["ram_total_bytes"] = info.ram_total_bytes; - output["vram_total_bytes"] = info.vram_total_bytes; - output["net_interface"] = info.net_interface; - output["net_link_speed_mbps"] = info.net_link_speed_mbps; - return output; - } - - nlohmann::json session_summary_to_json(const session_history::session_summary_t &summary) { - nlohmann::json output; - output["uuid"] = summary.uuid; - output["protocol"] = summary.protocol; - output["client_name"] = summary.client_name; - output["device_name"] = summary.device_name; - output["app_name"] = summary.app_name; - output["width"] = summary.width; - output["height"] = summary.height; - output["target_fps"] = summary.target_fps; - output["encoder_bitrate_kbps"] = summary.encoder_bitrate_kbps; - output["requested_bitrate_kbps"] = summary.requested_bitrate_kbps; - output["codec"] = summary.codec; - output["hdr"] = summary.hdr; - output["yuv444"] = summary.yuv444; - output["audio_channels"] = summary.audio_channels; - output["start_time_unix"] = summary.start_time_unix; - output["end_time_unix"] = summary.end_time_unix; - output["duration_seconds"] = round_to(summary.duration_seconds, 10.0); - output["verdict"] = summary.verdict; - output["server_version"] = summary.server_version; - output["host_cpu_model"] = summary.host_cpu_model; - output["host_gpu_model"] = summary.host_gpu_model; - output["stream_gpu_model"] = summary.stream_gpu_model; - return output; - } - - nlohmann::json session_sample_to_json(const session_history::session_sample_t &sample) { - nlohmann::json output; - output["session_uuid"] = sample.session_uuid; - output["timestamp_unix"] = sample.timestamp_unix; - output["bytes_sent_total"] = sample.bytes_sent_total; - output["packets_sent_video"] = sample.packets_sent_video; - output["frames_sent"] = sample.frames_sent; - output["last_frame_index"] = sample.last_frame_index; - output["video_dropped"] = sample.video_dropped; - output["audio_dropped"] = sample.audio_dropped; - output["client_reported_losses"] = sample.client_reported_losses; - output["idr_requests"] = sample.idr_requests; - output["ref_invalidations"] = sample.ref_invalidations; - output["encode_latency_ms"] = round_to(sample.encode_latency_ms, 10.0); - output["actual_fps"] = round_to(sample.actual_fps, 10.0); - output["actual_bitrate_kbps"] = round_to(sample.actual_bitrate_kbps, 10.0); - output["frame_interval_jitter_ms"] = round_to(sample.frame_interval_jitter_ms, 100.0); - output["host_cpu_percent"] = sample.host_cpu_percent < 0 ? -1 : round_to(sample.host_cpu_percent, 10.0); - output["host_gpu_percent"] = sample.host_gpu_percent < 0 ? -1 : round_to(sample.host_gpu_percent, 10.0); - output["host_gpu_encoder_percent"] = sample.host_gpu_encoder_percent < 0 ? -1 : round_to(sample.host_gpu_encoder_percent, 10.0); - output["host_ram_percent"] = sample.host_ram_percent < 0 ? -1 : round_to(sample.host_ram_percent, 10.0); - output["host_vram_percent"] = sample.host_vram_percent < 0 ? -1 : round_to(sample.host_vram_percent, 10.0); - output["host_cpu_temp_c"] = sample.host_cpu_temp_c < 0 ? -1 : round_to(sample.host_cpu_temp_c, 10.0); - output["host_gpu_temp_c"] = sample.host_gpu_temp_c < 0 ? -1 : round_to(sample.host_gpu_temp_c, 10.0); - output["host_net_rx_bps"] = sample.host_net_rx_bps < 0 ? -1 : sample.host_net_rx_bps; - output["host_net_tx_bps"] = sample.host_net_tx_bps < 0 ? -1 : sample.host_net_tx_bps; - return output; - } - - nlohmann::json session_event_to_json(const session_history::session_event_t &event) { - nlohmann::json output; - output["session_uuid"] = event.session_uuid; - output["timestamp_unix"] = event.timestamp_unix; - output["event_type"] = event.event_type; - output["payload"] = event.payload; - return output; - } - - nlohmann::json active_session_to_json(const session_history::active_session_t &session) { - nlohmann::json output; - output["uuid"] = session.uuid; - output["protocol"] = session.protocol; - output["client_name"] = session.client_name; - output["device_name"] = session.device_name; - output["app_name"] = session.app_name; - output["width"] = session.width; - output["height"] = session.height; - output["target_fps"] = session.target_fps; - output["encoder_bitrate_kbps"] = session.encoder_bitrate_kbps; - output["requested_bitrate_kbps"] = session.requested_bitrate_kbps; - output["codec"] = session.codec; - output["hdr"] = session.hdr; - output["yuv444"] = session.yuv444; - output["stream_gpu_model"] = session.stream_gpu_model; - output["uptime_seconds"] = round_to(session.uptime_seconds, 10.0); - output["actual_fps"] = round_to(session.actual_fps, 10.0); - output["actual_bitrate_kbps"] = round_to(session.actual_bitrate_kbps, 10.0); - output["encode_latency_ms"] = round_to(session.encode_latency_ms, 10.0); - output["frame_interval_jitter_ms"] = round_to(session.frame_interval_jitter_ms, 100.0); - output["frames_sent"] = session.frames_sent; - output["bytes_sent"] = session.bytes_sent; - output["client_reported_losses"] = session.client_reported_losses; - output["idr_requests"] = session.idr_requests; - return output; - } - - nlohmann::json session_detail_to_json(const session_history::session_detail_t &detail) { - nlohmann::json output = session_summary_to_json(detail.summary); - output["total_samples"] = detail.total_samples; - output["total_events"] = detail.total_events; - output["samples_truncated"] = detail.samples_truncated; - output["events_truncated"] = detail.events_truncated; - output["samples"] = nlohmann::json::array(); - for (const auto &sample : detail.samples) { - output["samples"].push_back(session_sample_to_json(sample)); - } - output["events"] = nlohmann::json::array(); - for (const auto &event : detail.events) { - output["events"].push_back(session_event_to_json(event)); - } - return output; - } - - nlohmann::json history_status_to_json(const session_history::history_status_t &status) { - nlohmann::json output; - output["available"] = status.available; - output["degraded"] = status.degraded; - output["dropped_samples"] = status.dropped_samples; - output["failed_writes"] = status.failed_writes; - output["pending_control_commands"] = status.pending_control_commands; - output["pending_priority_commands"] = status.pending_priority_commands; - output["pending_regular_commands"] = status.pending_regular_commands; - output["pending_samples"] = status.pending_samples; - return output; - } - - /** - * @brief Write an APIResponse to an HTTP response object. - * @param response The HTTP response object. - * @param api_response The APIResponse containing the structured response data. - */ - void write_api_response(resp_https_t response, const APIResponse &api_response) { - SimpleWeb::CaseInsensitiveMultimap headers = api_response.headers; - headers.emplace("Content-Type", "application/json"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - add_cors_headers(headers); - response->write(api_response.status_code, api_response.body, headers); - } - - /** - * @brief Send a 401 Unauthorized response. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void send_unauthorized(resp_https_t response, req_https_t request) { - auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); - BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv; - - constexpr auto code = client_error_unauthorized; - - nlohmann::json tree; - tree["status_code"] = code; - tree["status"] = false; - tree["error"] = "Unauthorized"; - const SimpleWeb::CaseInsensitiveMultimap headers { - {"Content-Type", "application/json"}, - {"X-Frame-Options", "DENY"}, - {"Content-Security-Policy", "frame-ancestors 'none';"}, - {"Access-Control-Allow-Origin", get_cors_origin()} - }; - response->write(code, tree.dump(), headers); - } - - /** - * @brief Send a redirect response. - * @param response The HTTP response object. - * @param request The HTTP request object. - * @param path The path to redirect to. - */ - void send_redirect(resp_https_t response, req_https_t request, const char *path) { - auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); - BOOST_LOG(info) << "Web UI: ["sv << address << "] -- redirecting"sv; - const SimpleWeb::CaseInsensitiveMultimap headers { - {"Location", path}, - {"X-Frame-Options", "DENY"}, - {"Content-Security-Policy", "frame-ancestors 'none';"} - }; - response->write(redirection_temporary_redirect, headers); - } - - /** - * @brief Enforce origin access policy based on configured network scope. - * @return True if the remote address is permitted, false otherwise (response set). - */ - bool checkIPOrigin(resp_https_t response, req_https_t request) { - const auto remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); - const auto ip_type = net::from_address(remote_address); - if (ip_type > http::origin_web_ui_allowed) { - BOOST_LOG(info) << "Web UI: ["sv << remote_address << "] -- denied by origin policy"sv; - nlohmann::json tree; - tree["status_code"] = static_cast(SimpleWeb::StatusCode::client_error_forbidden); - tree["status"] = false; - tree["error"] = "Forbidden"; - SimpleWeb::CaseInsensitiveMultimap headers { - {"Content-Type", "application/json"}, - {"X-Frame-Options", "DENY"}, - {"Content-Security-Policy", "frame-ancestors 'none';"} - }; - add_cors_headers(headers); - response->write(SimpleWeb::StatusCode::client_error_forbidden, tree.dump(), headers); - return false; - } - return true; - } - - /** - * @brief Check authentication and authorization for an HTTP request. - * @param request The HTTP request object. - * @return AuthResult with outcome and response details if not authorized. - */ - AuthResult check_auth(const req_https_t &request) { - auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); - std::string auth_header; - // Try Authorization header - if (auto auth_it = request->header.find("authorization"); auth_it != request->header.end()) { - auth_header = auth_it->second; - } else { - std::string token = extract_session_token_from_cookie(request->header); - if (!token.empty()) { - auth_header = "Session " + token; - } - } - return check_auth(address, auth_header, request->path, request->method); - } - - /** - * @brief Authenticate the user or API token for a specific path/method. - * @param response The HTTP response object. - * @param request The HTTP request object. - * @return True if authenticated and authorized, false otherwise. - */ - bool authenticate(resp_https_t response, req_https_t request) { - if (auto result = check_auth(request); !result.ok) { - if (result.code == StatusCode::redirection_temporary_redirect) { - response->write(result.code, result.headers); - } else if (!result.body.empty()) { - response->write(result.code, result.body, result.headers); - } else { - response->write(result.code); - } - return false; - } - return true; - } - - /** - * @brief Get the list of available display devices. - * @api_examples{/api/display-devices| GET| [{"device_id":"{...}","display_name":"\\\\.\\DISPLAY1","friendly_name":"Monitor"}, ...]} - * @note Pass query param detail=full to include extended metadata (refresh lists, inactive displays). - */ - void getDisplayDevices(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - try { - display_device::DeviceEnumerationDetail detail = display_device::DeviceEnumerationDetail::Minimal; - const auto query = request->parse_query_string(); - if (const auto it = query.find("detail"); it != query.end()) { - const auto value = boost::algorithm::to_lower_copy(it->second); - if (value == "full") { - detail = display_device::DeviceEnumerationDetail::Full; - } - } else if (const auto full_it = query.find("full"); full_it != query.end()) { - const auto value = boost::algorithm::to_lower_copy(full_it->second); - if (value == "1" || value == "true" || value == "yes") { - detail = display_device::DeviceEnumerationDetail::Full; - } - } - - const auto json_str = display_helper_integration::enumerate_devices_json(detail); - nlohmann::json tree = nlohmann::json::parse(json_str); - send_response(response, tree); - } catch (const std::exception &e) { - nlohmann::json tree; - tree["status"] = false; - tree["error"] = std::string {"Failed to enumerate display devices: "} + e.what(); - send_response(response, tree); - } - } - -#ifdef _WIN32 - /** - * @brief Validate refresh capabilities for a display via EDID for frame generation health checks. - * @api_examples{/api/framegen/edid-refresh?device_id=\\.\DISPLAY1| GET| {"status":true,"targets":[{"hz":120,"supported":true,"method":"range"}]}} - */ - void getFramegenEdidRefresh(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - try { - const auto query = request->parse_query_string(); - auto read_first = [&](std::initializer_list keys) -> std::string { - for (const auto &key : keys) { - const auto it = query.find(key); - if (it != query.end()) { - auto value = boost::algorithm::trim_copy(it->second); - if (!value.empty()) { - return value; - } - } - } - return {}; - }; - - std::string device_hint = read_first({"device_id", "device", "id", "display"}); - if (device_hint.empty()) { - bad_request(response, request, "device_id query parameter is required"); - return; - } - - std::vector targets {120, 180, 240, 288}; - if (const auto it = query.find("targets"); it != query.end()) { - std::vector parsed; - std::vector parts; - boost::split(parts, it->second, boost::is_any_of(",")); - for (auto part : parts) { - boost::algorithm::trim(part); - if (part.empty()) { - continue; - } - try { - int hz = std::stoi(part); - if (hz > 0) { - parsed.push_back(hz); - } - } catch (...) { - // ignore invalid entries - } - } - if (!parsed.empty()) { - targets = std::move(parsed); - } - } - - auto result = display_helper_integration::framegen_edid_refresh_support(device_hint, targets); - nlohmann::json out; - if (!result) { - out["status"] = false; - out["error"] = "Display device not found for EDID refresh validation."; - send_response(response, out); - return; - } - - out["status"] = true; - out["device_id"] = result->device_id; - out["device_label"] = result->device_label; - out["edid_present"] = result->edid_present; - if (result->max_vertical_hz) { - out["max_vertical_hz"] = *result->max_vertical_hz; - } - if (result->max_timing_hz) { - out["max_timing_hz"] = *result->max_timing_hz; - } - - nlohmann::json targets_json = nlohmann::json::array(); - for (const auto &entry : result->targets) { - nlohmann::json target_json; - target_json["hz"] = entry.hz; - target_json["supported"] = entry.supported.has_value() ? nlohmann::json(*entry.supported) : nlohmann::json(nullptr); - target_json["method"] = entry.method; - targets_json.push_back(std::move(target_json)); - } - out["targets"] = std::move(targets_json); - - send_response(response, out); - } catch (const std::exception &e) { - bad_request(response, request, e.what()); - } catch (...) { - bad_request(response, request, "Failed to validate display refresh via EDID."); - } - } - - /** - * @brief Health check for ViGEm (Virtual Gamepad) installation on Windows. - * @api_examples{/api/health/vigem| GET| {"installed":true,"version":""}} - */ - void getVigemHealth(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - try { - std::string version; - bool installed = platf::is_vigem_installed(&version); - nlohmann::json out; - out["installed"] = installed; - if (!version.empty()) { - out["version"] = version; - } - send_response(response, out); - } catch (...) { - bad_request(response, request, "Failed to evaluate ViGEm health"); - } - } -#endif - - /** - * @brief Send a 404 Not Found response. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void not_found(resp_https_t response, [[maybe_unused]] req_https_t request) { - constexpr auto code = client_error_not_found; - - nlohmann::json tree; - tree["status_code"] = static_cast(code); - tree["error"] = "Not Found"; - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json"); - headers.emplace("Access-Control-Allow-Origin", get_cors_origin()); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - - response->write(code, tree.dump(), headers); - } - - /** - * @brief Send a 400 Bad Request response. - * @param response The HTTP response object. - * @param request The HTTP request object. - * @param error_message The error message. - */ - void bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") { - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json; charset=utf-8"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - add_cors_headers(headers); - nlohmann::json error = {{"error", error_message}}; - response->write(client_error_bad_request, error.dump(), headers); - } - - void service_unavailable(resp_https_t response, const std::string &error_message) { - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json; charset=utf-8"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - add_cors_headers(headers); - nlohmann::json error = {{"error", error_message}}; - response->write(SimpleWeb::StatusCode::server_error_service_unavailable, error.dump(), headers); - } - - void conflict(resp_https_t response, const std::string &error_message) { - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json; charset=utf-8"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - add_cors_headers(headers); - nlohmann::json error = {{"error", error_message}}; - response->write(SimpleWeb::StatusCode::client_error_conflict, error.dump(), headers); - } - - void gateway_timeout(resp_https_t response, const std::string &error_message) { - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json; charset=utf-8"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - add_cors_headers(headers); - nlohmann::json error = {{"error", error_message}}; - response->write(SimpleWeb::StatusCode::server_error_gateway_timeout, error.dump(), headers); - } - - /** - * @brief Validate the request content type and send bad request when mismatch. - * @param response The HTTP response object. - * @param request The HTTP request object. - * @param contentType The required content type. - */ - bool validateContentType(resp_https_t response, req_https_t request, const std::string_view &contentType) { - auto requestContentType = request->header.find("content-type"); - if (requestContentType == request->header.end()) { - bad_request(response, request, "Content type not provided"); - return false; - } - - // Extract the media type part before any parameters (e.g., charset) - std::string actualContentType = requestContentType->second; - size_t semicolonPos = actualContentType.find(';'); - if (semicolonPos != std::string::npos) { - actualContentType = actualContentType.substr(0, semicolonPos); - } - - // Trim whitespace and convert to lowercase for case-insensitive comparison - boost::algorithm::trim(actualContentType); - boost::algorithm::to_lower(actualContentType); - - std::string expectedContentType(contentType); - boost::algorithm::to_lower(expectedContentType); - - if (actualContentType != expectedContentType) { - bad_request(response, request, "Content type mismatch"); - return false; - } - return true; - } - - bool check_content_type(resp_https_t response, req_https_t request, const std::string_view &contentType) { - return validateContentType(response, request, contentType); - } - - /** - * @brief SPA entry responder - serves the single-page app shell (index.html) - * for any non-API and non-static-asset GET requests. Allows unauthenticated - * access so the frontend can render login/first-run flows. Static and API - * routes are expected to be registered explicitly; this function returns - * a 404 for reserved prefixes to avoid accidentally exposing files. - */ - void getSpaEntry(resp_https_t response, req_https_t request) { - print_req(request); - - const std::string &p = request->path; - // Reserved prefixes that should not be handled by the SPA entry - static const std::vector reserved = {"/api", "/assets", "/covers", "/images", "/images/"}; - for (const auto &r : reserved) { - if (p.rfind(r, 0) == 0) { - // Let explicit handlers or default not_found handle these - not_found(response, request); - return; - } - } - - // Serve the SPA shell (index.html) without server-side auth so frontend - // can manage routing and authentication flows. - std::string content = file_handler::read_file(WEB_DIR "index.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "text/html; charset=utf-8"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - response->write(content, headers); - } - - // legacy per-page handlers removed; SPA entry handles these routes - - /** - * @brief Get the favicon image. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void getFaviconImage(resp_https_t response, req_https_t request) { - print_req(request); - - std::ifstream in(WEB_DIR "images/apollo.ico", std::ios::binary); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "image/x-icon"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - response->write(success_ok, in, headers); - } - - /** - * @brief Get the Apollo logo image. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @todo combine function with getFaviconImage and possibly getNodeModules - * @todo use mime_types map - */ - void getApolloLogoImage(resp_https_t response, req_https_t request) { - print_req(request); - - std::ifstream in(WEB_DIR "images/logo-apollo-45.png", std::ios::binary); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "image/png"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - response->write(success_ok, in, headers); - } - - /** - * @brief Check if a path is a child of another path. - * @param base The base path. - * @param query The path to check. - * @return True if the path is a child of the base path, false otherwise. - */ - bool isChildPath(fs::path const &base, fs::path const &query) { - auto relPath = fs::relative(base, query); - return *(relPath.begin()) != fs::path(".."); - } - - /** - * @brief Get an asset from the node_modules directory. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void getNodeModules(resp_https_t response, req_https_t request) { - print_req(request); - - fs::path webDirPath(WEB_DIR); - fs::path nodeModulesPath(webDirPath / "assets"); - - // .relative_path is needed to shed any leading slash that might exist in the request path - auto filePath = fs::weakly_canonical(webDirPath / fs::path(request->path).relative_path()); - - // Don't do anything if file does not exist or is outside the assets directory - if (!isChildPath(filePath, nodeModulesPath)) { - BOOST_LOG(warning) << "Someone requested a path " << filePath << " that is outside the assets folder"; - bad_request(response, request); - return; - } - - if (!fs::exists(filePath)) { - not_found(response, request); - return; - } - - auto relPath = fs::relative(filePath, webDirPath); - // get the mime type from the file extension mime_types map - // remove the leading period from the extension - auto mimeType = mime_types.find(relPath.extension().string().substr(1)); - if (mimeType == mime_types.end()) { - bad_request(response, request); - return; - } - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", mimeType->second); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - std::ifstream in(filePath.string(), std::ios::binary); - response->write(success_ok, in, headers); - } - - /** - * @brief Get the list of available applications. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/apps| GET| null} - */ - void getApps(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - try { - std::string content = file_handler::read_file(config::stream.file_apps.c_str()); - nlohmann::json file_tree = nlohmann::json::parse(content); - - file_tree["current_app"] = proc::proc.get_running_app_uuid(); - file_tree["host_uuid"] = http::unique_id; - file_tree["host_name"] = config::nvhttp.sunshine_name; -#ifdef _WIN32 - // No auto-insert here; controlled by config 'playnite_fullscreen_entry_enabled'. -#endif - - // Legacy versions of Sunshine used strings for boolean and integers, let's convert them - // List of keys to convert to boolean - std::vector boolean_keys = { - "exclude-global-prep-cmd", - "exclude-global-state-cmd", - "elevated", - "auto-detach", - "wait-all", - "terminate-on-pause", - "virtual-display", - "allow-client-commands", - "use-app-identity", - "per-client-app-identity", - "gen1-framegen-fix", - "gen2-framegen-fix", - "dlss-framegen-capture-fix", // backward compatibility - "lossless-scaling-enabled", - "lossless-scaling-framegen", - "lossless-scaling-legacy-auto-detect" - }; - - // List of keys to convert to integers - std::vector integer_keys = { - "exit-timeout", - "lossless-scaling-target-fps", - "lossless-scaling-rtss-limit", - "scale-factor", - "lossless-scaling-launch-delay" - }; - - bool mutated = false; - auto normalize_lossless_profile_overrides = [](nlohmann::json &node) -> bool { - if (!node.is_object()) { - return false; - } - bool changed = false; - auto convert_int = [&](const char *key) { - if (!node.contains(key)) { - return; - } - auto &value = node[key]; - if (value.is_string()) { - try { - value = std::stoi(value.get()); - changed = true; - } catch (...) { - } - } - }; - auto convert_bool = [&](const char *key) { - if (!node.contains(key)) { - return; - } - auto &value = node[key]; - if (value.is_string()) { - auto text = value.get(); - if (text == "true" || text == "false") { - value = (text == "true"); - changed = true; - } else if (text == "1" || text == "0") { - value = (text == "1"); - changed = true; - } - } - }; - convert_bool("performance-mode"); - convert_int("flow-scale"); - convert_int("resolution-scale"); - convert_int("sharpening"); - convert_bool("anime4k-vrs"); - if (node.contains("scaling-type") && node["scaling-type"].is_string()) { - auto text = node["scaling-type"].get(); - boost::algorithm::to_lower(text); - node["scaling-type"] = text; - changed = true; - } - if (node.contains("anime4k-size") && node["anime4k-size"].is_string()) { - auto text = node["anime4k-size"].get(); - boost::algorithm::to_upper(text); - node["anime4k-size"] = text; - changed = true; - } - return changed; - }; - // Walk fileTree and convert true/false strings to boolean or integer values - for (auto &app : file_tree["apps"]) { - for (const auto &key : boolean_keys) { - if (app.contains(key) && app[key].is_string()) { - app[key] = app[key] == "true"; - mutated = true; - } - } - for (const auto &key : integer_keys) { - if (app.contains(key) && app[key].is_string()) { - app[key] = std::stoi(app[key].get()); - mutated = true; - } - } - if (app.contains("lossless-scaling-recommended")) { - mutated = normalize_lossless_profile_overrides(app["lossless-scaling-recommended"]) || mutated; - } - if (app.contains("lossless-scaling-custom")) { - mutated = normalize_lossless_profile_overrides(app["lossless-scaling-custom"]) || mutated; - } - if (app.contains("prep-cmd")) { - for (auto &prep : app["prep-cmd"]) { - if (prep.contains("elevated") && prep["elevated"].is_string()) { - prep["elevated"] = prep["elevated"] == "true"; - mutated = true; - } - } - } - if (app.contains("state-cmd")) { - for (auto &state : app["state-cmd"]) { - if (state.contains("elevated") && state["elevated"].is_string()) { - state["elevated"] = state["elevated"] == "true"; - mutated = true; - } - } - } - // Ensure each app has a UUID (auto-insert if missing/empty) - if (!app.contains("uuid") || app["uuid"].is_null() || (app["uuid"].is_string() && app["uuid"].get().empty())) { - app["uuid"] = uuid_util::uuid_t::generate().string(); - mutated = true; - } - } - - // Add computed app ids for UI clients (best-effort, do not persist). - if (file_tree.contains("apps") && file_tree["apps"].is_array()) { - try { - const auto apps_snapshot = proc::proc.get_apps(); - const auto count = std::min(file_tree["apps"].size(), apps_snapshot.size()); - for (size_t idx = 0; idx < count; ++idx) { - auto &app = file_tree["apps"][idx]; - app["id"] = apps_snapshot[idx].id; - app["index"] = static_cast(idx); - } - } catch (...) { - } - } - - // If any normalization occurred, persist back to disk - if (mutated) { - try { - file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); - } catch (std::exception &e) { - BOOST_LOG(warning) << "GetApps persist normalization failed: "sv << e.what(); - } - } - - send_response(response, file_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "GetApps: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Save an application. To save a new application the index must be `-1`. To update an existing application, you must provide the current index of the application. - * @param response The HTTP response object. - * @param request The HTTP request object. - * The body for the post request should be JSON serialized in the following format: - * @code{.json} - * { - * "name": "Application Name", - * "output": "Log Output Path", - * "cmd": "Command to run the application", - * "exclude-global-prep-cmd": false, - * "elevated": false, - * "auto-detach": true, - * "wait-all": true, - * "exit-timeout": 5, - * "prep-cmd": [ - * { - * "do": "Command to prepare", - * "undo": "Command to undo preparation", - * "elevated": false - * } - * ], - * "detached": [ - * "Detached command" - * ], - * "image-path": "Full path to the application image. Must be a png file.", - * "uuid": "aaaa-bbbb" - * } - * @endcode - * - * @api_examples{/api/apps| POST| {"name":"Hello, World!","uuid": "aaaa-bbbb"}} - */ - void saveApp(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - - BOOST_LOG(info) << config::stream.file_apps; - try { - // TODO: Input Validation - - // Read the input JSON from the request body. - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - const int index = input_tree.at("index").get(); // intentionally throws if the provided value is missing or the wrong type - - // Read the existing apps file. - std::string content = file_handler::read_file(config::stream.file_apps.c_str()); - nlohmann::json file_tree = nlohmann::json::parse(content); - - // Migrate/merge the new app into the file tree. - proc::migrate_apps(&file_tree, &input_tree); - - if (input_tree.contains("config-overrides") && input_tree["config-overrides"].is_object()) { - auto &overrides = input_tree["config-overrides"]; - if (overrides.contains("nvenc_force_split_encode") && !overrides.contains("nvenc_split_encode")) { - overrides["nvenc_split_encode"] = overrides["nvenc_force_split_encode"]; - } - overrides.erase("nvenc_force_split_encode"); - } - - // If image-path omitted but we have a Playnite id, let Playnite helper resolve a cover (Windows) -#ifdef _WIN32 - enhance_app_with_playnite_cover(input_tree); - try { - if (input_tree.contains("playnite-id") && input_tree["playnite-id"].is_string()) { - const auto playnite_id = input_tree["playnite-id"].get(); - if (!playnite_id.empty()) { - input_tree["uuid"] = platf::playnite::sync::canonical_playnite_app_uuid(playnite_id); - } - } - } catch (...) {} -#endif - -#ifndef _WIN32 - if ((input_tree.contains("gen1-framegen-fix") && input_tree["gen1-framegen-fix"].is_boolean() && input_tree["gen1-framegen-fix"].get()) || - (input_tree.contains("dlss-framegen-capture-fix") && input_tree["dlss-framegen-capture-fix"].is_boolean() && input_tree["dlss-framegen-capture-fix"].get())) { - bad_request(response, request, "Frame generation capture fixes are only supported on Windows hosts."); - return; - } - if (input_tree.contains("gen2-framegen-fix") && input_tree["gen2-framegen-fix"].is_boolean() && input_tree["gen2-framegen-fix"].get()) { - bad_request(response, request, "Frame generation capture fixes are only supported on Windows hosts."); - return; - } -#else - // Migrate old field name to new for backward compatibility - if (input_tree.contains("dlss-framegen-capture-fix") && !input_tree.contains("gen1-framegen-fix")) { - input_tree["gen1-framegen-fix"] = input_tree["dlss-framegen-capture-fix"]; - } - // Remove old field to avoid duplication - input_tree.erase("dlss-framegen-capture-fix"); -#endif - - auto &apps_node = file_tree["apps"]; - if (!apps_node.is_array()) { - apps_node = nlohmann::json::array(); - } - input_tree.erase("index"); - - std::string input_uuid; - try { - if (input_tree.contains("uuid") && input_tree["uuid"].is_string()) { - input_uuid = input_tree["uuid"].get(); - } - } catch (...) {} - - bool replaced = false; - if (!input_uuid.empty()) { - for (auto it = apps_node.begin(); it != apps_node.end(); ++it) { - try { - if (it->contains("uuid") && (*it)["uuid"].is_string() && (*it)["uuid"].get() == input_uuid) { - *it = input_tree; - replaced = true; - break; - } - } catch (...) {} - } - } - - if (index == -1) { - if (input_uuid.empty()) { - input_uuid = uuid_util::uuid_t::generate().string(); - input_tree["uuid"] = input_uuid; - } - if (!replaced) { - apps_node.push_back(input_tree); - } - } else { - nlohmann::json newApps = nlohmann::json::array(); - for (size_t i = 0; i < apps_node.size(); ++i) { - if (i == index) { - try { - if ((!input_tree.contains("uuid") || input_tree["uuid"].is_null() || (input_tree["uuid"].is_string() && input_tree["uuid"].get().empty())) && - apps_node[i].contains("uuid") && apps_node[i]["uuid"].is_string()) { - input_tree["uuid"] = apps_node[i]["uuid"].get(); - } - } catch (...) {} - newApps.push_back(input_tree); - } else { - newApps.push_back(apps_node[i]); - } - } - file_tree["apps"] = newApps; - } - - // Update apps file and refresh client cache - confighttp::refresh_client_apps_cache(file_tree); - - // Prepare and send the output response. - nlohmann::json outputTree; - outputTree["status"] = true; - send_response(response, outputTree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "SaveApp: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Serve a specific application's cover image by UUID. - * Looks for files named @c uuid with a supported image extension in the covers directory. - * @api_examples{/api/apps/@c uuid/cover| GET| null} - */ - void getAppCover(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - if (request->path_match.size() < 2) { - bad_request(response, request, "Application uuid required"); - return; - } - - std::string uuid = request->path_match[1]; - if (uuid.empty()) { - bad_request(response, request, "Application uuid required"); - return; - } - - fs::path cover_path; - if (!resolve_cover_path_for_uuid(uuid, cover_path)) { - not_found(response, request); - return; - } - - std::ifstream in(cover_path, std::ios::binary); - if (!in) { - not_found(response, request); - return; - } - - std::string ext = cover_path.extension().string(); - std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - if (!ext.empty() && ext.front() == '.') { - ext.erase(ext.begin()); - } - - std::string mime = "image/png"; - if (!ext.empty()) { - auto it = mime_types.find(ext); - if (it != mime_types.end()) { - mime = it->second; - } - } - - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", mime); - headers.emplace("Cache-Control", "private, max-age=300"); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - response->write(success_ok, in, headers); - } - - /** - * @brief Upload or set a specific application's cover image by UUID. - * Accepts either a JSON body with {"url": "..."} (restricted to images.igdb.com) or {"data": base64}. - * Saves to appdata/covers/@c uuid.@c ext where ext is derived from URL or defaults to .png for data. - * @api_examples{/api/apps/@c uuid/cover| POST| {"url":"https://images.igdb.com/.../abc.png"}} - */ - - /** - * @brief Close the currently running application. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/apps/close| POST| null} - */ - void closeApp(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - proc::proc.terminate(); - nlohmann::json output_tree; - output_tree["status"] = true; - send_response(response, output_tree); - } - - /** - * @brief Reorder applications. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/apps/reorder| POST| {"order": ["aaaa-bbbb", "cccc-dddd"]}} - */ - void reorderApps(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - try { - std::stringstream ss; - ss << request->content.rdbuf(); - - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - nlohmann::json output_tree; - - // Read the existing apps file. - std::string content = file_handler::read_file(config::stream.file_apps.c_str()); - nlohmann::json fileTree = nlohmann::json::parse(content); - - // Get the desired order of UUIDs from the request. - if (!input_tree.contains("order") || !input_tree["order"].is_array()) { - throw std::runtime_error("Missing or invalid 'order' array in request body"); - } - const auto &order_uuids_json = input_tree["order"]; - - // Get the original apps array from the fileTree. - // Default to an empty array if "apps" key is missing or if it's present but not an array (after logging an error). - nlohmann::json original_apps_list = nlohmann::json::array(); - if (fileTree.contains("apps")) { - if (fileTree["apps"].is_array()) { - original_apps_list = fileTree["apps"]; - } else { - // "apps" key exists but is not an array. This is a malformed state. - BOOST_LOG(error) << "ReorderApps: 'apps' key in apps configuration file ('" << config::stream.file_apps - << "') is present but not an array."; - throw std::runtime_error("'apps' in file is not an array, cannot reorder."); - } - } else { - // "apps" key is missing. Treat as an empty list. Reordering an empty list is valid. - BOOST_LOG(debug) << "ReorderApps: 'apps' key missing in apps configuration file ('" << config::stream.file_apps - << "'). Treating as an empty list for reordering."; - // original_apps_list is already an empty array, so no specific action needed here. - } - - nlohmann::json reordered_apps_list = nlohmann::json::array(); - std::vector item_moved(original_apps_list.size(), false); - - // Phase 1: Place apps according to the 'order' array from the request. - // Iterate through the desired order of UUIDs. - for (const auto &uuid_json_value : order_uuids_json) { - if (!uuid_json_value.is_string()) { - BOOST_LOG(warning) << "ReorderApps: Encountered a non-string UUID in the 'order' array. Skipping this entry."; - continue; - } - std::string target_uuid = uuid_json_value.get(); - bool found_match_for_ordered_uuid = false; - - // Find the first unmoved app in the original list that matches the current target_uuid. - for (size_t i = 0; i < original_apps_list.size(); ++i) { - if (item_moved[i]) { - continue; // This specific app object has already been placed. - } - - const auto &app_item = original_apps_list[i]; - // Ensure the app item is an object and has a UUID to match against. - if (app_item.is_object() && app_item.contains("uuid") && app_item["uuid"].is_string()) { - if (app_item["uuid"].get() == target_uuid) { - reordered_apps_list.push_back(app_item); // Add the found app object to the new list. - item_moved[i] = true; // Mark this specific object as moved. - found_match_for_ordered_uuid = true; - break; // Found an app for this UUID, move to the next UUID in the 'order' array. - } - } - } - - if (!found_match_for_ordered_uuid) { - // This means a UUID specified in the 'order' array was not found in the original_apps_list - // among the currently available (unmoved) app objects. - // Per instruction "If the uuid is missing from the original json file, omit it." - BOOST_LOG(debug) << "ReorderApps: UUID '" << target_uuid << "' from 'order' array not found in available apps list or its matching app was already processed. Omitting."; - } - } - - // Phase 2: Append any remaining apps from the original list that were not explicitly ordered. - // These are app objects that were not marked 'item_moved' in Phase 1. - for (size_t i = 0; i < original_apps_list.size(); ++i) { - if (!item_moved[i]) { - reordered_apps_list.push_back(original_apps_list[i]); - } - } - - // Update the fileTree with the new, reordered list of apps. - fileTree["apps"] = reordered_apps_list; - - // Write the modified fileTree back to the apps configuration file. - file_handler::write_file(config::stream.file_apps.c_str(), fileTree.dump(4)); - - // Notify relevant parts of the system that the apps configuration has changed. - proc::refresh(config::stream.file_apps, false); - - output_tree["status"] = true; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "ReorderApps: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Delete an application. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/apps/delete | POST| { uuid: 'aaaa-bbbb' }} - */ - void deleteApp(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - const bool is_delete_method = request->method == "DELETE"; - std::optional index_from_path; - if (request->path_match.size() > 1) { - try { - index_from_path = static_cast(std::stoul(request->path_match[1])); - } catch (...) { - } - } - - std::stringstream ss; - ss << request->content.rdbuf(); - std::string raw_body = ss.str(); - - std::optional uuid; - std::optional index_from_body; - - if (!raw_body.empty()) { - if (!validateContentType(response, request, "application/json")) { - return; - } - try { - nlohmann::json input_tree = nlohmann::json::parse(raw_body); - if (input_tree.contains("uuid") && input_tree["uuid"].is_string()) { - uuid = input_tree["uuid"].get(); - } - if (input_tree.contains("index") && input_tree["index"].is_number_integer()) { - auto idx = input_tree["index"].get(); - if (idx >= 0) { - index_from_body = static_cast(idx); - } - } - } catch (const std::exception &e) { - bad_request(response, request, e.what()); - return; - } - } else if (!is_delete_method) { - bad_request(response, request, "Missing request body"); - return; - } - - std::optional target_index = index_from_body ? index_from_body : index_from_path; - - // Detect if the app being removed is the Playnite fullscreen launcher - auto is_playnite_fullscreen = [](const nlohmann::json &app) -> bool { - try { - if (app.contains("playnite-fullscreen") && app["playnite-fullscreen"].is_boolean() && app["playnite-fullscreen"].get()) { - return true; - } - if (app.contains("cmd") && app["cmd"].is_string()) { - auto s = app["cmd"].get(); - if (s.find("playnite-launcher") != std::string::npos && s.find("--fullscreen") != std::string::npos) { - return true; - } - } - if (app.contains("name") && app["name"].is_string() && app["name"].get() == "Playnite (Fullscreen)") { - return true; - } - } catch (...) {} - return false; - }; - - try { - std::string content = file_handler::read_file(config::stream.file_apps.c_str()); - nlohmann::json file_tree = nlohmann::json::parse(content); - if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { - bad_request(response, request, "Apps configuration missing or invalid"); - return; - } - - auto &apps_node = file_tree["apps"]; - nlohmann::json::array_t new_apps; - new_apps.reserve(apps_node.size()); - - bool removed = false; - bool disabled_fullscreen_flag = false; - - for (size_t i = 0; i < apps_node.size(); ++i) { - const auto &app_entry = apps_node[i]; - auto app_uuid = app_entry.contains("uuid") && app_entry["uuid"].is_string() ? app_entry["uuid"].get() : std::string {}; - - bool match = false; - if (uuid && !uuid->empty()) { - match = app_uuid == *uuid; - } else if (!uuid && target_index && *target_index == i) { - match = true; - if (!app_uuid.empty()) { - uuid = app_uuid; - } - } - - if (!match) { - new_apps.push_back(app_entry); - continue; - } - - removed = true; - -#ifdef _WIN32 - try { - if (is_playnite_fullscreen(app_entry)) { - auto current_cfg = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); - current_cfg["playnite_fullscreen_entry_enabled"] = "false"; - std::stringstream config_stream; - for (const auto &kv : current_cfg) { - config_stream << kv.first << " = " << kv.second << std::endl; - } - file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); - config::apply_config_now(); - disabled_fullscreen_flag = true; - } - } catch (...) { - } -#endif - } - - if (!removed) { - bad_request(response, request, "App to delete not found"); - return; - } - - file_tree["apps"] = new_apps; - file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); - proc::refresh(config::stream.file_apps, false); - - nlohmann::json output_tree; - output_tree["status"] = true; - if (disabled_fullscreen_flag) { - output_tree["playniteFullscreenDisabled"] = true; - } - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "DeleteApp: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Get the list of paired clients. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/clients/list| GET| null} - */ - void getClients(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - nlohmann::json named_certs = nvhttp::get_all_clients(); - nlohmann::json output_tree; - output_tree["named_certs"] = named_certs; -#ifdef _WIN32 - output_tree["platform"] = "windows"; -#endif - output_tree["status"] = true; - output_tree["platform"] = SUNSHINE_PLATFORM; - send_response(response, output_tree); - } - -#ifdef _WIN32 - static std::optional file_creation_time_ms(const std::filesystem::path &path) { - WIN32_FILE_ATTRIBUTE_DATA data {}; - if (!GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, &data)) { - return std::nullopt; - } - ULARGE_INTEGER t {}; - t.LowPart = data.ftCreationTime.dwLowDateTime; - t.HighPart = data.ftCreationTime.dwHighDateTime; - - // FILETIME is in 100ns units since 1601-01-01. - constexpr uint64_t kEpochDiff100ns = 116444736000000000ULL; // 1970-01-01 - 1601-01-01 - if (t.QuadPart < kEpochDiff100ns) { - return std::nullopt; - } - return (t.QuadPart - kEpochDiff100ns) / 10000ULL; - } - - static std::filesystem::path windows_color_profile_dir() { - wchar_t system_root[MAX_PATH] = {}; - if (GetSystemWindowsDirectoryW(system_root, _countof(system_root)) == 0) { - return std::filesystem::path(L"C:\\Windows\\System32\\spool\\drivers\\color"); - } - std::filesystem::path root(system_root); - return root / L"System32" / L"spool" / L"drivers" / L"color"; - } -#endif - - /** - * @brief Get a list of available HDR color profiles (Windows only). - * - * @api_examples{/api/clients/hdr-profiles| GET| null} - */ - void getHdrProfiles(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - nlohmann::json output_tree; - output_tree["status"] = true; - nlohmann::json profiles = nlohmann::json::array(); - -#ifdef _WIN32 - try { - const auto dir = windows_color_profile_dir(); - - struct entry_t { - std::string filename; - uint64_t added_ms; - }; - - std::vector entries; - for (const auto &entry : std::filesystem::directory_iterator(dir)) { - std::error_code ec; - if (!entry.is_regular_file(ec)) { - continue; - } - - auto ext = entry.path().extension().wstring(); - std::transform(ext.begin(), ext.end(), ext.begin(), [](wchar_t ch) { - return static_cast(std::towlower(ch)); - }); - if (ext != L".icm" && ext != L".icc") { - continue; - } - - const auto filename_utf8 = platf::to_utf8(entry.path().filename().wstring()); - const auto added_ms = file_creation_time_ms(entry.path()).value_or(0); - entries.push_back({filename_utf8, added_ms}); - } - - std::sort(entries.begin(), entries.end(), [](const entry_t &a, const entry_t &b) { - if (a.added_ms != b.added_ms) { - return a.added_ms > b.added_ms; - } - return a.filename < b.filename; - }); - - for (const auto &e : entries) { - nlohmann::json node; - node["filename"] = e.filename; - node["added_ms"] = e.added_ms; - profiles.push_back(std::move(node)); - } - } catch (const std::exception &e) { - output_tree["status"] = false; - output_tree["error"] = e.what(); - } catch (...) { - output_tree["status"] = false; - output_tree["error"] = "unknown error"; - } -#endif - - output_tree["profiles"] = std::move(profiles); - send_response(response, output_tree); - } - -#ifdef _WIN32 - // removed unused forward declaration for default_playnite_ext_dir() -#endif - - /** - * @brief Update stored settings for a paired client. - */ - /** - * @brief Disconnect a client session without unpairing it. - */ - void disconnectClient(resp_https_t response, req_https_t request) { - if (!check_content_type(response, request, "application/json")) { - return; - } - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - - try { - const nlohmann::json input_tree = nlohmann::json::parse(ss); - nlohmann::json output_tree; - const std::string uuid = input_tree.value("uuid", ""); - output_tree["status"] = nvhttp::disconnect_client(uuid); - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "DisconnectClient: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Unpair a client. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * The body for the POST request should be JSON serialized in the following format: - * @code{.json} - * { - * "uuid": "", - * "name": "", - * "display_mode": "1920x1080x59.94", - * "do": [ { "cmd": "", "elevated": false }, ... ], - * "undo": [ { "cmd": "", "elevated": false }, ... ], - * "perm": - * } - * @endcode - */ - void updateClient(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - nlohmann::json output_tree; - std::string uuid = input_tree.value("uuid", ""); - std::optional hdr_profile; - if (input_tree.contains("hdr_profile")) { - if (input_tree["hdr_profile"].is_null()) { - hdr_profile = std::string {}; - } else { - hdr_profile = input_tree.value("hdr_profile", ""); - } - } - - const bool has_extended_fields = - input_tree.contains("name") || - input_tree.contains("display_mode") || - input_tree.contains("output_name_override") || - input_tree.contains("always_use_virtual_display") || - input_tree.contains("virtual_display_mode") || - input_tree.contains("virtual_display_layout") || - input_tree.contains("config_overrides") || - input_tree.contains("prefer_10bit_sdr") || - input_tree.contains("enable_legacy_ordering") || - input_tree.contains("allow_client_commands") || - input_tree.contains("perm") || - input_tree.contains("do") || - input_tree.contains("undo"); - - if (!has_extended_fields && hdr_profile.has_value()) { - output_tree["status"] = nvhttp::set_client_hdr_profile(uuid, hdr_profile.value()); - send_response(response, output_tree); - return; - } - - std::string name = input_tree.value("name", ""); - std::string display_mode = input_tree.value("display_mode", ""); - std::string output_name_override = input_tree.value("output_name_override", ""); - bool enable_legacy_ordering = input_tree.value("enable_legacy_ordering", true); - bool allow_client_commands = input_tree.value("allow_client_commands", true); - bool always_use_virtual_display = input_tree.value("always_use_virtual_display", false); - std::optional prefer_10bit_sdr; - if (input_tree.contains("prefer_10bit_sdr") && !input_tree["prefer_10bit_sdr"].is_null()) { - prefer_10bit_sdr = util::get_non_string_json_value(input_tree, "prefer_10bit_sdr", false); - } else { - prefer_10bit_sdr.reset(); - } - std::optional> config_overrides; - if (input_tree.contains("config_overrides")) { - if (input_tree["config_overrides"].is_null()) { - config_overrides = std::unordered_map {}; - } else if (input_tree["config_overrides"].is_object()) { - std::unordered_map overrides; - for (const auto &item : input_tree["config_overrides"].items()) { - std::string key = item.key(); - if (key == "nvenc_force_split_encode") { - key = "nvenc_split_encode"; - } - const auto &val = item.value(); - if (key.empty() || val.is_null()) { - continue; - } - std::string encoded; - if (val.is_string()) { - encoded = val.get(); - } else { - encoded = val.dump(); - } - overrides[key] = std::move(encoded); - } - config_overrides = std::move(overrides); - } - } - std::string virtual_display_mode = input_tree.value("virtual_display_mode", ""); - std::string virtual_display_layout = input_tree.value("virtual_display_layout", ""); - auto do_cmds = nvhttp::extract_command_entries(input_tree, "do"); - auto undo_cmds = nvhttp::extract_command_entries(input_tree, "undo"); - auto perm = static_cast(input_tree.value("perm", static_cast(crypto::PERM::_no)) & static_cast(crypto::PERM::_all)); - bool updated = nvhttp::update_device_info( - uuid, - name, - display_mode, - output_name_override, - do_cmds, - undo_cmds, - perm, - enable_legacy_ordering, - allow_client_commands, - always_use_virtual_display, - virtual_display_mode, - virtual_display_layout, - prefer_10bit_sdr - ); - if (config_overrides.has_value() || hdr_profile.has_value()) { - updated = nvhttp::update_device_info( - uuid, - name, - display_mode, - output_name_override, - always_use_virtual_display, - virtual_display_mode, - virtual_display_layout, - std::move(config_overrides), - prefer_10bit_sdr, - hdr_profile - ) - && updated; - } - output_tree["status"] = updated; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "Update Client: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Unpair a client. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * The body for the POST request should be JSON serialized in the following format: - * @code{.json} - * { - * "uuid": "" - * } - * @endcode - * - * @api_examples{/api/clients/unpair| POST| {"uuid":"1234"}} - */ - void unpair(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - nlohmann::json output_tree; - std::string uuid = input_tree.value("uuid", ""); - output_tree["status"] = nvhttp::unpair_client(uuid); - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "Unpair: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Unpair all clients. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/clients/unpair-all| POST| null} - */ - void unpairAll(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - nvhttp::erase_all_clients(); - proc::proc.terminate(); - nlohmann::json output_tree; - output_tree["status"] = true; - send_response(response, output_tree); - } - - /** - * @brief Get the configuration settings. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void getConfig(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - nlohmann::json output_tree; - output_tree["status"] = true; - output_tree["platform"] = SUNSHINE_PLATFORM; - output_tree["version"] = PROJECT_VERSION; -#ifdef _WIN32 - output_tree["vdisplayStatus"] = (int) proc::vDisplayDriverStatus; -#endif - auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); - for (auto &[name, value] : vars) { - output_tree[name] = value; - } - send_response(response, output_tree); - } - - /** - * @brief Get immutables metadata about the server. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/meta| GET| null} - */ - void getMetadata(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - nlohmann::json output_tree; - output_tree["status"] = true; - output_tree["platform"] = SUNSHINE_PLATFORM; - output_tree["version"] = PROJECT_VERSION; - output_tree["commit"] = PROJECT_VERSION_COMMIT; -#ifdef PROJECT_VERSION_PRERELEASE - output_tree["prerelease"] = PROJECT_VERSION_PRERELEASE; -#else - output_tree["prerelease"] = ""; -#endif -#ifdef PROJECT_VERSION_BRANCH - output_tree["branch"] = PROJECT_VERSION_BRANCH; -#else - output_tree["branch"] = "unknown"; -#endif - // Build/release date provided by CMake (ISO 8601 when available) - output_tree["release_date"] = PROJECT_RELEASE_DATE; -#if defined(_WIN32) - try { - const auto gpus = platf::enumerate_gpus(); - if (!gpus.empty()) { - nlohmann::json gpu_array = nlohmann::json::array(); - bool has_nvidia = false; - bool has_amd = false; - bool has_intel = false; - - for (const auto &gpu : gpus) { - nlohmann::json gpu_entry; - gpu_entry["description"] = gpu.description; - gpu_entry["vendor_id"] = gpu.vendor_id; - gpu_entry["device_id"] = gpu.device_id; - gpu_entry["dedicated_video_memory"] = gpu.dedicated_video_memory; - gpu_array.push_back(std::move(gpu_entry)); - - switch (gpu.vendor_id) { - case 0x10DE: // NVIDIA - has_nvidia = true; - break; - case 0x1002: // AMD/ATI - case 0x1022: // AMD alternative PCI vendor ID (APUs) - has_amd = true; - break; - case 0x8086: // Intel - has_intel = true; - break; - default: - break; - } - } - - output_tree["gpus"] = std::move(gpu_array); - output_tree["has_nvidia_gpu"] = has_nvidia; - output_tree["has_amd_gpu"] = has_amd; - output_tree["has_intel_gpu"] = has_intel; - } - - const auto version = platf::query_windows_version(); - if (!version.display_version.empty()) { - output_tree["windows_display_version"] = version.display_version; - } - if (!version.release_id.empty()) { - output_tree["windows_release_id"] = version.release_id; - } - if (!version.product_name.empty()) { - output_tree["windows_product_name"] = version.product_name; - } - if (!version.current_build.empty()) { - output_tree["windows_current_build"] = version.current_build; - } - if (version.build_number.has_value()) { - output_tree["windows_build_number"] = version.build_number.value(); - } - if (version.major_version.has_value()) { - output_tree["windows_major_version"] = version.major_version.value(); - } - if (version.minor_version.has_value()) { - output_tree["windows_minor_version"] = version.minor_version.value(); - } - } catch (...) { - // Non-fatal; keep metadata response minimal if enumeration fails. - } -#endif - send_response(response, output_tree); - } - - /** - * @brief Get the locale setting. This endpoint does not require authentication. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/configLocale| GET| null} - */ - void getLocale(resp_https_t response, req_https_t request) { - print_req(request); - - nlohmann::json output_tree; - output_tree["status"] = true; - output_tree["locale"] = config::sunshine.locale; - send_response(response, output_tree); - } - - /** - * @brief Save the configuration settings. - * @param response The HTTP response object. - * @param request The HTTP request object. - * The body for the post request should be JSON serialized in the following format: - * @code{.json} - * { - * "key": "value" - * } - * @endcode - * - * @attention{It is recommended to ONLY save the config settings that differ from the default behavior.} - * - * @api_examples{/api/config| POST| {"key":"value"}} - */ - void saveConfig(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - // TODO: Input Validation - std::stringstream config_stream; - nlohmann::json output_tree; - nlohmann::json input_tree = nlohmann::json::parse(ss); - std::set changed_keys; - for (const auto &[k, v] : input_tree.items()) { - changed_keys.insert(k); - if (v.is_null() || (v.is_string() && v.get().empty())) { - continue; - } - - // v.dump() will dump valid json, which we do not want for strings in the config right now - // we should migrate the config file to straight json and get rid of all this nonsense - config_stream << k << " = " << (v.is_string() ? v.get() : v.dump()) << std::endl; - } - file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); - - // Detect restart-required keys - static const std::set restart_required_keys = { - "port", - "address_family", - "upnp", - "pkey", - "cert" - }; - bool restart_required = false; - for (const auto &k : changed_keys) { - if (restart_required_keys.count(k)) { - restart_required = true; - break; - } - } - - bool applied_now = false; - bool deferred = false; - - if (!restart_required) { - if (can_hot_apply_during_session(changed_keys) || !has_active_stream_sessions()) { - // Apply immediately - config::apply_config_now(); - applied_now = true; - } else { - config::mark_deferred_reload(); - deferred = true; - } - } - - output_tree["status"] = true; - output_tree["appliedNow"] = applied_now; - output_tree["deferred"] = deferred; - output_tree["restartRequired"] = restart_required; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "SaveConfig: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Partial update of configuration (PATCH /api/config). - * Merges provided JSON object into the existing key=value style config file. - * Removes keys when value is null or an empty string. Detects whether a - * restart is required and attempts to apply immediately when safe. - */ - void patchConfig(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json")) { - return; - } - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json output_tree; - nlohmann::json patch_tree = nlohmann::json::parse(ss); - if (!patch_tree.is_object()) { - bad_request(response, request, "PATCH body must be a JSON object"); - return; - } - - // Load existing config into a map - std::unordered_map current = config::parse_config( - file_handler::read_file(config::sunshine.config_file.c_str()) - ); - - // Track which keys are being modified to detect restart requirements - std::set changed_keys; - - for (auto it = patch_tree.begin(); it != patch_tree.end(); ++it) { - const std::string key = it.key(); - const nlohmann::json &val = it.value(); - changed_keys.insert(key); - - // Remove key when explicitly null or empty string - if (val.is_null() || (val.is_string() && val.get().empty())) { - auto curIt = current.find(key); - if (curIt != current.end()) { - current.erase(curIt); - } - continue; - } - - // Persist value: strings are raw, non-strings are dumped as JSON - if (val.is_string()) { - current[key] = val.get(); - } else { - current[key] = val.dump(); - } - } - - // Write back full merged config file - std::stringstream config_stream; - for (const auto &kv : current) { - config_stream << kv.first << " = " << kv.second << std::endl; - } - file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); - - // Detect restart-required keys - static const std::set restart_required_keys = { - "port", - "address_family", - "upnp", - "pkey", - "cert" - }; - bool restart_required = false; - for (const auto &k : changed_keys) { - if (restart_required_keys.count(k)) { - restart_required = true; - break; - } - } - - bool applied_now = false; - bool deferred = false; - if (!restart_required) { - if (can_hot_apply_during_session(changed_keys) || !has_active_stream_sessions()) { - // Apply immediately - config::apply_config_now(); - applied_now = true; - } else { - config::mark_deferred_reload(); - deferred = true; - } - } - - output_tree["status"] = true; - output_tree["appliedNow"] = applied_now; - output_tree["deferred"] = deferred; - output_tree["restartRequired"] = restart_required; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "PatchConfig: "sv << e.what(); - bad_request(response, request, e.what()); - return; - } - } - - // Lightweight session status for UI messaging - void getSessionStatus(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - - nlohmann::json output_tree; - const int active = rtsp_stream::session_count(); - const bool app_running = proc::proc.running() > 0; - output_tree["activeSessions"] = active; - output_tree["appRunning"] = app_running; - output_tree["appName"] = app_running ? proc::proc.get_last_run_app_name() : ""; - output_tree["paused"] = app_running && active == 0; - output_tree["status"] = true; - send_response(response, output_tree); - } - - // Live host system performance counters (CPU/GPU/RAM/VRAM/temps). - void getHostStats(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - - send_response(response, host_stats_to_json(host_stats::latest())); - } - - // Static host info — model strings + total RAM/VRAM, sampled once. - void getHostInfo(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - - send_response(response, host_info_to_json(host_stats::info())); - } - - - void listRTSPSessions(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - nlohmann::json output; - output["sessions"] = nlohmann::json::array(); - for (const auto &info : stream::get_all_session_info()) { - output["sessions"].push_back(rtsp_session_to_json(info)); - } - send_response(response, output); - } - - void listWebRTCSessions(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - nlohmann::json output; - output["sessions"] = nlohmann::json::array(); - for (const auto &session : webrtc_stream::list_sessions()) { - output["sessions"].push_back(webrtc_session_to_json(session)); - } - send_response(response, output); - } - - // ── Session History endpoints ──────────────────────────────────── - - void listSessionHistory(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - int limit = 25; - int offset = 0; - auto query = request->parse_query_string(); - auto it_limit = query.find("limit"); - if (it_limit != query.end()) { - try { limit = std::stoi(it_limit->second); } catch (...) {} - } - auto it_offset = query.find("offset"); - if (it_offset != query.end()) { - try { offset = std::stoi(it_offset->second); } catch (...) {} - } - limit = std::clamp(limit, 1, 100); - offset = std::max(offset, 0); - - nlohmann::json output; - output["sessions"] = nlohmann::json::array(); - for (const auto &s : session_history::list_sessions(limit, offset)) { - output["sessions"].push_back(session_summary_to_json(s)); - } - output["history_status"] = history_status_to_json(session_history::get_history_status()); - send_response(response, output); - } - - void getSessionHistoryDetail(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - auto uuid = request->path_match[1].str(); - const auto query = request->parse_query_string(); - const bool include_all = [&query]() { - auto it = query.find("full"); - if (it == query.end()) { - return false; - } - return it->second == "1" || it->second == "true" || it->second == "yes"; - }(); - - auto detail = session_history::get_session_detail(uuid, include_all); - if (!detail) { - not_found(response, request); - return; - } - - auto output = session_detail_to_json(*detail); - output["history_status"] = history_status_to_json(session_history::get_history_status()); - send_response(response, output); - } - - void deleteSessionHistory(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - auto uuid = request->path_match[1].str(); - auto result = session_history::delete_session(uuid); - switch (result) { - case session_history::delete_result_e::deleted: - break; - case session_history::delete_result_e::not_found: - not_found(response, request); - return; - case session_history::delete_result_e::active_session: - conflict(response, "Cannot delete an active session"); - return; - case session_history::delete_result_e::unavailable: - service_unavailable(response, "Session history subsystem unavailable"); - return; - case session_history::delete_result_e::timeout: - gateway_timeout(response, "Timed out waiting for session history delete"); - return; - case session_history::delete_result_e::failed: - service_unavailable(response, "Session history delete failed"); - return; - } - - nlohmann::json output; - output["status"] = "ok"; - output["uuid"] = uuid; - send_response(response, output); - } - - void getActiveSessionHistory(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - nlohmann::json output; - output["sessions"] = nlohmann::json::array(); - for (const auto &as : session_history::get_active_sessions()) { - output["sessions"].push_back(active_session_to_json(as)); - } - send_response(response, output); - } - - void createWebRTCSession(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - BOOST_LOG(debug) << "WebRTC: create session request received"; - - webrtc_stream::SessionOptions options; - std::stringstream ss; - ss << request->content.rdbuf(); - auto body = ss.str(); - if (!body.empty()) { - if (!check_content_type(response, request, "application/json")) { - return; - } - try { - nlohmann::json input = nlohmann::json::parse(body); - if (input.contains("audio")) { - options.audio = input.at("audio").get(); - } - if (input.contains("host_audio")) { - options.host_audio = input.at("host_audio").get(); - } - if (input.contains("video")) { - options.video = input.at("video").get(); - } - if (input.contains("encoded")) { - options.encoded = input.at("encoded").get(); - } - if (input.contains("width")) { - const int width = input.at("width").get(); - if (width > 0) { - options.width = width; - } - } - if (input.contains("height")) { - const int height = input.at("height").get(); - if (height > 0) { - options.height = height; - } - } - if (input.contains("fps")) { - const int fps = input.at("fps").get(); - if (fps > 0) { - options.fps = fps; - } - } - if (input.contains("bitrate_kbps")) { - options.bitrate_kbps = input.at("bitrate_kbps").get(); - } - if (input.contains("codec")) { - options.codec = input.at("codec").get(); - } - if (input.contains("hdr")) { - options.hdr = input.at("hdr").get(); - } - if (input.contains("audio_channels")) { - options.audio_channels = input.at("audio_channels").get(); - } - if (input.contains("audio_codec")) { - options.audio_codec = input.at("audio_codec").get(); - } - if (input.contains("profile")) { - options.profile = input.at("profile").get(); - } - if (input.contains("app_id")) { - options.app_id = input.at("app_id").get(); - } - if (input.contains("resume")) { - options.resume = input.at("resume").get(); - } - if (input.contains("video_pacing_mode")) { - options.video_pacing_mode = input.at("video_pacing_mode").get(); - } - if (input.contains("video_pacing_slack_ms")) { - options.video_pacing_slack_ms = input.at("video_pacing_slack_ms").get(); - } - if (input.contains("video_max_frame_age_ms")) { - options.video_max_frame_age_ms = input.at("video_max_frame_age_ms").get(); - } - - if (options.codec) { - auto lower = *options.codec; - boost::algorithm::to_lower(lower); - if (lower != "h264" && lower != "hevc" && lower != "av1") { - bad_request(response, request, "Unsupported codec"); - return; - } - options.codec = std::move(lower); - } - if (options.audio_codec) { - auto lower = *options.audio_codec; - boost::algorithm::to_lower(lower); - if (lower != "opus" && lower != "aac") { - bad_request(response, request, "Unsupported audio codec"); - return; - } - options.audio_codec = std::move(lower); - } - if (options.audio_channels) { - int channels = *options.audio_channels; - if (channels != 2 && channels != 6 && channels != 8) { - bad_request(response, request, "Unsupported audio channel count"); - return; - } - } - if (options.video_pacing_mode) { - auto lower = *options.video_pacing_mode; - boost::algorithm::to_lower(lower); - if (lower == "smooth") { - lower = "smoothness"; - } - if (lower != "latency" && lower != "balanced" && lower != "smoothness") { - bad_request(response, request, "Unsupported video pacing mode"); - return; - } - options.video_pacing_mode = std::move(lower); - } - if (options.video_pacing_slack_ms) { - const int slack_ms = *options.video_pacing_slack_ms; - if (slack_ms < 0 || slack_ms > 10) { - bad_request(response, request, "video_pacing_slack_ms must be between 0 and 10"); - return; - } - } - if (options.video_max_frame_age_ms) { - const int max_age_ms = *options.video_max_frame_age_ms; - if (max_age_ms < 5 || max_age_ms > 250) { - bad_request(response, request, "video_max_frame_age_ms must be between 5 and 250"); - return; - } - } - if (options.hdr.value_or(false)) { - if (!options.encoded) { - bad_request(response, request, "HDR requires encoded video for WebRTC sessions"); - return; - } - if (!options.codec || (*options.codec != "hevc" && *options.codec != "av1")) { - bad_request(response, request, "HDR requires HEVC or AV1 video encoding"); - return; - } - } - if (options.hdr.value_or(false)) { - if (!options.encoded) { - bad_request(response, request, "HDR requires encoded video for WebRTC sessions"); - return; - } - if (!options.codec || (*options.codec != "hevc" && *options.codec != "av1")) { - bad_request(response, request, "HDR requires HEVC or AV1 video encoding"); - return; - } - } - } catch (const std::exception &e) { - bad_request(response, request, e.what()); - return; - } - } - - BOOST_LOG(debug) << "WebRTC: creating session"; - if (auto error = webrtc_stream::ensure_capture_started(options)) { -#ifdef _WIN32 - // Lifecycle gap: if capture start fails after a virtual display was created/applied but - // before a session exists, ensure we don't leave the virtual display behind. - if (rtsp_stream::session_count() == 0 && !webrtc_stream::has_active_sessions()) { - (void) platf::virtual_display_cleanup::run( - "webrtc_session_start_failed", - config::video.dd.config_revert_on_disconnect - ); - } -#endif - bad_request(response, request, error->c_str()); - return; - } - auto session = webrtc_stream::create_session(options); - if (!session) { - webrtc_stream::shutdown_all_sessions(); - service_unavailable(response, "Shutdown in progress"); - return; - } - BOOST_LOG(debug) << "WebRTC: session created id=" << session->id; - nlohmann::json output; - output["status"] = true; - output["session"] = webrtc_session_to_json(*session); - output["cert_fingerprint"] = webrtc_stream::get_server_cert_fingerprint(); - output["cert_pem"] = webrtc_stream::get_server_cert_pem(); - output["ice_servers"] = load_webrtc_ice_servers(); - send_response(response, output); - } - - void getWebRTCSession(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - auto session = webrtc_stream::get_session(session_id); - if (!session) { - bad_request(response, request, "Session not found"); - return; - } - - nlohmann::json output; - output["session"] = webrtc_session_to_json(*session); - send_response(response, output); - } - - void deleteWebRTCSession(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - nlohmann::json output; - if (webrtc_stream::close_session(session_id)) { - output["status"] = true; - } else { - output["error"] = "Session not found"; - } - send_response(response, output); - } - - void postWebRTCOffer(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - if (!check_content_type(response, request, "application/json")) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json input = nlohmann::json::parse(ss.str()); - auto sdp = input.at("sdp").get(); - auto type = input.value("type", "offer"); - nlohmann::json output; - if (!webrtc_stream::set_remote_offer(session_id, sdp, type)) { - if (!webrtc_stream::get_session(session_id)) { - output["error"] = "Session not found"; - } else { - output["error"] = "Failed to process offer"; - } - send_response(response, output); - return; - } - - std::string answer_sdp; - std::string answer_type; - if (webrtc_stream::wait_for_local_answer(session_id, answer_sdp, answer_type, std::chrono::seconds {3})) { - output["status"] = true; - output["answer_ready"] = true; - output["sdp"] = answer_sdp; - output["type"] = answer_type; - } else { - output["status"] = true; - output["answer_ready"] = false; - output["sdp"] = nullptr; - output["type"] = nullptr; - } - send_response(response, output); - } catch (const std::exception &e) { - bad_request(response, request, e.what()); - } - } - - void getWebRTCAnswer(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - std::string answer_sdp; - std::string answer_type; - nlohmann::json output; - if (webrtc_stream::get_local_answer(session_id, answer_sdp, answer_type)) { - output["status"] = true; - output["answer_ready"] = true; - output["sdp"] = answer_sdp; - output["type"] = answer_type; - } else { - output["status"] = false; - output["error"] = "Answer not ready"; - } - send_response(response, output); - } - - void postWebRTCIce(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - if (!check_content_type(response, request, "application/json")) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json input = nlohmann::json::parse(ss.str()); - nlohmann::json output; - constexpr std::size_t kMaxCandidatesPerRequest = 256; - std::vector candidates; - if (input.is_array()) { - candidates.reserve(std::min(input.size(), kMaxCandidatesPerRequest)); - for (const auto &entry : input) { - if (candidates.size() >= kMaxCandidatesPerRequest) { - break; - } - candidates.push_back(entry); - } - } else if (input.contains("candidates") && input["candidates"].is_array()) { - const auto &arr = input["candidates"]; - candidates.reserve(std::min(arr.size(), kMaxCandidatesPerRequest)); - for (const auto &entry : arr) { - if (candidates.size() >= kMaxCandidatesPerRequest) { - break; - } - candidates.push_back(entry); - } - } else { - candidates.push_back(input); - } - - bool ok = true; - for (const auto &entry : candidates) { - if (!entry.is_object()) { - continue; - } - auto mid = entry.value("sdpMid", ""); - auto mline_index = entry.value("sdpMLineIndex", -1); - auto candidate = entry.value("candidate", ""); - if (candidate.empty()) { - continue; - } - if (!webrtc_stream::add_ice_candidate(session_id, std::move(mid), mline_index, std::move(candidate))) { - ok = false; - break; - } - } - if (ok) { - output["status"] = true; - } else { - output["error"] = "Session not found"; - } - send_response(response, output); - } catch (const std::exception &e) { - bad_request(response, request, e.what()); - } - } - - void getWebRTCIce(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - std::size_t since = 0; - auto query = request->parse_query_string(); - auto since_it = query.find("since"); - if (since_it != query.end()) { - try { - since = static_cast(std::stoull(since_it->second)); - } catch (...) { - bad_request(response, request, "Invalid since parameter"); - return; - } - } - - auto candidates = webrtc_stream::get_local_candidates(session_id, since); - nlohmann::json output; - output["status"] = true; - output["candidates"] = nlohmann::json::array(); - std::size_t last_index = since; - for (const auto &candidate : candidates) { - nlohmann::json item; - item["sdpMid"] = candidate.mid; - item["sdpMLineIndex"] = candidate.mline_index; - item["candidate"] = candidate.candidate; - item["index"] = candidate.index; - output["candidates"].push_back(std::move(item)); - last_index = std::max(last_index, candidate.index); - } - output["next_since"] = last_index; - send_response(response, output); - } - - void getWebRTCIceStream(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - std::string session_id; - if (request->path_match.size() > 1) { - session_id = request->path_match[1]; - } - - if (!webrtc_stream::get_session(session_id)) { - bad_request(response, request, "Session not found"); - return; - } - - std::size_t since = 0; - auto query = request->parse_query_string(); - auto since_it = query.find("since"); - if (since_it != query.end()) { - try { - since = static_cast(std::stoull(since_it->second)); - } catch (...) { - bad_request(response, request, "Invalid since parameter"); - return; - } - } - - std::thread([response, session_id, since]() mutable { - response->close_connection_after_response = true; - - response->write({{"Content-Type", "text/event-stream"}, {"Cache-Control", "no-cache"}, {"Connection", "keep-alive"}, {"Access-Control-Allow-Origin", get_cors_origin()}}); - - std::promise header_error; - response->send([&header_error](const SimpleWeb::error_code &ec) { - header_error.set_value(static_cast(ec)); - }); - if (header_error.get_future().get()) { - return; - } - - auto last_index = since; - auto last_keepalive = std::chrono::steady_clock::now(); - - while (true) { - auto candidates = webrtc_stream::get_local_candidates(session_id, last_index); - for (const auto &candidate : candidates) { - nlohmann::json payload; - payload["sdpMid"] = candidate.mid; - payload["sdpMLineIndex"] = candidate.mline_index; - payload["candidate"] = candidate.candidate; - - *response << "event: candidate\n"; - *response << "id: " << candidate.index << "\n"; - *response << "data: " << payload.dump() << "\n\n"; - - std::promise error; - response->send([&error](const SimpleWeb::error_code &ec) { - error.set_value(static_cast(ec)); - }); - if (error.get_future().get()) { - return; - } - - last_index = std::max(last_index, candidate.index); - } - - auto now = std::chrono::steady_clock::now(); - if (now - last_keepalive > std::chrono::seconds(2)) { - *response << "event: keepalive\n"; - *response << "data: {}\n\n"; - std::promise error; - response->send([&error](const SimpleWeb::error_code &ec) { - error.set_value(static_cast(ec)); - }); - if (error.get_future().get()) { - return; - } - last_keepalive = now; - } - - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - } - }).detach(); - } - - void getWebRTCCert(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - nlohmann::json output; - output["cert_fingerprint"] = webrtc_stream::get_server_cert_fingerprint(); - output["cert_pem"] = webrtc_stream::get_server_cert_pem(); - send_response(response, output); - } - - /** - * @brief Upload a cover image. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}} - */ - void uploadCover(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - std::stringstream ss; - - ss << request->content.rdbuf(); - try { - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - nlohmann::json output_tree; - std::string key = input_tree.value("key", ""); - if (key.empty()) { - bad_request(response, request, "Cover key is required"); - return; - } - std::string url = input_tree.value("url", ""); - const std::string coverdir = platf::appdata().string() + "/covers/"; - file_handler::make_directory(coverdir); - - // Final destination PNG path - const std::string dest_png = coverdir + http::url_escape(key) + ".png"; - - // Helper to check PNG magic header - auto file_is_png = [](const std::string &p) -> bool { - std::ifstream f(p, std::ios::binary); - - if (!f) { - return false; - } - unsigned char sig[8] {}; - f.read(reinterpret_cast(sig), 8); - static const unsigned char pngsig[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; - - return f.gcount() == 8 && std::equal(std::begin(sig), std::end(sig), std::begin(pngsig)); - }; - - // Build a temp source path (extension based on URL if available) - auto ext_from_url = [](std::string u) -> std::string { - auto qpos = u.find_first_of("?#"); - - if (qpos != std::string::npos) { - u = u.substr(0, qpos); - } - auto slash = u.find_last_of('/'); - if (slash != std::string::npos) { - u = u.substr(slash + 1); - } - auto dot = u.find_last_of('.'); - if (dot == std::string::npos) { - return std::string {".img"}; - } - std::string e = u.substr(dot); - // sanitize extension - if (e.size() > 8) { - return std::string {".img"}; - } - for (char &c : e) { - c = static_cast(std::tolower(static_cast(c))); - } - - return e; - }; - - std::string src_tmp; - if (!url.empty()) { - if (http::url_get_host(url) != "images.igdb.com") { - bad_request(response, request, "Only images.igdb.com is allowed"); - return; - } - const std::string ext = ext_from_url(url); - src_tmp = coverdir + http::url_escape(key) + "_src" + ext; - if (!http::download_file(url, src_tmp)) { - bad_request(response, request, "Failed to download cover"); - return; - } - } - - bool converted = false; -#ifdef _WIN32 - { - // Convert using WIC helper; falls back to copying if already PNG - std::wstring src_w(src_tmp.begin(), src_tmp.end()); - std::wstring dst_w(dest_png.begin(), dest_png.end()); - converted = platf::img::convert_to_png_96dpi(src_w, dst_w); - if (!converted && file_is_png(src_tmp)) { - std::error_code ec {}; - std::filesystem::copy_file(src_tmp, dest_png, std::filesystem::copy_options::overwrite_existing, ec); - converted = !ec.operator bool(); - } - } -#else - // Non-Windows: we can’t transcode here; accept only already-PNG data - if (file_is_png(src_tmp)) { - std::error_code ec {}; - - std::filesystem::rename(src_tmp, dest_png, ec); - if (ec) { - // If rename fails (cross-device), try copy - std::filesystem::copy_file(src_tmp, dest_png, std::filesystem::copy_options::overwrite_existing, ec); - if (!ec) { - std::filesystem::remove(src_tmp); - converted = true; - } - } else { - converted = true; - } - } else { - // Leave a clear error on non-Windows when not PNG - bad_request(response, request, "Cover must be PNG on this platform"); - return; - } -#endif - - // Cleanup temp source file when possible - if (!src_tmp.empty()) { - std::error_code del_ec {}; - - std::filesystem::remove(src_tmp, del_ec); - } - - if (!converted) { - bad_request(response, request, "Failed to convert cover to PNG"); - return; - } - - output_tree["status"] = true; - output_tree["path"] = dest_png; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "UploadCover: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Purge all auto-synced Playnite applications (playnite-managed == "auto"). - * @api_examples{/api/apps/purge_autosync| POST| null} - */ - void purgeAutoSyncedApps(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - try { - nlohmann::json output_tree; - nlohmann::json new_apps = nlohmann::json::array(); - std::string file = file_handler::read_file(config::stream.file_apps.c_str()); - nlohmann::json file_tree = nlohmann::json::parse(file); - auto &apps_node = file_tree["apps"]; - - int removed = 0; - for (auto &app : apps_node) { - std::string managed = app.contains("playnite-managed") && app["playnite-managed"].is_string() ? app["playnite-managed"].get() : std::string(); - if (managed == "auto") { - ++removed; - continue; - } - new_apps.push_back(app); - } - - file_tree["apps"] = new_apps; - confighttp::refresh_client_apps_cache(file_tree); - - output_tree["status"] = true; - output_tree["removed"] = removed; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "purgeAutoSyncedApps: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Get the logs from the log file. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/logs| GET| null} - */ - void getLogs(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - auto read_sunshine_log = [](std::string &out) { - auto log_path = logging::current_log_file(); - if (!log_path.empty()) { - const std::string log_path_str = log_path.string(); - out = file_handler::read_file(log_path_str.c_str()); - } - }; - - std::string content; - std::string source = "sunshine"; - const auto query = request->parse_query_string(); - if (const auto it = query.find("source"); it != query.end() && !it->second.empty()) { - source = it->second; - boost::algorithm::to_lower(source); - } - - bool handled = false; - if (source == "sunshine") { - read_sunshine_log(content); - handled = true; - } -#ifdef _WIN32 - else if (is_helper_log_source(source)) { - handled = true; - read_helper_log(source, content); - } -#endif - if (!handled) { - read_sunshine_log(content); - } - SimpleWeb::CaseInsensitiveMultimap headers; - std::string contentType = "text/plain"; -#ifdef _WIN32 - contentType += "; charset="; - contentType += currentCodePageToCharset(); -#endif - headers.emplace("Content-Type", contentType); - headers.emplace("X-Frame-Options", "DENY"); - headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); - response->write(success_ok, content, headers); - } - -#ifdef _WIN32 -#endif - - /** - * @brief Update existing credentials. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * The body for the POST request should be JSON serialized in the following format: - * @code{.json} - * { - * "currentUsername": "Current Username", - * "currentPassword": "Current Password", - * "newUsername": "New Username", - * "newPassword": "New Password", - * "confirmNewPassword": "Confirm New Password" - * } - * @endcode - * - * @api_examples{/api/password| POST| {"currentUsername":"admin","currentPassword":"admin","newUsername":"admin","newPassword":"admin","confirmNewPassword":"admin"}} - */ - void savePassword(resp_https_t response, req_https_t request) { - if ((!config::sunshine.username.empty() && !authenticate(response, request)) || !validateContentType(response, request, "application/json")) { - return; - } - print_req(request); - std::vector errors; - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - nlohmann::json output_tree; - std::string username = input_tree.value("currentUsername", ""); - std::string newUsername = input_tree.value("newUsername", ""); - std::string password = input_tree.value("currentPassword", ""); - std::string newPassword = input_tree.value("newPassword", ""); - std::string confirmPassword = input_tree.value("confirmNewPassword", ""); - if (newUsername.empty()) { - newUsername = username; - } - if (newUsername.empty()) { - errors.push_back("Invalid Username"); - } else { - auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); - if (config::sunshine.username.empty() || - (boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password)) { - if (newPassword.empty() || newPassword != confirmPassword) { - errors.push_back("Password Mismatch"); - } else { - if (http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword)) { - service_unavailable(response, "Unable to write credentials file"); - return; - } - if (http::reload_user_creds(config::sunshine.credentials_file)) { - service_unavailable(response, "Unable to reload credentials file"); - return; - } - sessionCookie.clear(); // force re-login - output_tree["status"] = true; - } - } else { - errors.push_back("Invalid Current Credentials"); - } - } - if (!errors.empty()) { - std::string error = std::accumulate(errors.begin(), errors.end(), std::string(), [](const std::string &a, const std::string &b) { - return a.empty() ? b : a + ", " + b; - }); - bad_request(response, request, error); - return; - } - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "SavePassword: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Get a one-time password (OTP). - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/otp| GET| null} - */ - void getOTP(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - nlohmann::json output_tree; - try { - std::stringstream ss; - ss << request->content.rdbuf(); - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - - std::string passphrase = input_tree.value("passphrase", ""); - if (passphrase.empty()) { - throw std::runtime_error("Passphrase not provided!"); - } - if (passphrase.size() < 4) { - throw std::runtime_error("Passphrase too short!"); - } - - std::string deviceName = input_tree.value("deviceName", ""); - output_tree["otp"] = nvhttp::request_otp(passphrase, deviceName); - output_tree["ip"] = platf::get_local_ip_for_gateway(); - output_tree["name"] = config::nvhttp.sunshine_name; - output_tree["status"] = true; - output_tree["message"] = "OTP created, effective within 3 minutes."; - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "OTP creation failed: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Send a PIN code to the host. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * The body for the POST request should be JSON serialized in the following format: - * @code{.json} - * { - * "pin": "", - * "name": "Friendly Client Name" - * } - * @endcode - * - * @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}} - */ - void savePin(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - try { - std::stringstream ss; - ss << request->content.rdbuf(); - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - nlohmann::json output_tree; - std::string pin = input_tree.value("pin", ""); - std::string name = input_tree.value("name", ""); - output_tree["status"] = nvhttp::pin(pin, name); - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "SavePin: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Reset the display device persistence. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/reset-display-device-persistence| POST| null} - */ - void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - nlohmann::json output_tree; - output_tree["status"] = display_helper_integration::reset_persistence(); - send_response(response, output_tree); - } - -#ifdef _WIN32 - /** - * @brief Export the current Windows display settings as a golden restore snapshot. - * @api_examples{/api/display/export_golden| POST| {"status":true}} - */ - void postExportGoldenDisplay(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json")) { - return; - } - if (!authenticate(response, request)) { - return; - } - print_req(request); - nlohmann::json out; - try { - const bool ok = display_helper_integration::export_golden_restore(); - out["status"] = ok; - } catch (...) { - out["status"] = false; - } - send_response(response, out); - } -#endif - -#ifdef _WIN32 - // --- Golden snapshot helpers (Windows-only) --- - static bool file_exists_nofail(const std::filesystem::path &p) { - try { - std::error_code ec; - return std::filesystem::exists(p, ec); - } catch (...) { - return false; - } - } - - // Return candidate paths where the helper writes the golden snapshot. - // We probe both the active user's Roaming/Local AppData and the current - // process's CSIDL paths, mirroring the log bundle collection logic. - static std::vector golden_snapshot_candidates() { - std::vector out; - auto add_if = [&](const std::filesystem::path &base) { - if (!base.empty()) { - out.emplace_back(base / L"Sunshine" / L"display_golden_restore.json"); - } - }; - - try { - // Prefer the active user's known folders (impersonated) when available - try { - platf::dxgi::safe_token user_token; - user_token.reset(platf::dxgi::retrieve_users_token(false)); - auto add_known = [&](REFKNOWNFOLDERID id) { - PWSTR baseW = nullptr; - if (SUCCEEDED(SHGetKnownFolderPath(id, 0, user_token.get(), &baseW)) && baseW) { - add_if(std::filesystem::path(baseW)); - CoTaskMemFree(baseW); - } - }; - add_known(FOLDERID_RoamingAppData); - add_known(FOLDERID_LocalAppData); - } catch (...) { - // ignore - } - - // Also probe the current process's CSIDL APPDATA and LOCAL_APPDATA - auto add_csidl = [&](int csidl) { - wchar_t baseW[MAX_PATH] = {}; - if (SUCCEEDED(SHGetFolderPathW(nullptr, csidl, nullptr, SHGFP_TYPE_CURRENT, baseW))) { - add_if(std::filesystem::path(baseW)); - } - }; - add_csidl(CSIDL_APPDATA); - add_csidl(CSIDL_LOCAL_APPDATA); - add_csidl(CSIDL_COMMON_APPDATA); - } catch (...) { - // best-effort - } - return out; - } - - constexpr int kGoldenSnapshotLatestVersion = 2; - - struct golden_current_mode_t { - unsigned int width {}; - unsigned int height {}; - double refresh_hz {}; - }; - - struct golden_current_summary_t { - bool valid {false}; - bool active_virtual_display {false}; - std::set devices; - std::unordered_map modes; - std::unordered_map hdr; - std::unordered_map> origins; - std::string primary; - }; - - static std::string normalized_display_id(std::string id) { - id.erase(id.begin(), std::find_if(id.begin(), id.end(), [](unsigned char ch) { - return !std::isspace(ch); - })); - id.erase(std::find_if(id.rbegin(), id.rend(), [](unsigned char ch) { - return !std::isspace(ch); - }).base(), - id.end()); - std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { - return static_cast(std::tolower(ch)); - }); - return id; - } - - static bool contains_ci(const std::string &haystack, const std::string &needle) { - if (needle.empty()) { - return true; - } - if (haystack.size() < needle.size()) { - return false; - } - for (size_t i = 0; i + needle.size() <= haystack.size(); ++i) { - bool match = true; - for (size_t j = 0; j < needle.size(); ++j) { - if (std::tolower(static_cast(haystack[i + j])) != - std::tolower(static_cast(needle[j]))) { - match = false; - break; - } - } - if (match) { - return true; - } - } - return false; - } - - static bool equals_ci(const std::string &lhs, const std::string &rhs) { - return lhs.size() == rhs.size() && contains_ci(lhs, rhs); - } - - static bool is_virtual_display_device(const display_device::EnumeratedDevice &device) { - if (contains_ci(device.m_device_id, "SUDOVDA") || - contains_ci(device.m_device_id, "SUDOMAKER") || - contains_ci(device.m_display_name, "SUDOVDA") || - contains_ci(device.m_display_name, "SUDOMAKER") || - contains_ci(device.m_friendly_name, "SUDOVDA") || - contains_ci(device.m_friendly_name, "SUDOMAKER")) { - return true; - } - if (equals_ci(device.m_friendly_name, "SudoMaker Virtual Display Adapter")) { - return true; - } - return device.m_edid && equals_ci(device.m_edid->m_manufacturer_id, "SMK"); - } - - static bool is_active_display_device(const display_device::EnumeratedDevice &device) { - return device.m_info.has_value() || !device.m_display_name.empty(); - } - - static std::optional floating_to_double(const display_device::FloatingPoint &value) { - if (std::holds_alternative(value)) { - return std::get(value); - } - const auto &rat = std::get(value); - if (rat.m_denominator == 0) { - return std::nullopt; - } - return static_cast(rat.m_numerator) / static_cast(rat.m_denominator); - } - - static bool nearly_equal_refresh(double lhs, double rhs) { - if (!std::isfinite(lhs) || !std::isfinite(rhs)) { - return false; - } - const double diff = std::abs(lhs - rhs); - const double scale = std::max({1.0, std::abs(lhs), std::abs(rhs)}); - return diff <= scale * 1e-4; - } - - static std::optional read_json_file_nofail(const std::filesystem::path &path) { - try { - std::ifstream file(path, std::ios::binary); - if (!file.is_open()) { - return std::nullopt; - } - auto parsed = nlohmann::json::parse(file, nullptr, false); - if (parsed.is_discarded() || !parsed.is_object()) { - return std::nullopt; - } - return parsed; - } catch (...) { - return std::nullopt; - } - } - - static std::optional parse_snapshot_version(const nlohmann::json &root) { - auto it = root.find("snapshot_version"); - if (it == root.end() || !it->is_number_integer()) { - return std::nullopt; - } - int version = it->get(); - if (version < 1) { - return std::nullopt; - } - return version; - } - - static bool snapshot_has_layout_data(const nlohmann::json &root) { - auto it = root.find("layouts"); - if (it == root.end() || !it->is_object()) { - return false; - } - for (auto entry = it->begin(); entry != it->end(); ++entry) { - if (!entry.key().empty()) { - if (entry->is_number_integer()) { - return true; - } - if (entry->is_object()) { - auto rotation = entry->find("rotation"); - if (rotation != entry->end() && (rotation->is_number_integer() || rotation->is_string())) { - return true; - } - } - } - } - return false; - } - - static std::set snapshot_topology_devices(const nlohmann::json &root) { - std::set ids; - auto topology = root.find("topology"); - if (topology != root.end() && topology->is_array()) { - for (const auto &group : *topology) { - if (!group.is_array()) { - continue; - } - for (const auto &device : group) { - if (device.is_string()) { - auto id = normalized_display_id(device.get()); - if (!id.empty()) { - ids.insert(std::move(id)); - } - } - } - } - } - if (ids.empty()) { - auto modes = root.find("modes"); - if (modes != root.end() && modes->is_object()) { - for (auto it = modes->begin(); it != modes->end(); ++it) { - auto id = normalized_display_id(it.key()); - if (!id.empty()) { - ids.insert(std::move(id)); - } - } - } - } - return ids; - } - - static std::unordered_map snapshot_modes(const nlohmann::json &root) { - std::unordered_map modes; - auto modes_it = root.find("modes"); - if (modes_it == root.end() || !modes_it->is_object()) { - return modes; - } - for (auto it = modes_it->begin(); it != modes_it->end(); ++it) { - if (!it->is_object()) { - continue; - } - auto id = normalized_display_id(it.key()); - const auto width = it->value("w", 0u); - const auto height = it->value("h", 0u); - const auto num = it->value("num", 0u); - const auto den = it->value("den", 0u); - if (id.empty() || width == 0 || height == 0 || den == 0) { - continue; - } - modes.emplace(std::move(id), golden_current_mode_t { - .width = width, - .height = height, - .refresh_hz = static_cast(num) / static_cast(den), - }); - } - return modes; - } - - static std::unordered_map snapshot_hdr_states(const nlohmann::json &root) { - std::unordered_map states; - auto hdr_it = root.find("hdr"); - if (hdr_it == root.end() || !hdr_it->is_object()) { - return states; - } - for (auto it = hdr_it->begin(); it != hdr_it->end(); ++it) { - if (!it->is_string()) { - continue; - } - auto id = normalized_display_id(it.key()); - auto value = boost::algorithm::to_lower_copy(it->get()); - if (id.empty() || (value != "on" && value != "off")) { - continue; - } - states.emplace(std::move(id), value == "on"); - } - return states; - } - - static std::unordered_map> snapshot_origins(const nlohmann::json &root) { - std::unordered_map> origins; - auto origins_it = root.find("origins"); - if (origins_it == root.end() || !origins_it->is_object()) { - return origins; - } - for (auto it = origins_it->begin(); it != origins_it->end(); ++it) { - if (!it->is_object()) { - continue; - } - auto id = normalized_display_id(it.key()); - if (id.empty()) { - continue; - } - origins.emplace(std::move(id), std::make_pair(it->value("x", 0), it->value("y", 0))); - } - return origins; - } - - static golden_current_summary_t current_golden_comparison_summary() { - golden_current_summary_t summary; - const auto devices = display_helper_integration::enumerate_devices(display_device::DeviceEnumerationDetail::Full); - if (!devices) { - return summary; - } - - std::set exclusions; - for (auto id : config::video.dd.snapshot_exclude_devices) { - id = normalized_display_id(std::move(id)); - if (!id.empty()) { - exclusions.insert(std::move(id)); - } - } - - for (const auto &device : *devices) { - if (is_virtual_display_device(device)) { - if (is_active_display_device(device)) { - summary.active_virtual_display = true; - } - continue; - } - if (!device.m_info || device.m_display_name.empty()) { - continue; - } - - auto id = normalized_display_id(device.m_device_id.empty() ? device.m_display_name : device.m_device_id); - if (id.empty() || exclusions.contains(id)) { - continue; - } - - summary.devices.insert(id); - if (auto refresh = floating_to_double(device.m_info->m_refresh_rate)) { - summary.modes[id] = golden_current_mode_t { - .width = device.m_info->m_resolution.m_width, - .height = device.m_info->m_resolution.m_height, - .refresh_hz = *refresh, - }; - } - if (device.m_info->m_hdr_state) { - summary.hdr[id] = *device.m_info->m_hdr_state == display_device::HdrState::Enabled; - } - summary.origins[id] = std::make_pair(device.m_info->m_origin_point.m_x, device.m_info->m_origin_point.m_y); - if (device.m_info->m_primary) { - summary.primary = id; - } - } - - summary.valid = !summary.devices.empty(); - return summary; - } - - static std::optional snapshot_current_mismatch_reason(const nlohmann::json &root) { - const auto current = current_golden_comparison_summary(); - if (current.active_virtual_display) { - return std::nullopt; - } - if (!current.valid) { - return std::nullopt; - } - - const auto snapshot_devices = snapshot_topology_devices(root); - if (snapshot_devices.empty()) { - return "invalid_snapshot"; - } - if (snapshot_devices != current.devices) { - return "display_set_changed"; - } - - const auto modes = snapshot_modes(root); - for (const auto &[id, mode] : modes) { - auto current_mode = current.modes.find(id); - if (current_mode == current.modes.end()) { - continue; - } - if (mode.width != current_mode->second.width || - mode.height != current_mode->second.height || - !nearly_equal_refresh(mode.refresh_hz, current_mode->second.refresh_hz)) { - return "display_mode_changed"; - } - } - - const auto hdr_states = snapshot_hdr_states(root); - for (const auto &[id, hdr] : hdr_states) { - auto current_hdr = current.hdr.find(id); - if (current_hdr != current.hdr.end() && hdr != current_hdr->second) { - return "hdr_changed"; - } - } - - auto primary_it = root.find("primary"); - if (primary_it != root.end() && primary_it->is_string()) { - const auto primary = normalized_display_id(primary_it->get()); - if (!primary.empty() && !current.primary.empty() && primary != current.primary) { - return "primary_changed"; - } - } - - const auto origins = snapshot_origins(root); - for (const auto &[id, origin] : origins) { - auto current_origin = current.origins.find(id); - if (current_origin != current.origins.end() && origin != current_origin->second) { - return "layout_changed"; - } - } - - return ""; - } - - void getGoldenStatus(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - nlohmann::json out; - bool exists = false; - std::optional snapshot_version; - bool has_layout = false; - bool needs_layout_upgrade = false; - bool out_of_date = false; - bool comparison_available = false; - std::string out_of_date_reason; - try { - for (const auto &p : golden_snapshot_candidates()) { - if (file_exists_nofail(p)) { - exists = true; - if (auto root = read_json_file_nofail(p)) { - snapshot_version = parse_snapshot_version(*root); - has_layout = snapshot_has_layout_data(*root); - const bool latest_schema = snapshot_version && *snapshot_version >= kGoldenSnapshotLatestVersion; - needs_layout_upgrade = !latest_schema || !has_layout; - out_of_date = needs_layout_upgrade; - if (needs_layout_upgrade) { - out_of_date_reason = "schema_upgrade_required"; - } - if (!has_active_stream_sessions()) { - if (auto mismatch = snapshot_current_mismatch_reason(*root)) { - comparison_available = true; - if (!mismatch->empty()) { - out_of_date = true; - out_of_date_reason = *mismatch; - } - } - } - } else { - needs_layout_upgrade = true; - out_of_date = true; - out_of_date_reason = "unreadable_snapshot"; - } - break; - } - } - } catch (...) { - } - out["exists"] = exists; - out["snapshot_version"] = snapshot_version ? nlohmann::json(*snapshot_version) : nlohmann::json(nullptr); - out["latest_snapshot_version"] = kGoldenSnapshotLatestVersion; - out["has_layout"] = has_layout; - out["needs_layout_upgrade"] = needs_layout_upgrade; - out["out_of_date"] = out_of_date; - out["comparison_available"] = comparison_available; - out["out_of_date_reason"] = out_of_date_reason; - send_response(response, out); - } - - void deleteGolden(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - nlohmann::json out; - bool any_deleted = false; - try { - for (const auto &p : golden_snapshot_candidates()) { - if (file_exists_nofail(p)) { - std::error_code ec; - std::filesystem::remove(p, ec); - if (!ec) { - any_deleted = true; - } - } - } - } catch (...) { - } - out["deleted"] = any_deleted; - send_response(response, out); - } -#endif - - /** - * @brief Restart Apollo. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/restart| POST| null} - */ - void restart(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - proc::proc.terminate(); - - // We may not return from this call - platf::restart(); - } - - /** - * @brief Quit Apollo. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * On Windows, if running in a service, a special shutdown code is returned. - */ - void quit(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - - BOOST_LOG(warning) << "Requested quit from config page!"sv; - - proc::proc.terminate(); - -#ifdef _WIN32 - if (GetConsoleWindow() == NULL) { - lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); - } else -#endif - { - lifetime::exit_sunshine(0, true); - } - // If exit fails, write a response after 5 seconds. - std::thread write_resp([response] { - std::this_thread::sleep_for(5s); - response->write(); - }); - write_resp.detach(); - } - - /** - * @brief Generate a new API token with specified scopes. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/token| POST| {"scopes":[{"path":"/api/apps","methods":["GET"]}]}}} - * - * Request body example: - * { - * "scopes": [ - * { "path": "/api/apps", "methods": ["GET", "POST"] } - * ] - * } - * - * Response example: - * { "token": "..." } - */ - void generateApiToken(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - std::stringstream ss; - ss << request->content.rdbuf(); - const std::string request_body = ss.str(); - auto token_opt = api_token_manager.generate_api_token(request_body, config::sunshine.username); - nlohmann::json output_tree; - if (!token_opt) { - output_tree["error"] = "Invalid token request"; - send_response(response, output_tree); - return; - } - output_tree["token"] = *token_opt; - send_response(response, output_tree); - } - - /** - * @brief List all active API tokens and their scopes. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/tokens| GET| null} - * - * Response example: - * [ - * { - * "hash": "...", - * "username": "admin", - * "created_at": 1719000000, - * "scopes": [ - * { "path": "/api/apps", "methods": ["GET"] } - * ] - * } - * ] - */ - void listApiTokens(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - nlohmann::json output_tree = nlohmann::json::parse(api_token_manager.list_api_tokens_json()); - send_response(response, output_tree); - } - - /** - * @brief List all token-eligible API routes and methods. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void listApiTokenRoutes(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - - print_req(request); - const auto catalog = snapshot_token_route_catalog(); - - nlohmann::json output_tree; - output_tree["status"] = true; - output_tree["routes"] = nlohmann::json::array(); - - for (const auto &[path, methods] : catalog) { - output_tree["routes"].push_back({{"path", path}, {"methods", ordered_methods_for_catalog(methods)}}); - } - - send_response(response, output_tree); - } - - /** - * @brief Revoke (delete) an API token by its hash. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/token/abcdef1234567890| DELETE| null} - * - * Response example: - * { "status": true } - */ - void revokeApiToken(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - std::string hash; - if (request->path_match.size() > 1) { - hash = request->path_match[1]; - } - bool result = api_token_manager.revoke_api_token_by_hash(hash); - nlohmann::json output_tree; - if (result) { - output_tree["status"] = true; - } else { - output_tree["error"] = "Internal server error"; - } - send_response(response, output_tree); - } - - void listSessions(resp_https_t response, req_https_t request); - void revokeSession(resp_https_t response, req_https_t request); - - /** - * @brief Launch an application. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void launchApp(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - try { - std::stringstream ss; - ss << request->content.rdbuf(); - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - - // Check for required uuid field in body - if (!input_tree.contains("uuid") || !input_tree["uuid"].is_string()) { - bad_request(response, request, "Missing or invalid uuid in request body"); - return; - } - std::string uuid = input_tree["uuid"].get(); - - nlohmann::json output_tree; - const auto &apps = proc::proc.get_apps(); - for (auto &app : apps) { - if (app.uuid == uuid) { - crypto::named_cert_t named_cert { - .name = "", - .uuid = http::unique_id, - .perm = crypto::PERM::_all, - }; - BOOST_LOG(info) << "Launching app ["sv << app.name << "] from web UI"sv; - auto launch_session = nvhttp::make_launch_session(true, false, request->parse_query_string(), &named_cert); - auto err = proc::proc.execute(app, launch_session); - if (err) { - bad_request(response, request, err == 503 ? "Failed to initialize video capture/encoding. Is a display connected and turned on?" : "Failed to start the specified application"); - } else { - output_tree["status"] = true; - send_response(response, output_tree); - } - return; - } - } - BOOST_LOG(error) << "Couldn't find app with uuid ["sv << uuid << ']'; - bad_request(response, request, "Cannot find requested application"); - } catch (std::exception &e) { - BOOST_LOG(warning) << "LaunchApp: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Disconnect a client. - * @param response The HTTP response object. - * @param request The HTTP request object. - */ - void disconnect(resp_https_t response, req_https_t request) { - if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { - return; - } - - print_req(request); - - try { - std::stringstream ss; - ss << request->content.rdbuf(); - nlohmann::json output_tree; - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - std::string uuid = input_tree.value("uuid", ""); - output_tree["status"] = nvhttp::find_and_stop_session(uuid, true); - send_response(response, output_tree); - } catch (std::exception &e) { - BOOST_LOG(warning) << "Disconnect: "sv << e.what(); - bad_request(response, request, e.what()); - } - } - - /** - * @brief Login the user. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * The body for the POST request should be JSON serialized in the following format: - * @code{.json} - * { - * "username": "", - * "password": "" - * } - * @endcode - */ - void login(resp_https_t response, req_https_t request) { - if (!checkIPOrigin(response, request) || !validateContentType(response, request, "application/json")) { - return; - } - - auto fg = util::fail_guard([&] { - response->write(SimpleWeb::StatusCode::client_error_unauthorized); - }); - - try { - std::stringstream ss; - ss << request->content.rdbuf(); - nlohmann::json input_tree = nlohmann::json::parse(ss.str()); - std::string username = input_tree.value("username", ""); - std::string password = input_tree.value("password", ""); - std::string hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); - if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) { - return; - } - std::string sessionCookieRaw = crypto::rand_alphabet(64); - sessionCookie = util::hex(crypto::hash(sessionCookieRaw + config::sunshine.salt)).to_string(); - cookie_creation_time = std::chrono::steady_clock::now(); - const SimpleWeb::CaseInsensitiveMultimap headers { - {"Set-Cookie", "auth=" + sessionCookieRaw + "; Secure; SameSite=Strict; Max-Age=2592000; Path=/"} - }; - response->write(headers); - fg.disable(); - } catch (std::exception &e) { - BOOST_LOG(warning) << "Web UI Login failed: ["sv << net::addr_to_normalized_string(request->remote_endpoint().address()) - << "]: "sv << e.what(); - response->write(SimpleWeb::StatusCode::server_error_internal_server_error); - fg.disable(); - return; - } - } - - void start() { - auto shutdown_event = mail::man->event(mail::shutdown); - auto port_https = net::map_port(PORT_HTTPS); - auto address_family = net::af_from_enum_string(config::sunshine.address_family); - - https_server_t server(config::nvhttp.cert, config::nvhttp.pkey); - server.default_resource["DELETE"] = [](resp_https_t response, req_https_t request) { - bad_request(response, request); - }; - server.default_resource["PATCH"] = [](resp_https_t response, req_https_t request) { - bad_request(response, request); - }; - server.default_resource["POST"] = [](resp_https_t response, req_https_t request) { - bad_request(response, request); - }; - server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) { - bad_request(response, request); - }; - - // Serve the SPA shell for any unmatched GET route. Explicit static and API - // routes are registered below; UI page routes are deprecated server-side - // and are handled by the SPA entry responder so frontend can manage - // authentication and routing. - server.default_resource["GET"] = getSpaEntry; - server.resource["^/$"]["GET"] = getSpaEntry; - server.resource["^/pin/?$"]["GET"] = getSpaEntry; - server.resource["^/apps/?$"]["GET"] = getSpaEntry; - server.resource["^/clients/?$"]["GET"] = getSpaEntry; - server.resource["^/config/?$"]["GET"] = getSpaEntry; - server.resource["^/password/?$"]["GET"] = getSpaEntry; - server.resource["^/welcome/?$"]["GET"] = getSpaEntry; - server.resource["^/login/?$"]["GET"] = getSpaEntry; - server.resource["^/troubleshooting/?$"]["GET"] = getSpaEntry; - clear_token_route_catalog(); - auto register_api_route = [&](const char *pattern, const char *method, const auto &handler) { - server.resource[pattern][method] = handler; - record_token_route(normalize_route_pattern(pattern), method); - }; - register_api_route("^/api/pin$", "POST", savePin); - register_api_route("^/api/otp$", "POST", getOTP); - register_api_route("^/api/apps$", "GET", getApps); - register_api_route("^/api/apps$", "POST", saveApp); - register_api_route("^/api/apps/([^/]+)/cover$", "GET", getAppCover); - register_api_route("^/api/apps/reorder$", "POST", reorderApps); - register_api_route("^/api/apps/delete$", "POST", deleteApp); - register_api_route("^/api/apps/launch$", "POST", launchApp); - register_api_route("^/api/apps/close$", "POST", closeApp); - register_api_route("^/api/logs$", "GET", getLogs); - register_api_route("^/api/config$", "GET", getConfig); - register_api_route("^/api/config$", "POST", saveConfig); - // Partial updates for config settings; merges with existing file and - // removes keys when value is null or empty string. - register_api_route("^/api/config$", "PATCH", patchConfig); - register_api_route("^/api/metadata$", "GET", getMetadata); - register_api_route("^/api/configLocale$", "GET", getLocale); - register_api_route("^/api/restart$", "POST", restart); - register_api_route("^/api/quit$", "POST", quit); -#if defined(_WIN32) - register_api_route("^/api/display/export_golden$", "POST", postExportGoldenDisplay); - register_api_route("^/api/display/golden_status$", "GET", getGoldenStatus); - register_api_route("^/api/display/golden$", "DELETE", deleteGolden); -#endif - register_api_route("^/api/password$", "POST", savePassword); - register_api_route("^/api/display-devices$", "GET", getDisplayDevices); -#ifdef _WIN32 - register_api_route("^/api/framegen/edid-refresh$", "GET", getFramegenEdidRefresh); - register_api_route("^/api/health/vigem$", "GET", getVigemHealth); - register_api_route("^/api/health/crashdump$", "GET", getCrashDumpStatus); - register_api_route("^/api/health/crashdump/dismiss$", "POST", postCrashDumpDismiss); -#endif - register_api_route("^/api/apps/([A-Fa-f0-9-]+)/cover$", "GET", getAppCover); - register_api_route("^/api/apps/([0-9]+)$", "DELETE", deleteApp); - register_api_route("^/api/clients/unpair-all$", "POST", unpairAll); - register_api_route("^/api/clients/list$", "GET", getClients); - register_api_route("^/api/clients/hdr-profiles$", "GET", getHdrProfiles); - register_api_route("^/api/clients/update$", "POST", updateClient); - register_api_route("^/api/clients/unpair$", "POST", unpair); - register_api_route("^/api/clients/disconnect$", "POST", disconnectClient); - register_api_route("^/api/apps/close$", "POST", closeApp); - register_api_route("^/api/session/status$", "GET", getSessionStatus); - register_api_route("^/api/host/stats$", "GET", getHostStats); - register_api_route("^/api/host/info$", "GET", getHostInfo); - register_api_route("^/api/rtsp/sessions$", "GET", listRTSPSessions); - register_api_route("^/api/webrtc/sessions$", "GET", listWebRTCSessions); - register_api_route("^/api/history/sessions$", "GET", listSessionHistory); - register_api_route("^/api/history/sessions/active$", "GET", getActiveSessionHistory); - register_api_route("^/api/history/sessions/([A-Fa-f0-9-]+)$", "GET", getSessionHistoryDetail); - register_api_route("^/api/history/sessions/([A-Fa-f0-9-]+)$", "DELETE", deleteSessionHistory); - register_api_route("^/api/webrtc/sessions$", "POST", createWebRTCSession); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)$", "GET", getWebRTCSession); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)$", "DELETE", deleteWebRTCSession); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/offer$", "POST", postWebRTCOffer); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/answer$", "GET", getWebRTCAnswer); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice$", "GET", getWebRTCIce); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice$", "POST", postWebRTCIce); - register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice/stream$", "GET", getWebRTCIceStream); - register_api_route("^/api/webrtc/cert$", "GET", getWebRTCCert); - // Keep legacy cover upload endpoint present in upstream master - register_api_route("^/api/covers/upload$", "POST", uploadCover); - register_api_route("^/api/apps/purge_autosync$", "POST", purgeAutoSyncedApps); -#ifdef _WIN32 - register_api_route("^/api/playnite/status$", "GET", getPlayniteStatus); - register_api_route("^/api/rtss/status$", "GET", getRtssStatus); - register_api_route("^/api/lossless_scaling/status$", "GET", getLosslessScalingStatus); - register_api_route("^/api/playnite/install$", "POST", installPlaynite); - register_api_route("^/api/playnite/uninstall$", "POST", uninstallPlaynite); - register_api_route("^/api/playnite/games$", "GET", getPlayniteGames); - register_api_route("^/api/playnite/categories$", "GET", getPlayniteCategories); - register_api_route("^/api/playnite/force_sync$", "POST", postPlayniteForceSync); - register_api_route("^/api/playnite/launch$", "POST", postPlayniteLaunch); - // Export logs bundle (Windows only) - register_api_route("^/api/logs/export$", "GET", downloadPlayniteLogs); - register_api_route("^/api/logs/export_crash/manifest$", "GET", getCrashBundleManifest); - register_api_route("^/api/logs/export_crash$", "GET", downloadCrashBundle); -#endif - server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; - server.resource["^/images/logo-apollo-45.png$"]["GET"] = getApolloLogoImage; - server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getApolloLogoImage; // legacy alias - server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; - register_api_route("^/api/token$", "POST", generateApiToken); - register_api_route("^/api/tokens$", "GET", listApiTokens); - register_api_route("^/api/token/routes$", "GET", listApiTokenRoutes); - register_api_route("^/api/token/([a-fA-F0-9]+)$", "DELETE", revokeApiToken); - // Session validation endpoint used by the web UI to detect HttpOnly session cookies - server.resource["^/api-tokens/?$"]["GET"] = getTokenPage; - register_api_route("^/api/auth/login$", "POST", loginUser); - register_api_route("^/api/auth/refresh$", "POST", refreshSession); - register_api_route("^/api/auth/logout$", "POST", logoutUser); - register_api_route("^/api/auth/status$", "GET", authStatus); - register_api_route("^/api/auth/sessions$", "GET", listSessions); - register_api_route("^/api/auth/sessions/([A-Fa-f0-9]+)$", "DELETE", revokeSession); - server.config.reuse_address = true; - server.config.address = net::get_bind_address(address_family); - server.config.port = port_https; - - auto accept_and_run = [&](auto *server) { - try { - server->start([port_https](unsigned short port) { - BOOST_LOG(info) << "Configuration UI available at [https://localhost:"sv << port << "]"; - }); - } catch (boost::system::system_error &err) { - // It's possible the exception gets thrown after calling server->stop() from a different thread - if (shutdown_event->peek()) { - return; - } - BOOST_LOG(fatal) << "Couldn't start Configuration HTTPS server on port ["sv << port_https << "]: "sv << err.what(); - shutdown_event->raise(true); - return; - } - }; - api_token_manager.load_api_tokens(); - session_token_manager.load_session_tokens(); - std::thread tcp {accept_and_run, &server}; - - // Start a background task to clean up expired session tokens every hour - std::jthread cleanup_thread([shutdown_event]() { - while (!shutdown_event->view(std::chrono::hours(1))) { - if (session_token_manager.cleanup_expired_session_tokens()) { - session_token_manager.save_session_tokens(); - } - } - }); - - // Wait for any event - shutdown_event->view(); - - server.stop(); - - tcp.join(); - // std::jthread (cleanup_thread) auto-joins on destruction, no need for joinable/join - } - - /** - * @brief Handles the HTTP request to serve the API token management page. - * - * This function authenticates the incoming request and, if successful, - * reads the "api-tokens.html" file from the web directory and sends its - * contents as an HTTP response with the appropriate content type. - * - * @param response The HTTP response object used to send data back to the client. - * @param request The HTTP request object containing client request data. - */ - void getTokenPage(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - std::string content = file_handler::read_file(WEB_DIR "api-tokens.html"); - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "text/html; charset=utf-8"); - response->write(content, headers); - } - - /** - * @brief Converts a string representation of a token scope to its corresponding TokenScope enum value. - * - * This function takes a string view and returns the matching TokenScope enum value. - * Supported string values are "Read", "read", "Write", and "write". - * If the input string does not match any known scope, an std::invalid_argument exception is thrown. - * - * @param s The string view representing the token scope. - * @return TokenScope The corresponding TokenScope enum value. - * @throws std::invalid_argument If the input string does not match any known scope. - */ - TokenScope scope_from_string(std::string_view s) { - if (s == "Read" || s == "read") { - return TokenScope::Read; - } - if (s == "Write" || s == "write") { - return TokenScope::Write; - } - throw std::invalid_argument("Unknown TokenScope: " + std::string(s)); - } - - /** - * @brief Converts a TokenScope enum value to its string representation. - * @param scope The TokenScope enum value to convert. - * @return The string representation of the scope. - */ - std::string scope_to_string(TokenScope scope) { - switch (scope) { - case TokenScope::Read: - return "Read"; - case TokenScope::Write: - return "Write"; - default: - throw std::invalid_argument("Unknown TokenScope enum value"); - } - } - - /** - * @brief User login endpoint to generate session tokens. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * Expects JSON body: - * { - * "username": "string", - * "password": "string" - * } - * - * Returns: - * { - * "status": true, - * "token": "session_token_string", - * "expires_in": 86400 - * } - * - * @api_examples{/api/auth/login| POST| {"username": "admin", "password": "password"}} - */ - void loginUser(resp_https_t response, req_https_t request) { - print_req(request); - - std::stringstream ss; - ss << request->content.rdbuf(); - try { - nlohmann::json input_tree = nlohmann::json::parse(ss); - if (!input_tree.contains("username") || !input_tree.contains("password")) { - bad_request(response, request, "Missing username or password"); - return; - } - - std::string username = input_tree["username"].get(); - std::string password = input_tree["password"].get(); - std::string redirect_url = input_tree.value("redirect", "/"); - bool remember_me = false; - if (auto it = input_tree.find("remember_me"); it != input_tree.end()) { - try { - remember_me = it->get(); - } catch (const nlohmann::json::exception &) { - remember_me = false; - } - } - - std::string user_agent; - if (auto ua = request->header.find("user-agent"); ua != request->header.end()) { - user_agent = ua->second; - } - std::string remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); - - APIResponse api_response = session_token_api.login(username, password, redirect_url, remember_me, user_agent, remote_address); - write_api_response(response, api_response); - - } catch (const nlohmann::json::exception &e) { - BOOST_LOG(warning) << "Login JSON error:"sv << e.what(); - bad_request(response, request, "Invalid JSON format"); - } - } - - void refreshSession(resp_https_t response, req_https_t request) { - print_req(request); - - std::string refresh_token; - if (auto auth = request->header.find("authorization"); - auth != request->header.end() && auth->second.rfind("Refresh ", 0) == 0) { - refresh_token = auth->second.substr(8); - } - if (refresh_token.empty()) { - refresh_token = extract_refresh_token_from_cookie(request->header); - } - - // Allow JSON body input for API clients that do not rely on cookies/Authorization header - if (refresh_token.empty()) { - std::stringstream ss; - ss << request->content.rdbuf(); - if (!ss.str().empty()) { - try { - auto body = nlohmann::json::parse(ss); - if (auto it = body.find("refresh_token"); it != body.end() && it->is_string()) { - refresh_token = it->get(); - } - } catch (const nlohmann::json::exception &) { - } - } - } - - std::string user_agent; - if (auto ua = request->header.find("user-agent"); ua != request->header.end()) { - user_agent = ua->second; - } - std::string remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); - - APIResponse api_response = session_token_api.refresh_session(refresh_token, user_agent, remote_address); - write_api_response(response, api_response); - } - - /** - * @brief User logout endpoint to revoke session tokens. - * @param response The HTTP response object. - * @param request The HTTP request object. - * - * @api_examples{/api/auth/logout| POST| null} - */ - void logoutUser(resp_https_t response, req_https_t request) { - print_req(request); - - std::string session_token; - if (auto auth = request->header.find("authorization"); - auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { - session_token = auth->second.substr(8); - } - if (session_token.empty()) { - session_token = extract_session_token_from_cookie(request->header); - } - - std::string refresh_token = extract_refresh_token_from_cookie(request->header); - - APIResponse api_response = session_token_api.logout(session_token, refresh_token); - write_api_response(response, api_response); - } - - void listSessions(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - - std::string raw_token; - if (auto auth = request->header.find("authorization"); - auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { - raw_token = auth->second.substr(8); - } - if (raw_token.empty()) { - raw_token = extract_session_token_from_cookie(request->header); - } - std::string active_hash; - if (!raw_token.empty()) { - if (auto hash = session_token_manager.get_hash_for_token(raw_token)) { - active_hash = *hash; - } - } - - APIResponse api_response = session_token_api.list_sessions(config::sunshine.username, active_hash); - write_api_response(response, api_response); - } - - void revokeSession(resp_https_t response, req_https_t request) { - if (!authenticate(response, request)) { - return; - } - print_req(request); - - if (request->path_match.size() < 2) { - bad_request(response, request, "Session id required"); - return; - } - std::string session_hash = request->path_match[1].str(); - - std::string raw_token; - if (auto auth = request->header.find("authorization"); - auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { - raw_token = auth->second.substr(8); - } - if (raw_token.empty()) { - raw_token = extract_session_token_from_cookie(request->header); - } - bool is_current = false; - if (!raw_token.empty()) { - if (auto hash = session_token_manager.get_hash_for_token(raw_token)) { - is_current = boost::iequals(*hash, session_hash); - } - } - - APIResponse api_response = session_token_api.revoke_session_by_hash(session_hash); - if (api_response.status_code == StatusCode::success_ok && is_current) { - std::string clear_cookie = std::string(session_cookie_name) + "=; Path=/; HttpOnly; SameSite=Strict; Secure; Priority=High; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; - std::string clear_refresh_cookie = std::string(refresh_cookie_name) + "=; Path=/; HttpOnly; SameSite=Strict; Secure; Priority=High; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; - api_response.headers.emplace("Set-Cookie", std::move(clear_cookie)); - api_response.headers.emplace("Set-Cookie", std::move(clear_refresh_cookie)); - } - write_api_response(response, api_response); - } - - /** - * @brief Authentication status endpoint. - * Returns whether credentials are configured and if authentication is required for protected API calls. - * This allows the frontend to avoid showing a login modal when not necessary. - * - * Response JSON shape: - * { - * "credentials_configured": true|false, - * "login_required": true|false, - * "authenticated": true|false - * } - * - * login_required becomes true only when credentials are configured and the supplied - * request lacks valid authentication (session token or bearer token) for protected APIs. - */ - void authStatus(resp_https_t response, req_https_t request) { - print_req(request); - - bool credentials_configured = !config::sunshine.username.empty(); - - // Determine if current request has valid auth (session or bearer) using existing check_auth - bool authenticated = false; - if (credentials_configured) { - if (auto result = check_auth(request); result.ok) { - authenticated = true; // check_auth returns ok for public routes; refine below - // We only consider it authenticated if an auth header or cookie was present and validated. - std::string auth_header; - if (auto auth_it = request->header.find("authorization"); auth_it != request->header.end()) { - auth_header = auth_it->second; - } else { - std::string token = extract_session_token_from_cookie(request->header); - if (!token.empty()) { - auth_header = "Session " + token; - } - } - if (auth_header.empty()) { - authenticated = false; // public access granted but no credentials supplied - } else { - // Re-run only auth layer for supplied header specifically to ensure validity - auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); - auto header_check = check_auth(address, auth_header, "/api/config", "GET"); // use protected path for validation - authenticated = header_check.ok; - } - } - } - - bool login_required = credentials_configured && !authenticated; - - nlohmann::json tree; - tree["credentials_configured"] = credentials_configured; - tree["login_required"] = login_required; - tree["authenticated"] = authenticated; - - SimpleWeb::CaseInsensitiveMultimap headers; - headers.emplace("Content-Type", "application/json; charset=utf-8"); - add_cors_headers(headers); - response->write(SimpleWeb::StatusCode::success_ok, tree.dump(), headers); - } -} // namespace confighttp +/** + * @file src/confighttp.cpp + * @brief Definitions for the Web UI Config HTTPS server. + * + * @todo Authentication, better handling of routes common to nvhttp, cleanup + */ +#define BOOST_BIND_GLOBAL_PLACEHOLDERS + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// lib includes +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "audio.h" +#include "config.h" +#include "confighttp.h" +#include "crypto.h" +#include "file_handler.h" +#include "globals.h" +#include "http_auth.h" +#include "httpcommon.h" +#include "platform/common.h" +#ifdef _WIN32 + #include "src/platform/windows/image_convert.h" + +#endif +#include "logging.h" +#include "network.h" +#include "nvhttp.h" +#include "platform/common.h" +#include "rtsp.h" +#include "session_history.h" +#include "stream.h" +#include "host_stats.h" +#include "webrtc_stream.h" + +#ifdef _WIN32 + #include "platform/windows/virtual_display_cleanup.h" +#endif + +#include +#if defined(_WIN32) + #include "platform/windows/misc.h" + #include "src/platform/windows/ipc/misc_utils.h" + #include "src/platform/windows/playnite_integration.h" + #include "src/platform/windows/playnite_sync.h" + + #include +#endif +#ifdef uuid_t + #undef uuid_t +#endif +#if defined(_WIN32) + #include "platform/windows/misc.h" + + #include + #include + #include +#endif +#include "display_helper_integration.h" +#include "process.h" +#include "utility.h" +#include "uuid.h" + +#ifdef _WIN32 + #include "platform/windows/utils.h" +#endif + +using namespace std::literals; +namespace pt = boost::property_tree; + +namespace confighttp { + // Global MIME type lookup used for static file responses + const std::map mime_types = { + {"css", "text/css"}, + {"gif", "image/gif"}, + {"htm", "text/html"}, + {"html", "text/html"}, + {"ico", "image/x-icon"}, + {"jpeg", "image/jpeg"}, + {"jpg", "image/jpeg"}, + {"js", "application/javascript"}, + {"json", "application/json"}, + {"png", "image/png"}, + {"webp", "image/webp"}, + {"svg", "image/svg+xml"}, + {"ttf", "font/ttf"}, + {"txt", "text/plain"}, + {"woff2", "font/woff2"}, + {"xml", "text/xml"}, + }; + + // Helper: sort apps by their 'name' field, if present + static void sort_apps_by_name(nlohmann::json &file_tree) { + try { + if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { + return; + } + auto &apps_node = file_tree["apps"]; + std::sort(apps_node.begin(), apps_node.end(), [](const nlohmann::json &a, const nlohmann::json &b) { + try { + return a.at("name").get() < b.at("name").get(); + } catch (...) { + return false; + } + }); + } catch (...) {} + } + + bool refresh_client_apps_cache(nlohmann::json &file_tree, bool sort_by_name) { + try { + if (sort_by_name) { + sort_apps_by_name(file_tree); + } + file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); + proc::refresh(config::stream.file_apps, false); + return true; + } catch (const std::exception &e) { + BOOST_LOG(warning) << "refresh_client_apps_cache: failed: " << e.what(); + } catch (...) { + BOOST_LOG(warning) << "refresh_client_apps_cache: failed (unknown)"; + } + return false; + } + namespace fs = std::filesystem; + using enum confighttp::StatusCode; + + static std::string trim_copy(const std::string &input) { + auto begin = input.begin(); + auto end = input.end(); + while (begin != end && std::isspace(static_cast(*begin))) { + ++begin; + } + while (end != begin && std::isspace(static_cast(*(end - 1)))) { + --end; + } + return std::string {begin, end}; + } + + static bool file_is_regular(const fs::path &path) { + if (path.empty()) { + return false; + } + std::error_code ec; + return fs::exists(path, ec) && fs::is_regular_file(path, ec); + } + + static bool resolve_cover_path_for_uuid(const std::string &uuid, fs::path &out_path) { + if (uuid.empty()) { + return false; + } + + try { + std::string content = file_handler::read_file(config::stream.file_apps.c_str()); + nlohmann::json file_tree = nlohmann::json::parse(content); + if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { + return false; + } + + const fs::path cover_dir = fs::path(platf::appdata()) / "covers"; + const fs::path config_dir = fs::path(config::stream.file_apps).parent_path(); + const fs::path assets_dir = fs::path(SUNSHINE_ASSETS_DIR); + + for (const auto &entry : file_tree["apps"]) { + if (!entry.is_object()) { + continue; + } + if (!entry.contains("uuid") || !entry["uuid"].is_string()) { + continue; + } + if (entry["uuid"].get() != uuid) { + continue; + } + + std::string image_path; + if (entry.contains("image-path") && entry["image-path"].is_string()) { + image_path = entry["image-path"].get(); + } + std::string playnite_id; + if (entry.contains("playnite-id") && entry["playnite-id"].is_string()) { + playnite_id = entry["playnite-id"].get(); + } + + std::vector candidates; + std::unordered_set seen; + auto push_candidate = [&](fs::path candidate) { + if (candidate.empty()) { + return; + } + auto normalized = candidate.lexically_normal(); + std::string key = normalized.generic_string(); +#ifdef _WIN32 + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); +#endif + if (!seen.insert(key).second) { + return; + } + candidates.emplace_back(std::move(normalized)); + }; + + auto trimmed = trim_copy(image_path); + auto normalized_path = trimmed; + std::replace(normalized_path.begin(), normalized_path.end(), '\\', '/'); + + if (!trimmed.empty()) { + fs::path direct(trimmed); + push_candidate(direct); + if (!direct.is_absolute()) { + if (!normalized_path.empty() && normalized_path.rfind("./", 0) == 0) { + fs::path rel(normalized_path.substr(2)); + push_candidate(config_dir / rel); + push_candidate(assets_dir / rel); + } + push_candidate(config_dir / direct); + push_candidate(assets_dir / direct); + if (normalized_path.rfind("covers/", 0) == 0) { + fs::path rel(normalized_path.substr(7)); + push_candidate(cover_dir / rel); + } + if (normalized_path.rfind("./covers/", 0) == 0) { + fs::path rel(normalized_path.substr(9)); + push_candidate(cover_dir / rel); + } + } + } + + static const std::array fallback_exts {".png", ".jpg", ".jpeg", ".webp"}; + for (const char *ext : fallback_exts) { + push_candidate(cover_dir / (uuid + ext)); + } + if (!playnite_id.empty()) { + push_candidate(cover_dir / (std::string("playnite_") + playnite_id + ".png")); + } + + for (const auto &candidate : candidates) { + if (file_is_regular(candidate)) { + out_path = candidate; + return true; + } + } + + fs::path fallback = assets_dir / "box.png"; + if (file_is_regular(fallback)) { + out_path = fallback; + return true; + } + + return false; + } + } catch (const std::exception &e) { + BOOST_LOG(warning) << "resolve_cover_path_for_uuid: failed for uuid '" << uuid << "': " << e.what(); + } catch (...) { + BOOST_LOG(warning) << "resolve_cover_path_for_uuid: failed for uuid '" << uuid << "': unknown error"; + } + return false; + } + + using https_server_t = SimpleWeb::Server; + using args_t = SimpleWeb::CaseInsensitiveMultimap; + using resp_https_t = std::shared_ptr::Response>; + using req_https_t = std::shared_ptr::Request>; + + bool is_token_route_eligible(std::string_view path) { + return path.rfind("/api/", 0) == 0 && path.rfind("/api/auth/", 0) != 0; + } + + std::vector ordered_methods_for_catalog(const std::set> &methods) { + static constexpr std::array preferred_order = { + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + }; + + std::vector ordered; + ordered.reserve(methods.size()); + + for (const auto method : preferred_order) { + if (methods.contains(std::string(method))) { + ordered.emplace_back(method); + } + } + + for (const auto &method : methods) { + if (std::find(preferred_order.begin(), preferred_order.end(), method) == preferred_order.end()) { + ordered.push_back(method); + } + } + + return ordered; + } + + namespace { + using token_route_methods_t = std::map>, std::less<>>; + + std::mutex token_route_catalog_mutex; + token_route_methods_t token_route_catalog; + + std::string normalize_route_pattern(std::string pattern) { + if (!pattern.empty() && pattern.front() == '^') { + pattern.erase(pattern.begin()); + } + if (!pattern.empty() && pattern.back() == '$') { + pattern.pop_back(); + } + return pattern; + } + + void clear_token_route_catalog() { + std::scoped_lock lock(token_route_catalog_mutex); + token_route_catalog.clear(); + } + + void record_token_route(std::string path, std::string method) { + if (!is_token_route_eligible(path)) { + return; + } + boost::to_upper(method); + std::scoped_lock lock(token_route_catalog_mutex); + token_route_catalog[std::move(path)].insert(std::move(method)); + } + + token_route_methods_t snapshot_token_route_catalog() { + std::scoped_lock lock(token_route_catalog_mutex); + return token_route_catalog; + } + + bool has_active_stream_sessions() { + return rtsp_stream::session_count() > 0 || webrtc_stream::has_active_sessions(); + } + + bool can_hot_apply_during_session(const std::set &keys) { + if (keys.empty()) { + return false; + } + + for (const auto &key : keys) { + if (key.rfind("playnite_", 0) == 0) { + continue; + } + + if (key == "session_history_enabled") { + return false; + } + + if (key == "session_history_ttl_days" || + key == "session_history_db_size_limit_mb") { + continue; + } + + return false; + } + + return true; + } + + } // namespace + + // Forward declaration for error helper implemented later + void bad_request(resp_https_t response, req_https_t request, const std::string &error_message); + void getAppCover(resp_https_t response, req_https_t request); + +#ifdef _WIN32 + // Forward declarations for Playnite handlers implemented in confighttp_playnite.cpp + void getPlayniteStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void installPlaynite(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void uninstallPlaynite(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void getPlayniteGames(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void getPlayniteCategories(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void postPlayniteForceSync(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void postPlayniteLaunch(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + // Helper to keep confighttp.cpp free of Playnite details + void enhance_app_with_playnite_cover(nlohmann::json &input_tree); + // New: download Playnite-related logs as a ZIP + + // RTSS status endpoint (Windows-only) + void getRtssStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void getLosslessScalingStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void downloadPlayniteLogs(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void getCrashDumpStatus(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void postCrashDumpDismiss(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void getCrashBundleManifest(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + void downloadCrashBundle(std::shared_ptr::Response> response, std::shared_ptr::Request> request); + // Display helper: export current OS state as golden restore snapshot + void postExportGoldenDisplay(resp_https_t response, req_https_t request); + // Helper log readers (Windows-only) + bool is_helper_log_source(const std::string &source); + bool read_helper_log(const std::string &source, std::string &out); +#endif + + enum class op_e { + ADD, ///< Add client + REMOVE ///< Remove client + }; + + // SESSION COOKIE + std::string sessionCookie; + static std::chrono::time_point cookie_creation_time; + + /** + * @brief Log the request details. + * @param request The HTTP request object. + */ + void print_req(const req_https_t &request) { + BOOST_LOG(debug) << "HTTP "sv << request->method << ' ' << request->path; + + if (!request->header.empty()) { + BOOST_LOG(verbose) << "Headers:"sv; + for (auto &[name, val] : request->header) { + BOOST_LOG(verbose) << name << " -- " + << (name == "Authorization" ? "CREDENTIALS REDACTED" : val); + } + } + + auto query = request->parse_query_string(); + if (!query.empty()) { + BOOST_LOG(verbose) << "Query Params:"sv; + for (auto &[name, val] : query) { + BOOST_LOG(verbose) << name << " -- " << val; + } + } + } + + /** + * @brief Get the CORS origin for localhost (no wildcard). + * @return The CORS origin string. + */ + static std::string get_cors_origin() { + std::uint16_t https_port = net::map_port(PORT_HTTPS); + return std::format("https://localhost:{}", https_port); + } + + /** + * @brief Helper to add CORS headers for API responses. + * @param headers The headers to add CORS to. + */ + void add_cors_headers(SimpleWeb::CaseInsensitiveMultimap &headers) { + headers.emplace("Access-Control-Allow-Origin", get_cors_origin()); + headers.emplace("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + headers.emplace("Access-Control-Allow-Headers", "Content-Type, Authorization"); + } + + /** + * @brief Send a response. + * @param response The HTTP response object. + * @param output_tree The JSON tree to send. + */ + void send_response(resp_https_t response, const nlohmann::json &output_tree) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json; charset=utf-8"); + add_cors_headers(headers); + response->write(success_ok, output_tree.dump(), headers); + } + + nlohmann::json load_webrtc_ice_servers() { + auto env = std::getenv("SUNSHINE_WEBRTC_ICE_SERVERS"); + if (!env || !*env) { + return nlohmann::json::array(); + } + + try { + auto parsed = nlohmann::json::parse(env); + if (parsed.is_array()) { + return parsed; + } + } catch (const std::exception &e) { + BOOST_LOG(warning) << "WebRTC: invalid SUNSHINE_WEBRTC_ICE_SERVERS: "sv << e.what(); + } + + return nlohmann::json::array(); + } + + nlohmann::json webrtc_session_to_json(const webrtc_stream::SessionState &state) { + nlohmann::json output; + output["id"] = state.id; + output["audio"] = state.audio; + output["video"] = state.video; + output["encoded"] = state.encoded; + output["audio_packets"] = state.audio_packets; + output["video_packets"] = state.video_packets; + output["audio_dropped"] = state.audio_dropped; + output["video_dropped"] = state.video_dropped; + output["audio_queue_frames"] = state.audio_queue_frames; + output["video_queue_frames"] = state.video_queue_frames; + output["video_inflight_frames"] = state.video_inflight_frames; + output["has_remote_offer"] = state.has_remote_offer; + output["has_local_answer"] = state.has_local_answer; + output["ice_candidates"] = state.ice_candidates; + output["width"] = state.width ? nlohmann::json(*state.width) : nlohmann::json(nullptr); + output["height"] = state.height ? nlohmann::json(*state.height) : nlohmann::json(nullptr); + output["fps"] = state.fps ? nlohmann::json(*state.fps) : nlohmann::json(nullptr); + output["bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); + // WebRTC has no FEC/audio adjustment, so the requested bitrate is the same as the encoder bitrate. + output["requested_bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); + output["encoder_bitrate_kbps"] = state.bitrate_kbps ? nlohmann::json(*state.bitrate_kbps) : nlohmann::json(nullptr); + output["codec"] = state.codec ? nlohmann::json(stream::canonical_codec_name(*state.codec)) : nlohmann::json(nullptr); + output["hdr"] = state.hdr ? nlohmann::json(*state.hdr) : nlohmann::json(nullptr); + output["yuv444"] = state.yuv444 ? nlohmann::json(*state.yuv444) : nlohmann::json(false); + output["stream_gpu_model"] = state.stream_gpu_model ? nlohmann::json(*state.stream_gpu_model) : nlohmann::json(nullptr); + output["audio_channels"] = state.audio_channels ? nlohmann::json(*state.audio_channels) : nlohmann::json(nullptr); + output["audio_codec"] = state.audio_codec ? nlohmann::json(*state.audio_codec) : nlohmann::json(nullptr); + output["profile"] = state.profile ? nlohmann::json(*state.profile) : nlohmann::json(nullptr); + output["video_pacing_mode"] = state.video_pacing_mode ? nlohmann::json(*state.video_pacing_mode) : nlohmann::json(nullptr); + output["video_pacing_slack_ms"] = state.video_pacing_slack_ms ? nlohmann::json(*state.video_pacing_slack_ms) : nlohmann::json(nullptr); + output["video_max_frame_age_ms"] = state.video_max_frame_age_ms ? nlohmann::json(*state.video_max_frame_age_ms) : nlohmann::json(nullptr); + output["last_audio_bytes"] = state.last_audio_bytes; + output["last_video_bytes"] = state.last_video_bytes; + output["video_bytes_total"] = state.video_bytes_total; + output["audio_bytes_total"] = state.audio_bytes_total; + output["bytes_sent"] = state.video_bytes_total + state.audio_bytes_total; + output["last_video_idr"] = state.last_video_idr; + output["last_video_frame_index"] = state.last_video_frame_index; + + auto now = std::chrono::steady_clock::now(); + auto age_or_null = [&now](const std::optional &tp) -> nlohmann::json { + if (!tp) { + return nullptr; + } + return std::chrono::duration_cast(now - *tp).count(); + }; + + output["last_audio_age_ms"] = age_or_null(state.last_audio_time); + output["last_video_age_ms"] = age_or_null(state.last_video_time); + return output; + } + + double round_to(double value, double factor) { + return std::round(value * factor) / factor; + } + + nlohmann::json rtsp_session_to_json(const stream::session_info_t &info) { + nlohmann::json output; + output["uuid"] = info.uuid; + output["device_name"] = info.device_name; + output["width"] = info.width; + output["height"] = info.height; + output["fps"] = info.fps; + output["encoder_bitrate_kbps"] = info.encoder_bitrate_kbps; + output["requested_bitrate_kbps"] = info.requested_bitrate_kbps; + output["video_format"] = info.video_format; + output["codec"] = stream::canonical_codec_name(stream::video_format_name(info.video_format)); + output["hdr"] = info.dynamic_range != 0; + output["yuv444"] = info.yuv444; + output["audio_channels"] = info.audio_channels; + output["stream_gpu_model"] = info.stream_gpu_model; + output["state"] = info.state; + output["frames_sent"] = info.frames_sent; + output["packets_sent"] = info.packets_sent; + output["bytes_sent"] = info.bytes_sent; + output["idr_requests"] = info.idr_requests; + output["invalidate_ref_count"] = info.invalidate_ref_count; + output["client_reported_losses"] = info.client_reported_losses; + output["encode_latency_ms"] = round_to(info.encode_latency_ms, 10.0); + output["last_frame_index"] = info.last_frame_index; + output["uptime_seconds"] = round_to(info.uptime_seconds, 10.0); + return output; + } + + nlohmann::json host_stats_to_json(const platf::host_stats_t &stats) { + nlohmann::json output; + output["cpu_percent"] = stats.cpu_percent; + output["cpu_temp_c"] = stats.cpu_temp_c; + output["ram_used_bytes"] = stats.ram_used_bytes; + output["ram_total_bytes"] = stats.ram_total_bytes; + output["ram_percent"] = stats.ram_total_bytes > 0 + ? (static_cast(stats.ram_used_bytes) * 100.0 / + static_cast(stats.ram_total_bytes)) + : 0.0; + output["gpu_percent"] = stats.gpu_percent; + output["gpu_encoder_percent"] = stats.gpu_encoder_percent; + output["gpu_temp_c"] = stats.gpu_temp_c; + const auto vram_used_bytes = + stats.vram_total_bytes > 0 && stats.vram_used_bytes > stats.vram_total_bytes ? + stats.vram_total_bytes : + stats.vram_used_bytes; + output["vram_used_bytes"] = vram_used_bytes; + output["vram_total_bytes"] = stats.vram_total_bytes; + output["vram_percent"] = stats.vram_total_bytes > 0 + ? (static_cast(vram_used_bytes) * 100.0 / + static_cast(stats.vram_total_bytes)) + : 0.0; + output["net_rx_bps"] = stats.net_rx_bps; + output["net_tx_bps"] = stats.net_tx_bps; + return output; + } + + nlohmann::json host_info_to_json(const platf::host_info_t &info) { + nlohmann::json output; + output["cpu_model"] = info.cpu_model; + output["gpu_model"] = info.gpu_model; + output["cpu_logical_cores"] = info.cpu_logical_cores; + output["ram_total_bytes"] = info.ram_total_bytes; + output["vram_total_bytes"] = info.vram_total_bytes; + output["net_interface"] = info.net_interface; + output["net_link_speed_mbps"] = info.net_link_speed_mbps; + return output; + } + + nlohmann::json session_summary_to_json(const session_history::session_summary_t &summary) { + nlohmann::json output; + output["uuid"] = summary.uuid; + output["protocol"] = summary.protocol; + output["client_name"] = summary.client_name; + output["device_name"] = summary.device_name; + output["app_name"] = summary.app_name; + output["width"] = summary.width; + output["height"] = summary.height; + output["target_fps"] = summary.target_fps; + output["encoder_bitrate_kbps"] = summary.encoder_bitrate_kbps; + output["requested_bitrate_kbps"] = summary.requested_bitrate_kbps; + output["codec"] = summary.codec; + output["hdr"] = summary.hdr; + output["yuv444"] = summary.yuv444; + output["audio_channels"] = summary.audio_channels; + output["start_time_unix"] = summary.start_time_unix; + output["end_time_unix"] = summary.end_time_unix; + output["duration_seconds"] = round_to(summary.duration_seconds, 10.0); + output["verdict"] = summary.verdict; + output["server_version"] = summary.server_version; + output["host_cpu_model"] = summary.host_cpu_model; + output["host_gpu_model"] = summary.host_gpu_model; + output["stream_gpu_model"] = summary.stream_gpu_model; + return output; + } + + nlohmann::json session_sample_to_json(const session_history::session_sample_t &sample) { + nlohmann::json output; + output["session_uuid"] = sample.session_uuid; + output["timestamp_unix"] = sample.timestamp_unix; + output["bytes_sent_total"] = sample.bytes_sent_total; + output["packets_sent_video"] = sample.packets_sent_video; + output["frames_sent"] = sample.frames_sent; + output["last_frame_index"] = sample.last_frame_index; + output["video_dropped"] = sample.video_dropped; + output["audio_dropped"] = sample.audio_dropped; + output["client_reported_losses"] = sample.client_reported_losses; + output["idr_requests"] = sample.idr_requests; + output["ref_invalidations"] = sample.ref_invalidations; + output["encode_latency_ms"] = round_to(sample.encode_latency_ms, 10.0); + output["actual_fps"] = round_to(sample.actual_fps, 10.0); + output["actual_bitrate_kbps"] = round_to(sample.actual_bitrate_kbps, 10.0); + output["frame_interval_jitter_ms"] = round_to(sample.frame_interval_jitter_ms, 100.0); + output["host_cpu_percent"] = sample.host_cpu_percent < 0 ? -1 : round_to(sample.host_cpu_percent, 10.0); + output["host_gpu_percent"] = sample.host_gpu_percent < 0 ? -1 : round_to(sample.host_gpu_percent, 10.0); + output["host_gpu_encoder_percent"] = sample.host_gpu_encoder_percent < 0 ? -1 : round_to(sample.host_gpu_encoder_percent, 10.0); + output["host_ram_percent"] = sample.host_ram_percent < 0 ? -1 : round_to(sample.host_ram_percent, 10.0); + output["host_vram_percent"] = sample.host_vram_percent < 0 ? -1 : round_to(sample.host_vram_percent, 10.0); + output["host_cpu_temp_c"] = sample.host_cpu_temp_c < 0 ? -1 : round_to(sample.host_cpu_temp_c, 10.0); + output["host_gpu_temp_c"] = sample.host_gpu_temp_c < 0 ? -1 : round_to(sample.host_gpu_temp_c, 10.0); + output["host_net_rx_bps"] = sample.host_net_rx_bps < 0 ? -1 : sample.host_net_rx_bps; + output["host_net_tx_bps"] = sample.host_net_tx_bps < 0 ? -1 : sample.host_net_tx_bps; + return output; + } + + nlohmann::json session_event_to_json(const session_history::session_event_t &event) { + nlohmann::json output; + output["session_uuid"] = event.session_uuid; + output["timestamp_unix"] = event.timestamp_unix; + output["event_type"] = event.event_type; + output["payload"] = event.payload; + return output; + } + + nlohmann::json active_session_to_json(const session_history::active_session_t &session) { + nlohmann::json output; + output["uuid"] = session.uuid; + output["protocol"] = session.protocol; + output["client_name"] = session.client_name; + output["device_name"] = session.device_name; + output["app_name"] = session.app_name; + output["width"] = session.width; + output["height"] = session.height; + output["target_fps"] = session.target_fps; + output["encoder_bitrate_kbps"] = session.encoder_bitrate_kbps; + output["requested_bitrate_kbps"] = session.requested_bitrate_kbps; + output["codec"] = session.codec; + output["hdr"] = session.hdr; + output["yuv444"] = session.yuv444; + output["stream_gpu_model"] = session.stream_gpu_model; + output["uptime_seconds"] = round_to(session.uptime_seconds, 10.0); + output["actual_fps"] = round_to(session.actual_fps, 10.0); + output["actual_bitrate_kbps"] = round_to(session.actual_bitrate_kbps, 10.0); + output["encode_latency_ms"] = round_to(session.encode_latency_ms, 10.0); + output["frame_interval_jitter_ms"] = round_to(session.frame_interval_jitter_ms, 100.0); + output["frames_sent"] = session.frames_sent; + output["bytes_sent"] = session.bytes_sent; + output["client_reported_losses"] = session.client_reported_losses; + output["idr_requests"] = session.idr_requests; + return output; + } + + nlohmann::json session_detail_to_json(const session_history::session_detail_t &detail) { + nlohmann::json output = session_summary_to_json(detail.summary); + output["total_samples"] = detail.total_samples; + output["total_events"] = detail.total_events; + output["samples_truncated"] = detail.samples_truncated; + output["events_truncated"] = detail.events_truncated; + output["samples"] = nlohmann::json::array(); + for (const auto &sample : detail.samples) { + output["samples"].push_back(session_sample_to_json(sample)); + } + output["events"] = nlohmann::json::array(); + for (const auto &event : detail.events) { + output["events"].push_back(session_event_to_json(event)); + } + return output; + } + + nlohmann::json history_status_to_json(const session_history::history_status_t &status) { + nlohmann::json output; + output["available"] = status.available; + output["degraded"] = status.degraded; + output["dropped_samples"] = status.dropped_samples; + output["failed_writes"] = status.failed_writes; + output["pending_control_commands"] = status.pending_control_commands; + output["pending_priority_commands"] = status.pending_priority_commands; + output["pending_regular_commands"] = status.pending_regular_commands; + output["pending_samples"] = status.pending_samples; + return output; + } + + /** + * @brief Write an APIResponse to an HTTP response object. + * @param response The HTTP response object. + * @param api_response The APIResponse containing the structured response data. + */ + void write_api_response(resp_https_t response, const APIResponse &api_response) { + SimpleWeb::CaseInsensitiveMultimap headers = api_response.headers; + headers.emplace("Content-Type", "application/json"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + add_cors_headers(headers); + response->write(api_response.status_code, api_response.body, headers); + } + + /** + * @brief Send a 401 Unauthorized response. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void send_unauthorized(resp_https_t response, req_https_t request) { + auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); + BOOST_LOG(info) << "Web UI: ["sv << address << "] -- not authorized"sv; + + constexpr auto code = client_error_unauthorized; + + nlohmann::json tree; + tree["status_code"] = code; + tree["status"] = false; + tree["error"] = "Unauthorized"; + const SimpleWeb::CaseInsensitiveMultimap headers { + {"Content-Type", "application/json"}, + {"X-Frame-Options", "DENY"}, + {"Content-Security-Policy", "frame-ancestors 'none';"}, + {"Access-Control-Allow-Origin", get_cors_origin()} + }; + response->write(code, tree.dump(), headers); + } + + /** + * @brief Send a redirect response. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @param path The path to redirect to. + */ + void send_redirect(resp_https_t response, req_https_t request, const char *path) { + auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); + BOOST_LOG(info) << "Web UI: ["sv << address << "] -- redirecting"sv; + const SimpleWeb::CaseInsensitiveMultimap headers { + {"Location", path}, + {"X-Frame-Options", "DENY"}, + {"Content-Security-Policy", "frame-ancestors 'none';"} + }; + response->write(redirection_temporary_redirect, headers); + } + + /** + * @brief Enforce origin access policy based on configured network scope. + * @return True if the remote address is permitted, false otherwise (response set). + */ + bool checkIPOrigin(resp_https_t response, req_https_t request) { + const auto remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); + const auto ip_type = net::from_address(remote_address); + if (ip_type > http::origin_web_ui_allowed) { + BOOST_LOG(info) << "Web UI: ["sv << remote_address << "] -- denied by origin policy"sv; + nlohmann::json tree; + tree["status_code"] = static_cast(SimpleWeb::StatusCode::client_error_forbidden); + tree["status"] = false; + tree["error"] = "Forbidden"; + SimpleWeb::CaseInsensitiveMultimap headers { + {"Content-Type", "application/json"}, + {"X-Frame-Options", "DENY"}, + {"Content-Security-Policy", "frame-ancestors 'none';"} + }; + add_cors_headers(headers); + response->write(SimpleWeb::StatusCode::client_error_forbidden, tree.dump(), headers); + return false; + } + return true; + } + + /** + * @brief Check authentication and authorization for an HTTP request. + * @param request The HTTP request object. + * @return AuthResult with outcome and response details if not authorized. + */ + AuthResult check_auth(const req_https_t &request) { + auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); + std::string auth_header; + // Try Authorization header + if (auto auth_it = request->header.find("authorization"); auth_it != request->header.end()) { + auth_header = auth_it->second; + } else { + std::string token = extract_session_token_from_cookie(request->header); + if (!token.empty()) { + auth_header = "Session " + token; + } + } + return check_auth(address, auth_header, request->path, request->method); + } + + /** + * @brief Authenticate the user or API token for a specific path/method. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @return True if authenticated and authorized, false otherwise. + */ + bool authenticate(resp_https_t response, req_https_t request) { + if (auto result = check_auth(request); !result.ok) { + if (result.code == StatusCode::redirection_temporary_redirect) { + response->write(result.code, result.headers); + } else if (!result.body.empty()) { + response->write(result.code, result.body, result.headers); + } else { + response->write(result.code); + } + return false; + } + return true; + } + + /** + * @brief Get the list of available display devices. + * @api_examples{/api/display-devices| GET| [{"device_id":"{...}","display_name":"\\\\.\\DISPLAY1","friendly_name":"Monitor"}, ...]} + * @note Pass query param detail=full to include extended metadata (refresh lists, inactive displays). + */ + void getDisplayDevices(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + try { + display_device::DeviceEnumerationDetail detail = display_device::DeviceEnumerationDetail::Minimal; + const auto query = request->parse_query_string(); + if (const auto it = query.find("detail"); it != query.end()) { + const auto value = boost::algorithm::to_lower_copy(it->second); + if (value == "full") { + detail = display_device::DeviceEnumerationDetail::Full; + } + } else if (const auto full_it = query.find("full"); full_it != query.end()) { + const auto value = boost::algorithm::to_lower_copy(full_it->second); + if (value == "1" || value == "true" || value == "yes") { + detail = display_device::DeviceEnumerationDetail::Full; + } + } + + const auto json_str = display_helper_integration::enumerate_devices_json(detail); + nlohmann::json tree = nlohmann::json::parse(json_str); + send_response(response, tree); + } catch (const std::exception &e) { + nlohmann::json tree; + tree["status"] = false; + tree["error"] = std::string {"Failed to enumerate display devices: "} + e.what(); + send_response(response, tree); + } + } + +#ifdef _WIN32 + /** + * @brief Validate refresh capabilities for a display via EDID for frame generation health checks. + * @api_examples{/api/framegen/edid-refresh?device_id=\\.\DISPLAY1| GET| {"status":true,"targets":[{"hz":120,"supported":true,"method":"range"}]}} + */ + void getFramegenEdidRefresh(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + try { + const auto query = request->parse_query_string(); + auto read_first = [&](std::initializer_list keys) -> std::string { + for (const auto &key : keys) { + const auto it = query.find(key); + if (it != query.end()) { + auto value = boost::algorithm::trim_copy(it->second); + if (!value.empty()) { + return value; + } + } + } + return {}; + }; + + std::string device_hint = read_first({"device_id", "device", "id", "display"}); + if (device_hint.empty()) { + bad_request(response, request, "device_id query parameter is required"); + return; + } + + std::vector targets {120, 180, 240, 288}; + if (const auto it = query.find("targets"); it != query.end()) { + std::vector parsed; + std::vector parts; + boost::split(parts, it->second, boost::is_any_of(",")); + for (auto part : parts) { + boost::algorithm::trim(part); + if (part.empty()) { + continue; + } + try { + int hz = std::stoi(part); + if (hz > 0) { + parsed.push_back(hz); + } + } catch (...) { + // ignore invalid entries + } + } + if (!parsed.empty()) { + targets = std::move(parsed); + } + } + + auto result = display_helper_integration::framegen_edid_refresh_support(device_hint, targets); + nlohmann::json out; + if (!result) { + out["status"] = false; + out["error"] = "Display device not found for EDID refresh validation."; + send_response(response, out); + return; + } + + out["status"] = true; + out["device_id"] = result->device_id; + out["device_label"] = result->device_label; + out["edid_present"] = result->edid_present; + if (result->max_vertical_hz) { + out["max_vertical_hz"] = *result->max_vertical_hz; + } + if (result->max_timing_hz) { + out["max_timing_hz"] = *result->max_timing_hz; + } + + nlohmann::json targets_json = nlohmann::json::array(); + for (const auto &entry : result->targets) { + nlohmann::json target_json; + target_json["hz"] = entry.hz; + target_json["supported"] = entry.supported.has_value() ? nlohmann::json(*entry.supported) : nlohmann::json(nullptr); + target_json["method"] = entry.method; + targets_json.push_back(std::move(target_json)); + } + out["targets"] = std::move(targets_json); + + send_response(response, out); + } catch (const std::exception &e) { + bad_request(response, request, e.what()); + } catch (...) { + bad_request(response, request, "Failed to validate display refresh via EDID."); + } + } + + /** + * @brief Health check for ViGEm (Virtual Gamepad) installation on Windows. + * @api_examples{/api/health/vigem| GET| {"installed":true,"version":""}} + */ + void getVigemHealth(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + try { + std::string version; + bool installed = platf::is_vigem_installed(&version); + nlohmann::json out; + out["installed"] = installed; + if (!version.empty()) { + out["version"] = version; + } + send_response(response, out); + } catch (...) { + bad_request(response, request, "Failed to evaluate ViGEm health"); + } + } +#endif + + /** + * @brief Send a 404 Not Found response. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void not_found(resp_https_t response, [[maybe_unused]] req_https_t request) { + constexpr auto code = client_error_not_found; + + nlohmann::json tree; + tree["status_code"] = static_cast(code); + tree["error"] = "Not Found"; + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json"); + headers.emplace("Access-Control-Allow-Origin", get_cors_origin()); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + + response->write(code, tree.dump(), headers); + } + + /** + * @brief Send a 400 Bad Request response. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @param error_message The error message. + */ + void bad_request(resp_https_t response, [[maybe_unused]] req_https_t request, const std::string &error_message = "Bad Request") { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + add_cors_headers(headers); + nlohmann::json error = {{"error", error_message}}; + response->write(client_error_bad_request, error.dump(), headers); + } + + void service_unavailable(resp_https_t response, const std::string &error_message) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + add_cors_headers(headers); + nlohmann::json error = {{"error", error_message}}; + response->write(SimpleWeb::StatusCode::server_error_service_unavailable, error.dump(), headers); + } + + void conflict(resp_https_t response, const std::string &error_message) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + add_cors_headers(headers); + nlohmann::json error = {{"error", error_message}}; + response->write(SimpleWeb::StatusCode::client_error_conflict, error.dump(), headers); + } + + void gateway_timeout(resp_https_t response, const std::string &error_message) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + add_cors_headers(headers); + nlohmann::json error = {{"error", error_message}}; + response->write(SimpleWeb::StatusCode::server_error_gateway_timeout, error.dump(), headers); + } + + /** + * @brief Validate the request content type and send bad request when mismatch. + * @param response The HTTP response object. + * @param request The HTTP request object. + * @param contentType The required content type. + */ + bool validateContentType(resp_https_t response, req_https_t request, const std::string_view &contentType) { + auto requestContentType = request->header.find("content-type"); + if (requestContentType == request->header.end()) { + bad_request(response, request, "Content type not provided"); + return false; + } + + // Extract the media type part before any parameters (e.g., charset) + std::string actualContentType = requestContentType->second; + size_t semicolonPos = actualContentType.find(';'); + if (semicolonPos != std::string::npos) { + actualContentType = actualContentType.substr(0, semicolonPos); + } + + // Trim whitespace and convert to lowercase for case-insensitive comparison + boost::algorithm::trim(actualContentType); + boost::algorithm::to_lower(actualContentType); + + std::string expectedContentType(contentType); + boost::algorithm::to_lower(expectedContentType); + + if (actualContentType != expectedContentType) { + bad_request(response, request, "Content type mismatch"); + return false; + } + return true; + } + + bool check_content_type(resp_https_t response, req_https_t request, const std::string_view &contentType) { + return validateContentType(response, request, contentType); + } + + /** + * @brief SPA entry responder - serves the single-page app shell (index.html) + * for any non-API and non-static-asset GET requests. Allows unauthenticated + * access so the frontend can render login/first-run flows. Static and API + * routes are expected to be registered explicitly; this function returns + * a 404 for reserved prefixes to avoid accidentally exposing files. + */ + void getSpaEntry(resp_https_t response, req_https_t request) { + print_req(request); + + const std::string &p = request->path; + // Reserved prefixes that should not be handled by the SPA entry + static const std::vector reserved = {"/api", "/assets", "/covers", "/images", "/images/"}; + for (const auto &r : reserved) { + if (p.rfind(r, 0) == 0) { + // Let explicit handlers or default not_found handle these + not_found(response, request); + return; + } + } + + // Serve the SPA shell (index.html) without server-side auth so frontend + // can manage routing and authentication flows. + std::string content = file_handler::read_file(WEB_DIR "index.html"); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "text/html; charset=utf-8"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(content, headers); + } + + // legacy per-page handlers removed; SPA entry handles these routes + + /** + * @brief Get the favicon image. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getFaviconImage(resp_https_t response, req_https_t request) { + print_req(request); + + std::ifstream in(WEB_DIR "images/apollo.ico", std::ios::binary); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "image/x-icon"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(success_ok, in, headers); + } + + /** + * @brief Get the Apollo logo image. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @todo combine function with getFaviconImage and possibly getNodeModules + * @todo use mime_types map + */ + void getApolloLogoImage(resp_https_t response, req_https_t request) { + print_req(request); + + std::ifstream in(WEB_DIR "images/logo-apollo-45.png", std::ios::binary); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "image/png"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(success_ok, in, headers); + } + + /** + * @brief Check if a path is a child of another path. + * @param base The base path. + * @param query The path to check. + * @return True if the path is a child of the base path, false otherwise. + */ + bool isChildPath(fs::path const &base, fs::path const &query) { + auto relPath = fs::relative(base, query); + return *(relPath.begin()) != fs::path(".."); + } + + /** + * @brief Get an asset from the node_modules directory. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getNodeModules(resp_https_t response, req_https_t request) { + print_req(request); + + fs::path webDirPath(WEB_DIR); + fs::path nodeModulesPath(webDirPath / "assets"); + + // .relative_path is needed to shed any leading slash that might exist in the request path + auto filePath = fs::weakly_canonical(webDirPath / fs::path(request->path).relative_path()); + + // Don't do anything if file does not exist or is outside the assets directory + if (!isChildPath(filePath, nodeModulesPath)) { + BOOST_LOG(warning) << "Someone requested a path " << filePath << " that is outside the assets folder"; + bad_request(response, request); + return; + } + + if (!fs::exists(filePath)) { + not_found(response, request); + return; + } + + auto relPath = fs::relative(filePath, webDirPath); + // get the mime type from the file extension mime_types map + // remove the leading period from the extension + auto mimeType = mime_types.find(relPath.extension().string().substr(1)); + if (mimeType == mime_types.end()) { + bad_request(response, request); + return; + } + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", mimeType->second); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + std::ifstream in(filePath.string(), std::ios::binary); + response->write(success_ok, in, headers); + } + + /** + * @brief Get the list of available applications. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/apps| GET| null} + */ + void getApps(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + try { + std::string content = file_handler::read_file(config::stream.file_apps.c_str()); + nlohmann::json file_tree = nlohmann::json::parse(content); + + file_tree["current_app"] = proc::proc.get_running_app_uuid(); + file_tree["host_uuid"] = http::unique_id; + file_tree["host_name"] = config::nvhttp.sunshine_name; +#ifdef _WIN32 + // No auto-insert here; controlled by config 'playnite_fullscreen_entry_enabled'. +#endif + + // Legacy versions of Sunshine used strings for boolean and integers, let's convert them + // List of keys to convert to boolean + std::vector boolean_keys = { + "exclude-global-prep-cmd", + "exclude-global-state-cmd", + "elevated", + "auto-detach", + "wait-all", + "terminate-on-pause", + "virtual-display", + "allow-client-commands", + "use-app-identity", + "per-client-app-identity", + "gen1-framegen-fix", + "gen2-framegen-fix", + "dlss-framegen-capture-fix", // backward compatibility + "lossless-scaling-enabled", + "lossless-scaling-framegen", + "lossless-scaling-legacy-auto-detect" + }; + + // List of keys to convert to integers + std::vector integer_keys = { + "exit-timeout", + "lossless-scaling-target-fps", + "lossless-scaling-rtss-limit", + "scale-factor", + "lossless-scaling-launch-delay" + }; + + bool mutated = false; + auto normalize_lossless_profile_overrides = [](nlohmann::json &node) -> bool { + if (!node.is_object()) { + return false; + } + bool changed = false; + auto convert_int = [&](const char *key) { + if (!node.contains(key)) { + return; + } + auto &value = node[key]; + if (value.is_string()) { + try { + value = std::stoi(value.get()); + changed = true; + } catch (...) { + } + } + }; + auto convert_bool = [&](const char *key) { + if (!node.contains(key)) { + return; + } + auto &value = node[key]; + if (value.is_string()) { + auto text = value.get(); + if (text == "true" || text == "false") { + value = (text == "true"); + changed = true; + } else if (text == "1" || text == "0") { + value = (text == "1"); + changed = true; + } + } + }; + convert_bool("performance-mode"); + convert_int("flow-scale"); + convert_int("resolution-scale"); + convert_int("sharpening"); + convert_bool("anime4k-vrs"); + if (node.contains("scaling-type") && node["scaling-type"].is_string()) { + auto text = node["scaling-type"].get(); + boost::algorithm::to_lower(text); + node["scaling-type"] = text; + changed = true; + } + if (node.contains("anime4k-size") && node["anime4k-size"].is_string()) { + auto text = node["anime4k-size"].get(); + boost::algorithm::to_upper(text); + node["anime4k-size"] = text; + changed = true; + } + return changed; + }; + // Walk fileTree and convert true/false strings to boolean or integer values + for (auto &app : file_tree["apps"]) { + for (const auto &key : boolean_keys) { + if (app.contains(key) && app[key].is_string()) { + app[key] = app[key] == "true"; + mutated = true; + } + } + for (const auto &key : integer_keys) { + if (app.contains(key) && app[key].is_string()) { + app[key] = std::stoi(app[key].get()); + mutated = true; + } + } + if (app.contains("lossless-scaling-recommended")) { + mutated = normalize_lossless_profile_overrides(app["lossless-scaling-recommended"]) || mutated; + } + if (app.contains("lossless-scaling-custom")) { + mutated = normalize_lossless_profile_overrides(app["lossless-scaling-custom"]) || mutated; + } + if (app.contains("prep-cmd")) { + for (auto &prep : app["prep-cmd"]) { + if (prep.contains("elevated") && prep["elevated"].is_string()) { + prep["elevated"] = prep["elevated"] == "true"; + mutated = true; + } + } + } + if (app.contains("state-cmd")) { + for (auto &state : app["state-cmd"]) { + if (state.contains("elevated") && state["elevated"].is_string()) { + state["elevated"] = state["elevated"] == "true"; + mutated = true; + } + } + } + // Ensure each app has a UUID (auto-insert if missing/empty) + if (!app.contains("uuid") || app["uuid"].is_null() || (app["uuid"].is_string() && app["uuid"].get().empty())) { + app["uuid"] = uuid_util::uuid_t::generate().string(); + mutated = true; + } + } + + // Add computed app ids for UI clients (best-effort, do not persist). + if (file_tree.contains("apps") && file_tree["apps"].is_array()) { + try { + const auto apps_snapshot = proc::proc.get_apps(); + const auto count = std::min(file_tree["apps"].size(), apps_snapshot.size()); + for (size_t idx = 0; idx < count; ++idx) { + auto &app = file_tree["apps"][idx]; + app["id"] = apps_snapshot[idx].id; + app["index"] = static_cast(idx); + } + } catch (...) { + } + } + + // If any normalization occurred, persist back to disk + if (mutated) { + try { + file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); + } catch (std::exception &e) { + BOOST_LOG(warning) << "GetApps persist normalization failed: "sv << e.what(); + } + } + + send_response(response, file_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "GetApps: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Save an application. To save a new application the index must be `-1`. To update an existing application, you must provide the current index of the application. + * @param response The HTTP response object. + * @param request The HTTP request object. + * The body for the post request should be JSON serialized in the following format: + * @code{.json} + * { + * "name": "Application Name", + * "output": "Log Output Path", + * "cmd": "Command to run the application", + * "exclude-global-prep-cmd": false, + * "elevated": false, + * "auto-detach": true, + * "wait-all": true, + * "exit-timeout": 5, + * "prep-cmd": [ + * { + * "do": "Command to prepare", + * "undo": "Command to undo preparation", + * "elevated": false + * } + * ], + * "detached": [ + * "Detached command" + * ], + * "image-path": "Full path to the application image. Must be a png file.", + * "uuid": "aaaa-bbbb" + * } + * @endcode + * + * @api_examples{/api/apps| POST| {"name":"Hello, World!","uuid": "aaaa-bbbb"}} + */ + void saveApp(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + + BOOST_LOG(info) << config::stream.file_apps; + try { + // TODO: Input Validation + + // Read the input JSON from the request body. + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + const int index = input_tree.at("index").get(); // intentionally throws if the provided value is missing or the wrong type + + // Read the existing apps file. + std::string content = file_handler::read_file(config::stream.file_apps.c_str()); + nlohmann::json file_tree = nlohmann::json::parse(content); + + // Migrate/merge the new app into the file tree. + proc::migrate_apps(&file_tree, &input_tree); + + if (input_tree.contains("config-overrides") && input_tree["config-overrides"].is_object()) { + auto &overrides = input_tree["config-overrides"]; + if (overrides.contains("nvenc_force_split_encode") && !overrides.contains("nvenc_split_encode")) { + overrides["nvenc_split_encode"] = overrides["nvenc_force_split_encode"]; + } + overrides.erase("nvenc_force_split_encode"); + } + + // If image-path omitted but we have a Playnite id, let Playnite helper resolve a cover (Windows) +#ifdef _WIN32 + enhance_app_with_playnite_cover(input_tree); + try { + if (input_tree.contains("playnite-id") && input_tree["playnite-id"].is_string()) { + const auto playnite_id = input_tree["playnite-id"].get(); + if (!playnite_id.empty()) { + input_tree["uuid"] = platf::playnite::sync::canonical_playnite_app_uuid(playnite_id); + } + } + } catch (...) {} +#endif + +#ifndef _WIN32 + if ((input_tree.contains("gen1-framegen-fix") && input_tree["gen1-framegen-fix"].is_boolean() && input_tree["gen1-framegen-fix"].get()) || + (input_tree.contains("dlss-framegen-capture-fix") && input_tree["dlss-framegen-capture-fix"].is_boolean() && input_tree["dlss-framegen-capture-fix"].get())) { + bad_request(response, request, "Frame generation capture fixes are only supported on Windows hosts."); + return; + } + if (input_tree.contains("gen2-framegen-fix") && input_tree["gen2-framegen-fix"].is_boolean() && input_tree["gen2-framegen-fix"].get()) { + bad_request(response, request, "Frame generation capture fixes are only supported on Windows hosts."); + return; + } +#else + // Migrate old field name to new for backward compatibility + if (input_tree.contains("dlss-framegen-capture-fix") && !input_tree.contains("gen1-framegen-fix")) { + input_tree["gen1-framegen-fix"] = input_tree["dlss-framegen-capture-fix"]; + } + // Remove old field to avoid duplication + input_tree.erase("dlss-framegen-capture-fix"); +#endif + + auto &apps_node = file_tree["apps"]; + if (!apps_node.is_array()) { + apps_node = nlohmann::json::array(); + } + input_tree.erase("index"); + + std::string input_uuid; + try { + if (input_tree.contains("uuid") && input_tree["uuid"].is_string()) { + input_uuid = input_tree["uuid"].get(); + } + } catch (...) {} + + bool replaced = false; + if (!input_uuid.empty()) { + for (auto it = apps_node.begin(); it != apps_node.end(); ++it) { + try { + if (it->contains("uuid") && (*it)["uuid"].is_string() && (*it)["uuid"].get() == input_uuid) { + *it = input_tree; + replaced = true; + break; + } + } catch (...) {} + } + } + + if (index == -1) { + if (input_uuid.empty()) { + input_uuid = uuid_util::uuid_t::generate().string(); + input_tree["uuid"] = input_uuid; + } + if (!replaced) { + apps_node.push_back(input_tree); + } + } else { + nlohmann::json newApps = nlohmann::json::array(); + for (size_t i = 0; i < apps_node.size(); ++i) { + if (i == index) { + try { + if ((!input_tree.contains("uuid") || input_tree["uuid"].is_null() || (input_tree["uuid"].is_string() && input_tree["uuid"].get().empty())) && + apps_node[i].contains("uuid") && apps_node[i]["uuid"].is_string()) { + input_tree["uuid"] = apps_node[i]["uuid"].get(); + } + } catch (...) {} + newApps.push_back(input_tree); + } else { + newApps.push_back(apps_node[i]); + } + } + file_tree["apps"] = newApps; + } + + // Update apps file and refresh client cache + confighttp::refresh_client_apps_cache(file_tree); + + // Prepare and send the output response. + nlohmann::json outputTree; + outputTree["status"] = true; + send_response(response, outputTree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "SaveApp: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Serve a specific application's cover image by UUID. + * Looks for files named @c uuid with a supported image extension in the covers directory. + * @api_examples{/api/apps/@c uuid/cover| GET| null} + */ + void getAppCover(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + if (request->path_match.size() < 2) { + bad_request(response, request, "Application uuid required"); + return; + } + + std::string uuid = request->path_match[1]; + if (uuid.empty()) { + bad_request(response, request, "Application uuid required"); + return; + } + + fs::path cover_path; + if (!resolve_cover_path_for_uuid(uuid, cover_path)) { + not_found(response, request); + return; + } + + std::ifstream in(cover_path, std::ios::binary); + if (!in) { + not_found(response, request); + return; + } + + std::string ext = cover_path.extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (!ext.empty() && ext.front() == '.') { + ext.erase(ext.begin()); + } + + std::string mime = "image/png"; + if (!ext.empty()) { + auto it = mime_types.find(ext); + if (it != mime_types.end()) { + mime = it->second; + } + } + + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", mime); + headers.emplace("Cache-Control", "private, max-age=300"); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(success_ok, in, headers); + } + + /** + * @brief Upload or set a specific application's cover image by UUID. + * Accepts either a JSON body with {"url": "..."} (restricted to images.igdb.com) or {"data": base64}. + * Saves to appdata/covers/@c uuid.@c ext where ext is derived from URL or defaults to .png for data. + * @api_examples{/api/apps/@c uuid/cover| POST| {"url":"https://images.igdb.com/.../abc.png"}} + */ + + /** + * @brief Close the currently running application. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/apps/close| POST| null} + */ + void closeApp(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + proc::proc.terminate(); + nlohmann::json output_tree; + output_tree["status"] = true; + send_response(response, output_tree); + } + + /** + * @brief Reorder applications. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/apps/reorder| POST| {"order": ["aaaa-bbbb", "cccc-dddd"]}} + */ + void reorderApps(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + try { + std::stringstream ss; + ss << request->content.rdbuf(); + + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + + // Read the existing apps file. + std::string content = file_handler::read_file(config::stream.file_apps.c_str()); + nlohmann::json fileTree = nlohmann::json::parse(content); + + // Get the desired order of UUIDs from the request. + if (!input_tree.contains("order") || !input_tree["order"].is_array()) { + throw std::runtime_error("Missing or invalid 'order' array in request body"); + } + const auto &order_uuids_json = input_tree["order"]; + + // Get the original apps array from the fileTree. + // Default to an empty array if "apps" key is missing or if it's present but not an array (after logging an error). + nlohmann::json original_apps_list = nlohmann::json::array(); + if (fileTree.contains("apps")) { + if (fileTree["apps"].is_array()) { + original_apps_list = fileTree["apps"]; + } else { + // "apps" key exists but is not an array. This is a malformed state. + BOOST_LOG(error) << "ReorderApps: 'apps' key in apps configuration file ('" << config::stream.file_apps + << "') is present but not an array."; + throw std::runtime_error("'apps' in file is not an array, cannot reorder."); + } + } else { + // "apps" key is missing. Treat as an empty list. Reordering an empty list is valid. + BOOST_LOG(debug) << "ReorderApps: 'apps' key missing in apps configuration file ('" << config::stream.file_apps + << "'). Treating as an empty list for reordering."; + // original_apps_list is already an empty array, so no specific action needed here. + } + + nlohmann::json reordered_apps_list = nlohmann::json::array(); + std::vector item_moved(original_apps_list.size(), false); + + // Phase 1: Place apps according to the 'order' array from the request. + // Iterate through the desired order of UUIDs. + for (const auto &uuid_json_value : order_uuids_json) { + if (!uuid_json_value.is_string()) { + BOOST_LOG(warning) << "ReorderApps: Encountered a non-string UUID in the 'order' array. Skipping this entry."; + continue; + } + std::string target_uuid = uuid_json_value.get(); + bool found_match_for_ordered_uuid = false; + + // Find the first unmoved app in the original list that matches the current target_uuid. + for (size_t i = 0; i < original_apps_list.size(); ++i) { + if (item_moved[i]) { + continue; // This specific app object has already been placed. + } + + const auto &app_item = original_apps_list[i]; + // Ensure the app item is an object and has a UUID to match against. + if (app_item.is_object() && app_item.contains("uuid") && app_item["uuid"].is_string()) { + if (app_item["uuid"].get() == target_uuid) { + reordered_apps_list.push_back(app_item); // Add the found app object to the new list. + item_moved[i] = true; // Mark this specific object as moved. + found_match_for_ordered_uuid = true; + break; // Found an app for this UUID, move to the next UUID in the 'order' array. + } + } + } + + if (!found_match_for_ordered_uuid) { + // This means a UUID specified in the 'order' array was not found in the original_apps_list + // among the currently available (unmoved) app objects. + // Per instruction "If the uuid is missing from the original json file, omit it." + BOOST_LOG(debug) << "ReorderApps: UUID '" << target_uuid << "' from 'order' array not found in available apps list or its matching app was already processed. Omitting."; + } + } + + // Phase 2: Append any remaining apps from the original list that were not explicitly ordered. + // These are app objects that were not marked 'item_moved' in Phase 1. + for (size_t i = 0; i < original_apps_list.size(); ++i) { + if (!item_moved[i]) { + reordered_apps_list.push_back(original_apps_list[i]); + } + } + + // Update the fileTree with the new, reordered list of apps. + fileTree["apps"] = reordered_apps_list; + + // Write the modified fileTree back to the apps configuration file. + file_handler::write_file(config::stream.file_apps.c_str(), fileTree.dump(4)); + + // Notify relevant parts of the system that the apps configuration has changed. + proc::refresh(config::stream.file_apps, false); + + output_tree["status"] = true; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "ReorderApps: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Delete an application. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/apps/delete | POST| { uuid: 'aaaa-bbbb' }} + */ + void deleteApp(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + const bool is_delete_method = request->method == "DELETE"; + std::optional index_from_path; + if (request->path_match.size() > 1) { + try { + index_from_path = static_cast(std::stoul(request->path_match[1])); + } catch (...) { + } + } + + std::stringstream ss; + ss << request->content.rdbuf(); + std::string raw_body = ss.str(); + + std::optional uuid; + std::optional index_from_body; + + if (!raw_body.empty()) { + if (!validateContentType(response, request, "application/json")) { + return; + } + try { + nlohmann::json input_tree = nlohmann::json::parse(raw_body); + if (input_tree.contains("uuid") && input_tree["uuid"].is_string()) { + uuid = input_tree["uuid"].get(); + } + if (input_tree.contains("index") && input_tree["index"].is_number_integer()) { + auto idx = input_tree["index"].get(); + if (idx >= 0) { + index_from_body = static_cast(idx); + } + } + } catch (const std::exception &e) { + bad_request(response, request, e.what()); + return; + } + } else if (!is_delete_method) { + bad_request(response, request, "Missing request body"); + return; + } + + std::optional target_index = index_from_body ? index_from_body : index_from_path; + + // Detect if the app being removed is the Playnite fullscreen launcher + auto is_playnite_fullscreen = [](const nlohmann::json &app) -> bool { + try { + if (app.contains("playnite-fullscreen") && app["playnite-fullscreen"].is_boolean() && app["playnite-fullscreen"].get()) { + return true; + } + if (app.contains("cmd") && app["cmd"].is_string()) { + auto s = app["cmd"].get(); + if (s.find("playnite-launcher") != std::string::npos && s.find("--fullscreen") != std::string::npos) { + return true; + } + } + if (app.contains("name") && app["name"].is_string() && app["name"].get() == "Playnite (Fullscreen)") { + return true; + } + } catch (...) {} + return false; + }; + + try { + std::string content = file_handler::read_file(config::stream.file_apps.c_str()); + nlohmann::json file_tree = nlohmann::json::parse(content); + if (!file_tree.contains("apps") || !file_tree["apps"].is_array()) { + bad_request(response, request, "Apps configuration missing or invalid"); + return; + } + + auto &apps_node = file_tree["apps"]; + nlohmann::json::array_t new_apps; + new_apps.reserve(apps_node.size()); + + bool removed = false; + bool disabled_fullscreen_flag = false; + + for (size_t i = 0; i < apps_node.size(); ++i) { + const auto &app_entry = apps_node[i]; + auto app_uuid = app_entry.contains("uuid") && app_entry["uuid"].is_string() ? app_entry["uuid"].get() : std::string {}; + + bool match = false; + if (uuid && !uuid->empty()) { + match = app_uuid == *uuid; + } else if (!uuid && target_index && *target_index == i) { + match = true; + if (!app_uuid.empty()) { + uuid = app_uuid; + } + } + + if (!match) { + new_apps.push_back(app_entry); + continue; + } + + removed = true; + +#ifdef _WIN32 + try { + if (is_playnite_fullscreen(app_entry)) { + auto current_cfg = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); + current_cfg["playnite_fullscreen_entry_enabled"] = "false"; + std::stringstream config_stream; + for (const auto &kv : current_cfg) { + config_stream << kv.first << " = " << kv.second << std::endl; + } + file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); + config::apply_config_now(); + disabled_fullscreen_flag = true; + } + } catch (...) { + } +#endif + } + + if (!removed) { + bad_request(response, request, "App to delete not found"); + return; + } + + file_tree["apps"] = new_apps; + file_handler::write_file(config::stream.file_apps.c_str(), file_tree.dump(4)); + proc::refresh(config::stream.file_apps, false); + + nlohmann::json output_tree; + output_tree["status"] = true; + if (disabled_fullscreen_flag) { + output_tree["playniteFullscreenDisabled"] = true; + } + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "DeleteApp: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Get the list of paired clients. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/clients/list| GET| null} + */ + void getClients(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json named_certs = nvhttp::get_all_clients(); + nlohmann::json output_tree; + output_tree["named_certs"] = named_certs; +#ifdef _WIN32 + output_tree["platform"] = "windows"; +#endif + output_tree["status"] = true; + output_tree["platform"] = SUNSHINE_PLATFORM; + send_response(response, output_tree); + } + +#ifdef _WIN32 + static std::optional file_creation_time_ms(const std::filesystem::path &path) { + WIN32_FILE_ATTRIBUTE_DATA data {}; + if (!GetFileAttributesExW(path.c_str(), GetFileExInfoStandard, &data)) { + return std::nullopt; + } + ULARGE_INTEGER t {}; + t.LowPart = data.ftCreationTime.dwLowDateTime; + t.HighPart = data.ftCreationTime.dwHighDateTime; + + // FILETIME is in 100ns units since 1601-01-01. + constexpr uint64_t kEpochDiff100ns = 116444736000000000ULL; // 1970-01-01 - 1601-01-01 + if (t.QuadPart < kEpochDiff100ns) { + return std::nullopt; + } + return (t.QuadPart - kEpochDiff100ns) / 10000ULL; + } + + static std::filesystem::path windows_color_profile_dir() { + wchar_t system_root[MAX_PATH] = {}; + if (GetSystemWindowsDirectoryW(system_root, _countof(system_root)) == 0) { + return std::filesystem::path(L"C:\\Windows\\System32\\spool\\drivers\\color"); + } + std::filesystem::path root(system_root); + return root / L"System32" / L"spool" / L"drivers" / L"color"; + } +#endif + + /** + * @brief Get a list of available HDR color profiles (Windows only). + * + * @api_examples{/api/clients/hdr-profiles| GET| null} + */ + void getHdrProfiles(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["status"] = true; + nlohmann::json profiles = nlohmann::json::array(); + +#ifdef _WIN32 + try { + const auto dir = windows_color_profile_dir(); + + struct entry_t { + std::string filename; + uint64_t added_ms; + }; + + std::vector entries; + for (const auto &entry : std::filesystem::directory_iterator(dir)) { + std::error_code ec; + if (!entry.is_regular_file(ec)) { + continue; + } + + auto ext = entry.path().extension().wstring(); + std::transform(ext.begin(), ext.end(), ext.begin(), [](wchar_t ch) { + return static_cast(std::towlower(ch)); + }); + if (ext != L".icm" && ext != L".icc") { + continue; + } + + const auto filename_utf8 = platf::to_utf8(entry.path().filename().wstring()); + const auto added_ms = file_creation_time_ms(entry.path()).value_or(0); + entries.push_back({filename_utf8, added_ms}); + } + + std::sort(entries.begin(), entries.end(), [](const entry_t &a, const entry_t &b) { + if (a.added_ms != b.added_ms) { + return a.added_ms > b.added_ms; + } + return a.filename < b.filename; + }); + + for (const auto &e : entries) { + nlohmann::json node; + node["filename"] = e.filename; + node["added_ms"] = e.added_ms; + profiles.push_back(std::move(node)); + } + } catch (const std::exception &e) { + output_tree["status"] = false; + output_tree["error"] = e.what(); + } catch (...) { + output_tree["status"] = false; + output_tree["error"] = "unknown error"; + } +#endif + + output_tree["profiles"] = std::move(profiles); + send_response(response, output_tree); + } + +#ifdef _WIN32 + // removed unused forward declaration for default_playnite_ext_dir() +#endif + + /** + * @brief Update stored settings for a paired client. + */ + /** + * @brief Disconnect a client session without unpairing it. + */ + void disconnectClient(resp_https_t response, req_https_t request) { + if (!check_content_type(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + + try { + const nlohmann::json input_tree = nlohmann::json::parse(ss); + nlohmann::json output_tree; + const std::string uuid = input_tree.value("uuid", ""); + output_tree["status"] = nvhttp::disconnect_client(uuid); + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "DisconnectClient: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Unpair a client. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The body for the POST request should be JSON serialized in the following format: + * @code{.json} + * { + * "uuid": "", + * "name": "", + * "display_mode": "1920x1080x59.94", + * "do": [ { "cmd": "", "elevated": false }, ... ], + * "undo": [ { "cmd": "", "elevated": false }, ... ], + * "perm": + * } + * @endcode + */ + void updateClient(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + std::string uuid = input_tree.value("uuid", ""); + std::optional hdr_profile; + if (input_tree.contains("hdr_profile")) { + if (input_tree["hdr_profile"].is_null()) { + hdr_profile = std::string {}; + } else { + hdr_profile = input_tree.value("hdr_profile", ""); + } + } + + const bool has_extended_fields = + input_tree.contains("name") || + input_tree.contains("display_mode") || + input_tree.contains("output_name_override") || + input_tree.contains("always_use_virtual_display") || + input_tree.contains("virtual_display_mode") || + input_tree.contains("virtual_display_layout") || + input_tree.contains("config_overrides") || + input_tree.contains("prefer_10bit_sdr") || + input_tree.contains("enable_legacy_ordering") || + input_tree.contains("allow_client_commands") || + input_tree.contains("perm") || + input_tree.contains("do") || + input_tree.contains("undo"); + + if (!has_extended_fields && hdr_profile.has_value()) { + output_tree["status"] = nvhttp::set_client_hdr_profile(uuid, hdr_profile.value()); + send_response(response, output_tree); + return; + } + + std::string name = input_tree.value("name", ""); + std::string display_mode = input_tree.value("display_mode", ""); + std::string output_name_override = input_tree.value("output_name_override", ""); + bool enable_legacy_ordering = input_tree.value("enable_legacy_ordering", true); + bool allow_client_commands = input_tree.value("allow_client_commands", true); + bool always_use_virtual_display = input_tree.value("always_use_virtual_display", false); + std::optional prefer_10bit_sdr; + if (input_tree.contains("prefer_10bit_sdr") && !input_tree["prefer_10bit_sdr"].is_null()) { + prefer_10bit_sdr = util::get_non_string_json_value(input_tree, "prefer_10bit_sdr", false); + } else { + prefer_10bit_sdr.reset(); + } + std::optional> config_overrides; + if (input_tree.contains("config_overrides")) { + if (input_tree["config_overrides"].is_null()) { + config_overrides = std::unordered_map {}; + } else if (input_tree["config_overrides"].is_object()) { + std::unordered_map overrides; + for (const auto &item : input_tree["config_overrides"].items()) { + std::string key = item.key(); + if (key == "nvenc_force_split_encode") { + key = "nvenc_split_encode"; + } + const auto &val = item.value(); + if (key.empty() || val.is_null()) { + continue; + } + std::string encoded; + if (val.is_string()) { + encoded = val.get(); + } else { + encoded = val.dump(); + } + overrides[key] = std::move(encoded); + } + config_overrides = std::move(overrides); + } + } + std::string virtual_display_mode = input_tree.value("virtual_display_mode", ""); + std::string virtual_display_layout = input_tree.value("virtual_display_layout", ""); + auto do_cmds = nvhttp::extract_command_entries(input_tree, "do"); + auto undo_cmds = nvhttp::extract_command_entries(input_tree, "undo"); + auto perm = static_cast(input_tree.value("perm", static_cast(crypto::PERM::_no)) & static_cast(crypto::PERM::_all)); + bool updated = nvhttp::update_device_info( + uuid, + name, + display_mode, + output_name_override, + do_cmds, + undo_cmds, + perm, + enable_legacy_ordering, + allow_client_commands, + always_use_virtual_display, + virtual_display_mode, + virtual_display_layout, + prefer_10bit_sdr + ); + if (config_overrides.has_value() || hdr_profile.has_value()) { + updated = nvhttp::update_device_info( + uuid, + name, + display_mode, + output_name_override, + always_use_virtual_display, + virtual_display_mode, + virtual_display_layout, + std::move(config_overrides), + prefer_10bit_sdr, + hdr_profile + ) + && updated; + } + output_tree["status"] = updated; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "Update Client: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Unpair a client. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The body for the POST request should be JSON serialized in the following format: + * @code{.json} + * { + * "uuid": "" + * } + * @endcode + * + * @api_examples{/api/clients/unpair| POST| {"uuid":"1234"}} + */ + void unpair(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + std::string uuid = input_tree.value("uuid", ""); + output_tree["status"] = nvhttp::unpair_client(uuid); + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "Unpair: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Unpair all clients. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/clients/unpair-all| POST| null} + */ + void unpairAll(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + nvhttp::erase_all_clients(); + proc::proc.terminate(); + nlohmann::json output_tree; + output_tree["status"] = true; + send_response(response, output_tree); + } + + /** + * @brief Get the configuration settings. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getConfig(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["status"] = true; + output_tree["platform"] = SUNSHINE_PLATFORM; + output_tree["version"] = PROJECT_VERSION; +#ifdef _WIN32 + output_tree["vdisplayStatus"] = (int) proc::vDisplayDriverStatus; +#endif + auto vars = config::parse_config(file_handler::read_file(config::sunshine.config_file.c_str())); + for (auto &[name, value] : vars) { + output_tree[name] = value; + } + send_response(response, output_tree); + } + + /** + * @brief Get immutables metadata about the server. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/meta| GET| null} + */ + void getMetadata(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["status"] = true; + output_tree["platform"] = SUNSHINE_PLATFORM; + output_tree["version"] = PROJECT_VERSION; + output_tree["commit"] = PROJECT_VERSION_COMMIT; +#ifdef PROJECT_VERSION_PRERELEASE + output_tree["prerelease"] = PROJECT_VERSION_PRERELEASE; +#else + output_tree["prerelease"] = ""; +#endif +#ifdef PROJECT_VERSION_BRANCH + output_tree["branch"] = PROJECT_VERSION_BRANCH; +#else + output_tree["branch"] = "unknown"; +#endif + // Build/release date provided by CMake (ISO 8601 when available) + output_tree["release_date"] = PROJECT_RELEASE_DATE; +#if defined(_WIN32) + try { + const auto gpus = platf::enumerate_gpus(); + if (!gpus.empty()) { + nlohmann::json gpu_array = nlohmann::json::array(); + bool has_nvidia = false; + bool has_amd = false; + bool has_intel = false; + + for (const auto &gpu : gpus) { + nlohmann::json gpu_entry; + gpu_entry["description"] = gpu.description; + gpu_entry["vendor_id"] = gpu.vendor_id; + gpu_entry["device_id"] = gpu.device_id; + gpu_entry["dedicated_video_memory"] = gpu.dedicated_video_memory; + gpu_array.push_back(std::move(gpu_entry)); + + switch (gpu.vendor_id) { + case 0x10DE: // NVIDIA + has_nvidia = true; + break; + case 0x1002: // AMD/ATI + case 0x1022: // AMD alternative PCI vendor ID (APUs) + has_amd = true; + break; + case 0x8086: // Intel + has_intel = true; + break; + default: + break; + } + } + + output_tree["gpus"] = std::move(gpu_array); + output_tree["has_nvidia_gpu"] = has_nvidia; + output_tree["has_amd_gpu"] = has_amd; + output_tree["has_intel_gpu"] = has_intel; + } + + const auto version = platf::query_windows_version(); + if (!version.display_version.empty()) { + output_tree["windows_display_version"] = version.display_version; + } + if (!version.release_id.empty()) { + output_tree["windows_release_id"] = version.release_id; + } + if (!version.product_name.empty()) { + output_tree["windows_product_name"] = version.product_name; + } + if (!version.current_build.empty()) { + output_tree["windows_current_build"] = version.current_build; + } + if (version.build_number.has_value()) { + output_tree["windows_build_number"] = version.build_number.value(); + } + if (version.major_version.has_value()) { + output_tree["windows_major_version"] = version.major_version.value(); + } + if (version.minor_version.has_value()) { + output_tree["windows_minor_version"] = version.minor_version.value(); + } + } catch (...) { + // Non-fatal; keep metadata response minimal if enumeration fails. + } +#endif + send_response(response, output_tree); + } + + /** + * @brief Get the locale setting. This endpoint does not require authentication. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/configLocale| GET| null} + */ + void getLocale(resp_https_t response, req_https_t request) { + print_req(request); + + nlohmann::json output_tree; + output_tree["status"] = true; + output_tree["locale"] = config::sunshine.locale; + send_response(response, output_tree); + } + + /** + * @brief Get the active remote microphone debug status. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void getAudioDebug(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + const auto snapshot = audio::get_mic_debug_snapshot(); + nlohmann::json output_tree; + output_tree["status"] = true; + output_tree["sessionActive"] = snapshot.session_active; + output_tree["micRequested"] = snapshot.mic_requested; + output_tree["encryptionEnabled"] = snapshot.encryption_enabled; + output_tree["backendInitialized"] = snapshot.backend_initialized; + output_tree["firstPacketReceived"] = snapshot.first_packet_received; + output_tree["decodeActive"] = snapshot.decode_active; + output_tree["renderActive"] = snapshot.render_active; + output_tree["signalDetected"] = snapshot.signal_detected; + output_tree["packetsReceived"] = snapshot.packets_received; + output_tree["packetsDecoded"] = snapshot.packets_decoded; + output_tree["packetsRendered"] = snapshot.packets_rendered; + output_tree["packetsDropped"] = snapshot.packets_dropped; + output_tree["decryptErrors"] = snapshot.decrypt_errors; + output_tree["decodeErrors"] = snapshot.decode_errors; + output_tree["renderErrors"] = snapshot.render_errors; + output_tree["silentPackets"] = snapshot.silent_packets; + output_tree["lastSequenceNumber"] = snapshot.last_sequence_number; + output_tree["lastPayloadSize"] = snapshot.last_payload_size; + output_tree["lastInputLevel"] = snapshot.last_input_level; + output_tree["lastRenderLevel"] = snapshot.last_render_level; + output_tree["lastPacketAgeMs"] = snapshot.last_packet_age_ms; + output_tree["lastDecodeAgeMs"] = snapshot.last_decode_age_ms; + output_tree["lastRenderAgeMs"] = snapshot.last_render_age_ms; + output_tree["clientName"] = snapshot.client_name; + output_tree["backendName"] = snapshot.backend_name; + output_tree["targetDeviceName"] = snapshot.target_device_name; + output_tree["endpointMixFormat"] = snapshot.endpoint_mix_format; + output_tree["renderDeviceFormat"] = snapshot.render_device_format; + output_tree["renderFormat"] = snapshot.render_format; + output_tree["captureDeviceName"] = snapshot.capture_device_name; + output_tree["captureEndpointMixFormat"] = snapshot.capture_endpoint_mix_format; + output_tree["captureDeviceFormat"] = snapshot.capture_device_format; + output_tree["resamplingActive"] = snapshot.resampling_active; + output_tree["recommendedFormatEnforced"] = snapshot.recommended_format_enforced; + output_tree["recommendedFormatActive"] = snapshot.recommended_format_active; + output_tree["channelMapping"] = snapshot.channel_mapping; + output_tree["state"] = snapshot.state; + output_tree["lastError"] = snapshot.last_error; + output_tree["recentEvents"] = snapshot.recent_events; + send_response(response, output_tree); + } + + /** + * @brief Save the configuration settings. + * @param response The HTTP response object. + * @param request The HTTP request object. + * The body for the post request should be JSON serialized in the following format: + * @code{.json} + * { + * "key": "value" + * } + * @endcode + * + * @attention{It is recommended to ONLY save the config settings that differ from the default behavior.} + * + * @api_examples{/api/config| POST| {"key":"value"}} + */ + void saveConfig(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + // TODO: Input Validation + std::stringstream config_stream; + nlohmann::json output_tree; + nlohmann::json input_tree = nlohmann::json::parse(ss); + std::set changed_keys; + for (const auto &[k, v] : input_tree.items()) { + changed_keys.insert(k); + if (v.is_null() || (v.is_string() && v.get().empty())) { + continue; + } + + // v.dump() will dump valid json, which we do not want for strings in the config right now + // we should migrate the config file to straight json and get rid of all this nonsense + config_stream << k << " = " << (v.is_string() ? v.get() : v.dump()) << std::endl; + } + file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); + + // Detect restart-required keys + static const std::set restart_required_keys = { + "port", + "address_family", + "upnp", + "pkey", + "cert" + }; + bool restart_required = false; + for (const auto &k : changed_keys) { + if (restart_required_keys.count(k)) { + restart_required = true; + break; + } + } + + bool applied_now = false; + bool deferred = false; + + if (!restart_required) { + if (can_hot_apply_during_session(changed_keys) || !has_active_stream_sessions()) { + // Apply immediately + config::apply_config_now(); + applied_now = true; + } else { + config::mark_deferred_reload(); + deferred = true; + } + } + + output_tree["status"] = true; + output_tree["appliedNow"] = applied_now; + output_tree["deferred"] = deferred; + output_tree["restartRequired"] = restart_required; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "SaveConfig: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Partial update of configuration (PATCH /api/config). + * Merges provided JSON object into the existing key=value style config file. + * Removes keys when value is null or an empty string. Detects whether a + * restart is required and attempts to apply immediately when safe. + */ + void patchConfig(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json output_tree; + nlohmann::json patch_tree = nlohmann::json::parse(ss); + if (!patch_tree.is_object()) { + bad_request(response, request, "PATCH body must be a JSON object"); + return; + } + + // Load existing config into a map + std::unordered_map current = config::parse_config( + file_handler::read_file(config::sunshine.config_file.c_str()) + ); + + // Track which keys are being modified to detect restart requirements + std::set changed_keys; + + for (auto it = patch_tree.begin(); it != patch_tree.end(); ++it) { + const std::string key = it.key(); + const nlohmann::json &val = it.value(); + changed_keys.insert(key); + + // Remove key when explicitly null or empty string + if (val.is_null() || (val.is_string() && val.get().empty())) { + auto curIt = current.find(key); + if (curIt != current.end()) { + current.erase(curIt); + } + continue; + } + + // Persist value: strings are raw, non-strings are dumped as JSON + if (val.is_string()) { + current[key] = val.get(); + } else { + current[key] = val.dump(); + } + } + + // Write back full merged config file + std::stringstream config_stream; + for (const auto &kv : current) { + config_stream << kv.first << " = " << kv.second << std::endl; + } + file_handler::write_file(config::sunshine.config_file.c_str(), config_stream.str()); + + // Detect restart-required keys + static const std::set restart_required_keys = { + "port", + "address_family", + "upnp", + "pkey", + "cert" + }; + bool restart_required = false; + for (const auto &k : changed_keys) { + if (restart_required_keys.count(k)) { + restart_required = true; + break; + } + } + + bool applied_now = false; + bool deferred = false; + if (!restart_required) { + if (can_hot_apply_during_session(changed_keys) || !has_active_stream_sessions()) { + // Apply immediately + config::apply_config_now(); + applied_now = true; + } else { + config::mark_deferred_reload(); + deferred = true; + } + } + + output_tree["status"] = true; + output_tree["appliedNow"] = applied_now; + output_tree["deferred"] = deferred; + output_tree["restartRequired"] = restart_required; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "PatchConfig: "sv << e.what(); + bad_request(response, request, e.what()); + return; + } + } + + // Lightweight session status for UI messaging + void getSessionStatus(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + + nlohmann::json output_tree; + const int active = rtsp_stream::session_count(); + const bool app_running = proc::proc.running() > 0; + output_tree["activeSessions"] = active; + output_tree["appRunning"] = app_running; + output_tree["appName"] = app_running ? proc::proc.get_last_run_app_name() : ""; + output_tree["paused"] = app_running && active == 0; + output_tree["status"] = true; + send_response(response, output_tree); + } + + // Live host system performance counters (CPU/GPU/RAM/VRAM/temps). + void getHostStats(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + + send_response(response, host_stats_to_json(host_stats::latest())); + } + + // Static host info — model strings + total RAM/VRAM, sampled once. + void getHostInfo(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + + send_response(response, host_info_to_json(host_stats::info())); + } + + + void listRTSPSessions(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + nlohmann::json output; + output["sessions"] = nlohmann::json::array(); + for (const auto &info : stream::get_all_session_info()) { + output["sessions"].push_back(rtsp_session_to_json(info)); + } + send_response(response, output); + } + + void listWebRTCSessions(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + nlohmann::json output; + output["sessions"] = nlohmann::json::array(); + for (const auto &session : webrtc_stream::list_sessions()) { + output["sessions"].push_back(webrtc_session_to_json(session)); + } + send_response(response, output); + } + + // ── Session History endpoints ──────────────────────────────────── + + void listSessionHistory(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + int limit = 25; + int offset = 0; + auto query = request->parse_query_string(); + auto it_limit = query.find("limit"); + if (it_limit != query.end()) { + try { limit = std::stoi(it_limit->second); } catch (...) {} + } + auto it_offset = query.find("offset"); + if (it_offset != query.end()) { + try { offset = std::stoi(it_offset->second); } catch (...) {} + } + limit = std::clamp(limit, 1, 100); + offset = std::max(offset, 0); + + nlohmann::json output; + output["sessions"] = nlohmann::json::array(); + for (const auto &s : session_history::list_sessions(limit, offset)) { + output["sessions"].push_back(session_summary_to_json(s)); + } + output["history_status"] = history_status_to_json(session_history::get_history_status()); + send_response(response, output); + } + + void getSessionHistoryDetail(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + auto uuid = request->path_match[1].str(); + const auto query = request->parse_query_string(); + const bool include_all = [&query]() { + auto it = query.find("full"); + if (it == query.end()) { + return false; + } + return it->second == "1" || it->second == "true" || it->second == "yes"; + }(); + + auto detail = session_history::get_session_detail(uuid, include_all); + if (!detail) { + not_found(response, request); + return; + } + + auto output = session_detail_to_json(*detail); + output["history_status"] = history_status_to_json(session_history::get_history_status()); + send_response(response, output); + } + + void deleteSessionHistory(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + auto uuid = request->path_match[1].str(); + auto result = session_history::delete_session(uuid); + switch (result) { + case session_history::delete_result_e::deleted: + break; + case session_history::delete_result_e::not_found: + not_found(response, request); + return; + case session_history::delete_result_e::active_session: + conflict(response, "Cannot delete an active session"); + return; + case session_history::delete_result_e::unavailable: + service_unavailable(response, "Session history subsystem unavailable"); + return; + case session_history::delete_result_e::timeout: + gateway_timeout(response, "Timed out waiting for session history delete"); + return; + case session_history::delete_result_e::failed: + service_unavailable(response, "Session history delete failed"); + return; + } + + nlohmann::json output; + output["status"] = "ok"; + output["uuid"] = uuid; + send_response(response, output); + } + + void getActiveSessionHistory(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + nlohmann::json output; + output["sessions"] = nlohmann::json::array(); + for (const auto &as : session_history::get_active_sessions()) { + output["sessions"].push_back(active_session_to_json(as)); + } + send_response(response, output); + } + + void createWebRTCSession(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + BOOST_LOG(debug) << "WebRTC: create session request received"; + + webrtc_stream::SessionOptions options; + std::stringstream ss; + ss << request->content.rdbuf(); + auto body = ss.str(); + if (!body.empty()) { + if (!check_content_type(response, request, "application/json")) { + return; + } + try { + nlohmann::json input = nlohmann::json::parse(body); + if (input.contains("audio")) { + options.audio = input.at("audio").get(); + } + if (input.contains("host_audio")) { + options.host_audio = input.at("host_audio").get(); + } + if (input.contains("video")) { + options.video = input.at("video").get(); + } + if (input.contains("encoded")) { + options.encoded = input.at("encoded").get(); + } + if (input.contains("width")) { + const int width = input.at("width").get(); + if (width > 0) { + options.width = width; + } + } + if (input.contains("height")) { + const int height = input.at("height").get(); + if (height > 0) { + options.height = height; + } + } + if (input.contains("fps")) { + const int fps = input.at("fps").get(); + if (fps > 0) { + options.fps = fps; + } + } + if (input.contains("bitrate_kbps")) { + options.bitrate_kbps = input.at("bitrate_kbps").get(); + } + if (input.contains("codec")) { + options.codec = input.at("codec").get(); + } + if (input.contains("hdr")) { + options.hdr = input.at("hdr").get(); + } + if (input.contains("audio_channels")) { + options.audio_channels = input.at("audio_channels").get(); + } + if (input.contains("audio_codec")) { + options.audio_codec = input.at("audio_codec").get(); + } + if (input.contains("profile")) { + options.profile = input.at("profile").get(); + } + if (input.contains("app_id")) { + options.app_id = input.at("app_id").get(); + } + if (input.contains("resume")) { + options.resume = input.at("resume").get(); + } + if (input.contains("video_pacing_mode")) { + options.video_pacing_mode = input.at("video_pacing_mode").get(); + } + if (input.contains("video_pacing_slack_ms")) { + options.video_pacing_slack_ms = input.at("video_pacing_slack_ms").get(); + } + if (input.contains("video_max_frame_age_ms")) { + options.video_max_frame_age_ms = input.at("video_max_frame_age_ms").get(); + } + + if (options.codec) { + auto lower = *options.codec; + boost::algorithm::to_lower(lower); + if (lower != "h264" && lower != "hevc" && lower != "av1") { + bad_request(response, request, "Unsupported codec"); + return; + } + options.codec = std::move(lower); + } + if (options.audio_codec) { + auto lower = *options.audio_codec; + boost::algorithm::to_lower(lower); + if (lower != "opus" && lower != "aac") { + bad_request(response, request, "Unsupported audio codec"); + return; + } + options.audio_codec = std::move(lower); + } + if (options.audio_channels) { + int channels = *options.audio_channels; + if (channels != 2 && channels != 6 && channels != 8) { + bad_request(response, request, "Unsupported audio channel count"); + return; + } + } + if (options.video_pacing_mode) { + auto lower = *options.video_pacing_mode; + boost::algorithm::to_lower(lower); + if (lower == "smooth") { + lower = "smoothness"; + } + if (lower != "latency" && lower != "balanced" && lower != "smoothness") { + bad_request(response, request, "Unsupported video pacing mode"); + return; + } + options.video_pacing_mode = std::move(lower); + } + if (options.video_pacing_slack_ms) { + const int slack_ms = *options.video_pacing_slack_ms; + if (slack_ms < 0 || slack_ms > 10) { + bad_request(response, request, "video_pacing_slack_ms must be between 0 and 10"); + return; + } + } + if (options.video_max_frame_age_ms) { + const int max_age_ms = *options.video_max_frame_age_ms; + if (max_age_ms < 5 || max_age_ms > 250) { + bad_request(response, request, "video_max_frame_age_ms must be between 5 and 250"); + return; + } + } + if (options.hdr.value_or(false)) { + if (!options.encoded) { + bad_request(response, request, "HDR requires encoded video for WebRTC sessions"); + return; + } + if (!options.codec || (*options.codec != "hevc" && *options.codec != "av1")) { + bad_request(response, request, "HDR requires HEVC or AV1 video encoding"); + return; + } + } + if (options.hdr.value_or(false)) { + if (!options.encoded) { + bad_request(response, request, "HDR requires encoded video for WebRTC sessions"); + return; + } + if (!options.codec || (*options.codec != "hevc" && *options.codec != "av1")) { + bad_request(response, request, "HDR requires HEVC or AV1 video encoding"); + return; + } + } + } catch (const std::exception &e) { + bad_request(response, request, e.what()); + return; + } + } + + BOOST_LOG(debug) << "WebRTC: creating session"; + if (auto error = webrtc_stream::ensure_capture_started(options)) { +#ifdef _WIN32 + // Lifecycle gap: if capture start fails after a virtual display was created/applied but + // before a session exists, ensure we don't leave the virtual display behind. + if (rtsp_stream::session_count() == 0 && !webrtc_stream::has_active_sessions()) { + (void) platf::virtual_display_cleanup::run( + "webrtc_session_start_failed", + config::video.dd.config_revert_on_disconnect + ); + } +#endif + bad_request(response, request, error->c_str()); + return; + } + auto session = webrtc_stream::create_session(options); + if (!session) { + webrtc_stream::shutdown_all_sessions(); + service_unavailable(response, "Shutdown in progress"); + return; + } + BOOST_LOG(debug) << "WebRTC: session created id=" << session->id; + nlohmann::json output; + output["status"] = true; + output["session"] = webrtc_session_to_json(*session); + output["cert_fingerprint"] = webrtc_stream::get_server_cert_fingerprint(); + output["cert_pem"] = webrtc_stream::get_server_cert_pem(); + output["ice_servers"] = load_webrtc_ice_servers(); + send_response(response, output); + } + + void getWebRTCSession(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + auto session = webrtc_stream::get_session(session_id); + if (!session) { + bad_request(response, request, "Session not found"); + return; + } + + nlohmann::json output; + output["session"] = webrtc_session_to_json(*session); + send_response(response, output); + } + + void deleteWebRTCSession(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + nlohmann::json output; + if (webrtc_stream::close_session(session_id)) { + output["status"] = true; + } else { + output["error"] = "Session not found"; + } + send_response(response, output); + } + + void postWebRTCOffer(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + if (!check_content_type(response, request, "application/json")) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input = nlohmann::json::parse(ss.str()); + auto sdp = input.at("sdp").get(); + auto type = input.value("type", "offer"); + nlohmann::json output; + if (!webrtc_stream::set_remote_offer(session_id, sdp, type)) { + if (!webrtc_stream::get_session(session_id)) { + output["error"] = "Session not found"; + } else { + output["error"] = "Failed to process offer"; + } + send_response(response, output); + return; + } + + std::string answer_sdp; + std::string answer_type; + if (webrtc_stream::wait_for_local_answer(session_id, answer_sdp, answer_type, std::chrono::seconds {3})) { + output["status"] = true; + output["answer_ready"] = true; + output["sdp"] = answer_sdp; + output["type"] = answer_type; + } else { + output["status"] = true; + output["answer_ready"] = false; + output["sdp"] = nullptr; + output["type"] = nullptr; + } + send_response(response, output); + } catch (const std::exception &e) { + bad_request(response, request, e.what()); + } + } + + void getWebRTCAnswer(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + std::string answer_sdp; + std::string answer_type; + nlohmann::json output; + if (webrtc_stream::get_local_answer(session_id, answer_sdp, answer_type)) { + output["status"] = true; + output["answer_ready"] = true; + output["sdp"] = answer_sdp; + output["type"] = answer_type; + } else { + output["status"] = false; + output["error"] = "Answer not ready"; + } + send_response(response, output); + } + + void postWebRTCIce(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + if (!check_content_type(response, request, "application/json")) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input = nlohmann::json::parse(ss.str()); + nlohmann::json output; + constexpr std::size_t kMaxCandidatesPerRequest = 256; + std::vector candidates; + if (input.is_array()) { + candidates.reserve(std::min(input.size(), kMaxCandidatesPerRequest)); + for (const auto &entry : input) { + if (candidates.size() >= kMaxCandidatesPerRequest) { + break; + } + candidates.push_back(entry); + } + } else if (input.contains("candidates") && input["candidates"].is_array()) { + const auto &arr = input["candidates"]; + candidates.reserve(std::min(arr.size(), kMaxCandidatesPerRequest)); + for (const auto &entry : arr) { + if (candidates.size() >= kMaxCandidatesPerRequest) { + break; + } + candidates.push_back(entry); + } + } else { + candidates.push_back(input); + } + + bool ok = true; + for (const auto &entry : candidates) { + if (!entry.is_object()) { + continue; + } + auto mid = entry.value("sdpMid", ""); + auto mline_index = entry.value("sdpMLineIndex", -1); + auto candidate = entry.value("candidate", ""); + if (candidate.empty()) { + continue; + } + if (!webrtc_stream::add_ice_candidate(session_id, std::move(mid), mline_index, std::move(candidate))) { + ok = false; + break; + } + } + if (ok) { + output["status"] = true; + } else { + output["error"] = "Session not found"; + } + send_response(response, output); + } catch (const std::exception &e) { + bad_request(response, request, e.what()); + } + } + + void getWebRTCIce(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + std::size_t since = 0; + auto query = request->parse_query_string(); + auto since_it = query.find("since"); + if (since_it != query.end()) { + try { + since = static_cast(std::stoull(since_it->second)); + } catch (...) { + bad_request(response, request, "Invalid since parameter"); + return; + } + } + + auto candidates = webrtc_stream::get_local_candidates(session_id, since); + nlohmann::json output; + output["status"] = true; + output["candidates"] = nlohmann::json::array(); + std::size_t last_index = since; + for (const auto &candidate : candidates) { + nlohmann::json item; + item["sdpMid"] = candidate.mid; + item["sdpMLineIndex"] = candidate.mline_index; + item["candidate"] = candidate.candidate; + item["index"] = candidate.index; + output["candidates"].push_back(std::move(item)); + last_index = std::max(last_index, candidate.index); + } + output["next_since"] = last_index; + send_response(response, output); + } + + void getWebRTCIceStream(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + std::string session_id; + if (request->path_match.size() > 1) { + session_id = request->path_match[1]; + } + + if (!webrtc_stream::get_session(session_id)) { + bad_request(response, request, "Session not found"); + return; + } + + std::size_t since = 0; + auto query = request->parse_query_string(); + auto since_it = query.find("since"); + if (since_it != query.end()) { + try { + since = static_cast(std::stoull(since_it->second)); + } catch (...) { + bad_request(response, request, "Invalid since parameter"); + return; + } + } + + std::thread([response, session_id, since]() mutable { + response->close_connection_after_response = true; + + response->write({{"Content-Type", "text/event-stream"}, {"Cache-Control", "no-cache"}, {"Connection", "keep-alive"}, {"Access-Control-Allow-Origin", get_cors_origin()}}); + + std::promise header_error; + response->send([&header_error](const SimpleWeb::error_code &ec) { + header_error.set_value(static_cast(ec)); + }); + if (header_error.get_future().get()) { + return; + } + + auto last_index = since; + auto last_keepalive = std::chrono::steady_clock::now(); + + while (true) { + auto candidates = webrtc_stream::get_local_candidates(session_id, last_index); + for (const auto &candidate : candidates) { + nlohmann::json payload; + payload["sdpMid"] = candidate.mid; + payload["sdpMLineIndex"] = candidate.mline_index; + payload["candidate"] = candidate.candidate; + + *response << "event: candidate\n"; + *response << "id: " << candidate.index << "\n"; + *response << "data: " << payload.dump() << "\n\n"; + + std::promise error; + response->send([&error](const SimpleWeb::error_code &ec) { + error.set_value(static_cast(ec)); + }); + if (error.get_future().get()) { + return; + } + + last_index = std::max(last_index, candidate.index); + } + + auto now = std::chrono::steady_clock::now(); + if (now - last_keepalive > std::chrono::seconds(2)) { + *response << "event: keepalive\n"; + *response << "data: {}\n\n"; + std::promise error; + response->send([&error](const SimpleWeb::error_code &ec) { + error.set_value(static_cast(ec)); + }); + if (error.get_future().get()) { + return; + } + last_keepalive = now; + } + + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + }).detach(); + } + + void getWebRTCCert(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + nlohmann::json output; + output["cert_fingerprint"] = webrtc_stream::get_server_cert_fingerprint(); + output["cert_pem"] = webrtc_stream::get_server_cert_pem(); + send_response(response, output); + } + + /** + * @brief Upload a cover image. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/covers/upload| POST| {"key":"igdb_1234","url":"https://images.igdb.com/igdb/image/upload/t_cover_big_2x/abc123.png"}} + */ + void uploadCover(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + std::stringstream ss; + + ss << request->content.rdbuf(); + try { + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + std::string key = input_tree.value("key", ""); + if (key.empty()) { + bad_request(response, request, "Cover key is required"); + return; + } + std::string url = input_tree.value("url", ""); + const std::string coverdir = platf::appdata().string() + "/covers/"; + file_handler::make_directory(coverdir); + + // Final destination PNG path + const std::string dest_png = coverdir + http::url_escape(key) + ".png"; + + // Helper to check PNG magic header + auto file_is_png = [](const std::string &p) -> bool { + std::ifstream f(p, std::ios::binary); + + if (!f) { + return false; + } + unsigned char sig[8] {}; + f.read(reinterpret_cast(sig), 8); + static const unsigned char pngsig[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + + return f.gcount() == 8 && std::equal(std::begin(sig), std::end(sig), std::begin(pngsig)); + }; + + // Build a temp source path (extension based on URL if available) + auto ext_from_url = [](std::string u) -> std::string { + auto qpos = u.find_first_of("?#"); + + if (qpos != std::string::npos) { + u = u.substr(0, qpos); + } + auto slash = u.find_last_of('/'); + if (slash != std::string::npos) { + u = u.substr(slash + 1); + } + auto dot = u.find_last_of('.'); + if (dot == std::string::npos) { + return std::string {".img"}; + } + std::string e = u.substr(dot); + // sanitize extension + if (e.size() > 8) { + return std::string {".img"}; + } + for (char &c : e) { + c = static_cast(std::tolower(static_cast(c))); + } + + return e; + }; + + std::string src_tmp; + if (!url.empty()) { + if (http::url_get_host(url) != "images.igdb.com") { + bad_request(response, request, "Only images.igdb.com is allowed"); + return; + } + const std::string ext = ext_from_url(url); + src_tmp = coverdir + http::url_escape(key) + "_src" + ext; + if (!http::download_file(url, src_tmp)) { + bad_request(response, request, "Failed to download cover"); + return; + } + } + + bool converted = false; +#ifdef _WIN32 + { + // Convert using WIC helper; falls back to copying if already PNG + std::wstring src_w(src_tmp.begin(), src_tmp.end()); + std::wstring dst_w(dest_png.begin(), dest_png.end()); + converted = platf::img::convert_to_png_96dpi(src_w, dst_w); + if (!converted && file_is_png(src_tmp)) { + std::error_code ec {}; + std::filesystem::copy_file(src_tmp, dest_png, std::filesystem::copy_options::overwrite_existing, ec); + converted = !ec.operator bool(); + } + } +#else + // Non-Windows: we can’t transcode here; accept only already-PNG data + if (file_is_png(src_tmp)) { + std::error_code ec {}; + + std::filesystem::rename(src_tmp, dest_png, ec); + if (ec) { + // If rename fails (cross-device), try copy + std::filesystem::copy_file(src_tmp, dest_png, std::filesystem::copy_options::overwrite_existing, ec); + if (!ec) { + std::filesystem::remove(src_tmp); + converted = true; + } + } else { + converted = true; + } + } else { + // Leave a clear error on non-Windows when not PNG + bad_request(response, request, "Cover must be PNG on this platform"); + return; + } +#endif + + // Cleanup temp source file when possible + if (!src_tmp.empty()) { + std::error_code del_ec {}; + + std::filesystem::remove(src_tmp, del_ec); + } + + if (!converted) { + bad_request(response, request, "Failed to convert cover to PNG"); + return; + } + + output_tree["status"] = true; + output_tree["path"] = dest_png; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "UploadCover: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Purge all auto-synced Playnite applications (playnite-managed == "auto"). + * @api_examples{/api/apps/purge_autosync| POST| null} + */ + void purgeAutoSyncedApps(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + try { + nlohmann::json output_tree; + nlohmann::json new_apps = nlohmann::json::array(); + std::string file = file_handler::read_file(config::stream.file_apps.c_str()); + nlohmann::json file_tree = nlohmann::json::parse(file); + auto &apps_node = file_tree["apps"]; + + int removed = 0; + for (auto &app : apps_node) { + std::string managed = app.contains("playnite-managed") && app["playnite-managed"].is_string() ? app["playnite-managed"].get() : std::string(); + if (managed == "auto") { + ++removed; + continue; + } + new_apps.push_back(app); + } + + file_tree["apps"] = new_apps; + confighttp::refresh_client_apps_cache(file_tree); + + output_tree["status"] = true; + output_tree["removed"] = removed; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "purgeAutoSyncedApps: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Get the logs from the log file. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/logs| GET| null} + */ + void getLogs(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + auto read_sunshine_log = [](std::string &out) { + auto log_path = logging::current_log_file(); + if (!log_path.empty()) { + const std::string log_path_str = log_path.string(); + out = file_handler::read_file(log_path_str.c_str()); + } + }; + + std::string content; + std::string source = "sunshine"; + const auto query = request->parse_query_string(); + if (const auto it = query.find("source"); it != query.end() && !it->second.empty()) { + source = it->second; + boost::algorithm::to_lower(source); + } + + bool handled = false; + if (source == "sunshine") { + read_sunshine_log(content); + handled = true; + } +#ifdef _WIN32 + else if (is_helper_log_source(source)) { + handled = true; + read_helper_log(source, content); + } +#endif + if (!handled) { + read_sunshine_log(content); + } + SimpleWeb::CaseInsensitiveMultimap headers; + std::string contentType = "text/plain"; +#ifdef _WIN32 + contentType += "; charset="; + contentType += currentCodePageToCharset(); +#endif + headers.emplace("Content-Type", contentType); + headers.emplace("X-Frame-Options", "DENY"); + headers.emplace("Content-Security-Policy", "frame-ancestors 'none';"); + response->write(success_ok, content, headers); + } + +#ifdef _WIN32 +#endif + + /** + * @brief Update existing credentials. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The body for the POST request should be JSON serialized in the following format: + * @code{.json} + * { + * "currentUsername": "Current Username", + * "currentPassword": "Current Password", + * "newUsername": "New Username", + * "newPassword": "New Password", + * "confirmNewPassword": "Confirm New Password" + * } + * @endcode + * + * @api_examples{/api/password| POST| {"currentUsername":"admin","currentPassword":"admin","newUsername":"admin","newPassword":"admin","confirmNewPassword":"admin"}} + */ + void savePassword(resp_https_t response, req_https_t request) { + if ((!config::sunshine.username.empty() && !authenticate(response, request)) || !validateContentType(response, request, "application/json")) { + return; + } + print_req(request); + std::vector errors; + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + std::string username = input_tree.value("currentUsername", ""); + std::string newUsername = input_tree.value("newUsername", ""); + std::string password = input_tree.value("currentPassword", ""); + std::string newPassword = input_tree.value("newPassword", ""); + std::string confirmPassword = input_tree.value("confirmNewPassword", ""); + if (newUsername.empty()) { + newUsername = username; + } + if (newUsername.empty()) { + errors.push_back("Invalid Username"); + } else { + auto hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); + if (config::sunshine.username.empty() || + (boost::iequals(username, config::sunshine.username) && hash == config::sunshine.password)) { + if (newPassword.empty() || newPassword != confirmPassword) { + errors.push_back("Password Mismatch"); + } else { + if (http::save_user_creds(config::sunshine.credentials_file, newUsername, newPassword)) { + service_unavailable(response, "Unable to write credentials file"); + return; + } + if (http::reload_user_creds(config::sunshine.credentials_file)) { + service_unavailable(response, "Unable to reload credentials file"); + return; + } + sessionCookie.clear(); // force re-login + output_tree["status"] = true; + } + } else { + errors.push_back("Invalid Current Credentials"); + } + } + if (!errors.empty()) { + std::string error = std::accumulate(errors.begin(), errors.end(), std::string(), [](const std::string &a, const std::string &b) { + return a.empty() ? b : a + ", " + b; + }); + bad_request(response, request, error); + return; + } + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "SavePassword: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Get a one-time password (OTP). + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/otp| GET| null} + */ + void getOTP(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + try { + std::stringstream ss; + ss << request->content.rdbuf(); + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + + std::string passphrase = input_tree.value("passphrase", ""); + if (passphrase.empty()) { + throw std::runtime_error("Passphrase not provided!"); + } + if (passphrase.size() < 4) { + throw std::runtime_error("Passphrase too short!"); + } + + std::string deviceName = input_tree.value("deviceName", ""); + output_tree["otp"] = nvhttp::request_otp(passphrase, deviceName); + output_tree["ip"] = platf::get_local_ip_for_gateway(); + output_tree["name"] = config::nvhttp.sunshine_name; + output_tree["status"] = true; + output_tree["message"] = "OTP created, effective within 3 minutes."; + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "OTP creation failed: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Send a PIN code to the host. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The body for the POST request should be JSON serialized in the following format: + * @code{.json} + * { + * "pin": "", + * "name": "Friendly Client Name" + * } + * @endcode + * + * @api_examples{/api/pin| POST| {"pin":"1234","name":"My PC"}} + */ + void savePin(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + try { + std::stringstream ss; + ss << request->content.rdbuf(); + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + nlohmann::json output_tree; + std::string pin = input_tree.value("pin", ""); + std::string name = input_tree.value("name", ""); + output_tree["status"] = nvhttp::pin(pin, name); + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "SavePin: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Reset the display device persistence. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/reset-display-device-persistence| POST| null} + */ + void resetDisplayDevicePersistence(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + nlohmann::json output_tree; + output_tree["status"] = display_helper_integration::reset_persistence(); + send_response(response, output_tree); + } + +#ifdef _WIN32 + /** + * @brief Export the current Windows display settings as a golden restore snapshot. + * @api_examples{/api/display/export_golden| POST| {"status":true}} + */ + void postExportGoldenDisplay(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json")) { + return; + } + if (!authenticate(response, request)) { + return; + } + print_req(request); + nlohmann::json out; + try { + const bool ok = display_helper_integration::export_golden_restore(); + out["status"] = ok; + } catch (...) { + out["status"] = false; + } + send_response(response, out); + } +#endif + +#ifdef _WIN32 + // --- Golden snapshot helpers (Windows-only) --- + static bool file_exists_nofail(const std::filesystem::path &p) { + try { + std::error_code ec; + return std::filesystem::exists(p, ec); + } catch (...) { + return false; + } + } + + // Return candidate paths where the helper writes the golden snapshot. + // We probe both the active user's Roaming/Local AppData and the current + // process's CSIDL paths, mirroring the log bundle collection logic. + static std::vector golden_snapshot_candidates() { + std::vector out; + auto add_if = [&](const std::filesystem::path &base) { + if (!base.empty()) { + out.emplace_back(base / L"Sunshine" / L"display_golden_restore.json"); + } + }; + + try { + // Prefer the active user's known folders (impersonated) when available + try { + platf::dxgi::safe_token user_token; + user_token.reset(platf::dxgi::retrieve_users_token(false)); + auto add_known = [&](REFKNOWNFOLDERID id) { + PWSTR baseW = nullptr; + if (SUCCEEDED(SHGetKnownFolderPath(id, 0, user_token.get(), &baseW)) && baseW) { + add_if(std::filesystem::path(baseW)); + CoTaskMemFree(baseW); + } + }; + add_known(FOLDERID_RoamingAppData); + add_known(FOLDERID_LocalAppData); + } catch (...) { + // ignore + } + + // Also probe the current process's CSIDL APPDATA and LOCAL_APPDATA + auto add_csidl = [&](int csidl) { + wchar_t baseW[MAX_PATH] = {}; + if (SUCCEEDED(SHGetFolderPathW(nullptr, csidl, nullptr, SHGFP_TYPE_CURRENT, baseW))) { + add_if(std::filesystem::path(baseW)); + } + }; + add_csidl(CSIDL_APPDATA); + add_csidl(CSIDL_LOCAL_APPDATA); + add_csidl(CSIDL_COMMON_APPDATA); + } catch (...) { + // best-effort + } + return out; + } + + constexpr int kGoldenSnapshotLatestVersion = 2; + + struct golden_current_mode_t { + unsigned int width {}; + unsigned int height {}; + double refresh_hz {}; + }; + + struct golden_current_summary_t { + bool valid {false}; + bool active_virtual_display {false}; + std::set devices; + std::unordered_map modes; + std::unordered_map hdr; + std::unordered_map> origins; + std::string primary; + }; + + static std::string normalized_display_id(std::string id) { + id.erase(id.begin(), std::find_if(id.begin(), id.end(), [](unsigned char ch) { + return !std::isspace(ch); + })); + id.erase(std::find_if(id.rbegin(), id.rend(), [](unsigned char ch) { + return !std::isspace(ch); + }).base(), + id.end()); + std::transform(id.begin(), id.end(), id.begin(), [](unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return id; + } + + static bool contains_ci(const std::string &haystack, const std::string &needle) { + if (needle.empty()) { + return true; + } + if (haystack.size() < needle.size()) { + return false; + } + for (size_t i = 0; i + needle.size() <= haystack.size(); ++i) { + bool match = true; + for (size_t j = 0; j < needle.size(); ++j) { + if (std::tolower(static_cast(haystack[i + j])) != + std::tolower(static_cast(needle[j]))) { + match = false; + break; + } + } + if (match) { + return true; + } + } + return false; + } + + static bool equals_ci(const std::string &lhs, const std::string &rhs) { + return lhs.size() == rhs.size() && contains_ci(lhs, rhs); + } + + static bool is_virtual_display_device(const display_device::EnumeratedDevice &device) { + if (contains_ci(device.m_device_id, "SUDOVDA") || + contains_ci(device.m_device_id, "SUDOMAKER") || + contains_ci(device.m_display_name, "SUDOVDA") || + contains_ci(device.m_display_name, "SUDOMAKER") || + contains_ci(device.m_friendly_name, "SUDOVDA") || + contains_ci(device.m_friendly_name, "SUDOMAKER")) { + return true; + } + if (equals_ci(device.m_friendly_name, "SudoMaker Virtual Display Adapter")) { + return true; + } + return device.m_edid && equals_ci(device.m_edid->m_manufacturer_id, "SMK"); + } + + static bool is_active_display_device(const display_device::EnumeratedDevice &device) { + return device.m_info.has_value() || !device.m_display_name.empty(); + } + + static std::optional floating_to_double(const display_device::FloatingPoint &value) { + if (std::holds_alternative(value)) { + return std::get(value); + } + const auto &rat = std::get(value); + if (rat.m_denominator == 0) { + return std::nullopt; + } + return static_cast(rat.m_numerator) / static_cast(rat.m_denominator); + } + + static bool nearly_equal_refresh(double lhs, double rhs) { + if (!std::isfinite(lhs) || !std::isfinite(rhs)) { + return false; + } + const double diff = std::abs(lhs - rhs); + const double scale = std::max({1.0, std::abs(lhs), std::abs(rhs)}); + return diff <= scale * 1e-4; + } + + static std::optional read_json_file_nofail(const std::filesystem::path &path) { + try { + std::ifstream file(path, std::ios::binary); + if (!file.is_open()) { + return std::nullopt; + } + auto parsed = nlohmann::json::parse(file, nullptr, false); + if (parsed.is_discarded() || !parsed.is_object()) { + return std::nullopt; + } + return parsed; + } catch (...) { + return std::nullopt; + } + } + + static std::optional parse_snapshot_version(const nlohmann::json &root) { + auto it = root.find("snapshot_version"); + if (it == root.end() || !it->is_number_integer()) { + return std::nullopt; + } + int version = it->get(); + if (version < 1) { + return std::nullopt; + } + return version; + } + + static bool snapshot_has_layout_data(const nlohmann::json &root) { + auto it = root.find("layouts"); + if (it == root.end() || !it->is_object()) { + return false; + } + for (auto entry = it->begin(); entry != it->end(); ++entry) { + if (!entry.key().empty()) { + if (entry->is_number_integer()) { + return true; + } + if (entry->is_object()) { + auto rotation = entry->find("rotation"); + if (rotation != entry->end() && (rotation->is_number_integer() || rotation->is_string())) { + return true; + } + } + } + } + return false; + } + + static std::set snapshot_topology_devices(const nlohmann::json &root) { + std::set ids; + auto topology = root.find("topology"); + if (topology != root.end() && topology->is_array()) { + for (const auto &group : *topology) { + if (!group.is_array()) { + continue; + } + for (const auto &device : group) { + if (device.is_string()) { + auto id = normalized_display_id(device.get()); + if (!id.empty()) { + ids.insert(std::move(id)); + } + } + } + } + } + if (ids.empty()) { + auto modes = root.find("modes"); + if (modes != root.end() && modes->is_object()) { + for (auto it = modes->begin(); it != modes->end(); ++it) { + auto id = normalized_display_id(it.key()); + if (!id.empty()) { + ids.insert(std::move(id)); + } + } + } + } + return ids; + } + + static std::unordered_map snapshot_modes(const nlohmann::json &root) { + std::unordered_map modes; + auto modes_it = root.find("modes"); + if (modes_it == root.end() || !modes_it->is_object()) { + return modes; + } + for (auto it = modes_it->begin(); it != modes_it->end(); ++it) { + if (!it->is_object()) { + continue; + } + auto id = normalized_display_id(it.key()); + const auto width = it->value("w", 0u); + const auto height = it->value("h", 0u); + const auto num = it->value("num", 0u); + const auto den = it->value("den", 0u); + if (id.empty() || width == 0 || height == 0 || den == 0) { + continue; + } + modes.emplace(std::move(id), golden_current_mode_t { + .width = width, + .height = height, + .refresh_hz = static_cast(num) / static_cast(den), + }); + } + return modes; + } + + static std::unordered_map snapshot_hdr_states(const nlohmann::json &root) { + std::unordered_map states; + auto hdr_it = root.find("hdr"); + if (hdr_it == root.end() || !hdr_it->is_object()) { + return states; + } + for (auto it = hdr_it->begin(); it != hdr_it->end(); ++it) { + if (!it->is_string()) { + continue; + } + auto id = normalized_display_id(it.key()); + auto value = boost::algorithm::to_lower_copy(it->get()); + if (id.empty() || (value != "on" && value != "off")) { + continue; + } + states.emplace(std::move(id), value == "on"); + } + return states; + } + + static std::unordered_map> snapshot_origins(const nlohmann::json &root) { + std::unordered_map> origins; + auto origins_it = root.find("origins"); + if (origins_it == root.end() || !origins_it->is_object()) { + return origins; + } + for (auto it = origins_it->begin(); it != origins_it->end(); ++it) { + if (!it->is_object()) { + continue; + } + auto id = normalized_display_id(it.key()); + if (id.empty()) { + continue; + } + origins.emplace(std::move(id), std::make_pair(it->value("x", 0), it->value("y", 0))); + } + return origins; + } + + static golden_current_summary_t current_golden_comparison_summary() { + golden_current_summary_t summary; + const auto devices = display_helper_integration::enumerate_devices(display_device::DeviceEnumerationDetail::Full); + if (!devices) { + return summary; + } + + std::set exclusions; + for (auto id : config::video.dd.snapshot_exclude_devices) { + id = normalized_display_id(std::move(id)); + if (!id.empty()) { + exclusions.insert(std::move(id)); + } + } + + for (const auto &device : *devices) { + if (is_virtual_display_device(device)) { + if (is_active_display_device(device)) { + summary.active_virtual_display = true; + } + continue; + } + if (!device.m_info || device.m_display_name.empty()) { + continue; + } + + auto id = normalized_display_id(device.m_device_id.empty() ? device.m_display_name : device.m_device_id); + if (id.empty() || exclusions.contains(id)) { + continue; + } + + summary.devices.insert(id); + if (auto refresh = floating_to_double(device.m_info->m_refresh_rate)) { + summary.modes[id] = golden_current_mode_t { + .width = device.m_info->m_resolution.m_width, + .height = device.m_info->m_resolution.m_height, + .refresh_hz = *refresh, + }; + } + if (device.m_info->m_hdr_state) { + summary.hdr[id] = *device.m_info->m_hdr_state == display_device::HdrState::Enabled; + } + summary.origins[id] = std::make_pair(device.m_info->m_origin_point.m_x, device.m_info->m_origin_point.m_y); + if (device.m_info->m_primary) { + summary.primary = id; + } + } + + summary.valid = !summary.devices.empty(); + return summary; + } + + static std::optional snapshot_current_mismatch_reason(const nlohmann::json &root) { + const auto current = current_golden_comparison_summary(); + if (current.active_virtual_display) { + return std::nullopt; + } + if (!current.valid) { + return std::nullopt; + } + + const auto snapshot_devices = snapshot_topology_devices(root); + if (snapshot_devices.empty()) { + return "invalid_snapshot"; + } + if (snapshot_devices != current.devices) { + return "display_set_changed"; + } + + const auto modes = snapshot_modes(root); + for (const auto &[id, mode] : modes) { + auto current_mode = current.modes.find(id); + if (current_mode == current.modes.end()) { + continue; + } + if (mode.width != current_mode->second.width || + mode.height != current_mode->second.height || + !nearly_equal_refresh(mode.refresh_hz, current_mode->second.refresh_hz)) { + return "display_mode_changed"; + } + } + + const auto hdr_states = snapshot_hdr_states(root); + for (const auto &[id, hdr] : hdr_states) { + auto current_hdr = current.hdr.find(id); + if (current_hdr != current.hdr.end() && hdr != current_hdr->second) { + return "hdr_changed"; + } + } + + auto primary_it = root.find("primary"); + if (primary_it != root.end() && primary_it->is_string()) { + const auto primary = normalized_display_id(primary_it->get()); + if (!primary.empty() && !current.primary.empty() && primary != current.primary) { + return "primary_changed"; + } + } + + const auto origins = snapshot_origins(root); + for (const auto &[id, origin] : origins) { + auto current_origin = current.origins.find(id); + if (current_origin != current.origins.end() && origin != current_origin->second) { + return "layout_changed"; + } + } + + return ""; + } + + void getGoldenStatus(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + nlohmann::json out; + bool exists = false; + std::optional snapshot_version; + bool has_layout = false; + bool needs_layout_upgrade = false; + bool out_of_date = false; + bool comparison_available = false; + std::string out_of_date_reason; + try { + for (const auto &p : golden_snapshot_candidates()) { + if (file_exists_nofail(p)) { + exists = true; + if (auto root = read_json_file_nofail(p)) { + snapshot_version = parse_snapshot_version(*root); + has_layout = snapshot_has_layout_data(*root); + const bool latest_schema = snapshot_version && *snapshot_version >= kGoldenSnapshotLatestVersion; + needs_layout_upgrade = !latest_schema || !has_layout; + out_of_date = needs_layout_upgrade; + if (needs_layout_upgrade) { + out_of_date_reason = "schema_upgrade_required"; + } + if (!has_active_stream_sessions()) { + if (auto mismatch = snapshot_current_mismatch_reason(*root)) { + comparison_available = true; + if (!mismatch->empty()) { + out_of_date = true; + out_of_date_reason = *mismatch; + } + } + } + } else { + needs_layout_upgrade = true; + out_of_date = true; + out_of_date_reason = "unreadable_snapshot"; + } + break; + } + } + } catch (...) { + } + out["exists"] = exists; + out["snapshot_version"] = snapshot_version ? nlohmann::json(*snapshot_version) : nlohmann::json(nullptr); + out["latest_snapshot_version"] = kGoldenSnapshotLatestVersion; + out["has_layout"] = has_layout; + out["needs_layout_upgrade"] = needs_layout_upgrade; + out["out_of_date"] = out_of_date; + out["comparison_available"] = comparison_available; + out["out_of_date_reason"] = out_of_date_reason; + send_response(response, out); + } + + void deleteGolden(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + nlohmann::json out; + bool any_deleted = false; + try { + for (const auto &p : golden_snapshot_candidates()) { + if (file_exists_nofail(p)) { + std::error_code ec; + std::filesystem::remove(p, ec); + if (!ec) { + any_deleted = true; + } + } + } + } catch (...) { + } + out["deleted"] = any_deleted; + send_response(response, out); + } +#endif + + /** + * @brief Restart Apollo. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/restart| POST| null} + */ + void restart(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + proc::proc.terminate(); + + // We may not return from this call + platf::restart(); + } + + /** + * @brief Quit Apollo. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * On Windows, if running in a service, a special shutdown code is returned. + */ + void quit(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + BOOST_LOG(warning) << "Requested quit from config page!"sv; + + proc::proc.terminate(); + +#ifdef _WIN32 + if (GetConsoleWindow() == NULL) { + lifetime::exit_sunshine(ERROR_SHUTDOWN_IN_PROGRESS, true); + } else +#endif + { + lifetime::exit_sunshine(0, true); + } + // If exit fails, write a response after 5 seconds. + std::thread write_resp([response] { + std::this_thread::sleep_for(5s); + response->write(); + }); + write_resp.detach(); + } + + /** + * @brief Generate a new API token with specified scopes. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/token| POST| {"scopes":[{"path":"/api/apps","methods":["GET"]}]}}} + * + * Request body example: + * { + * "scopes": [ + * { "path": "/api/apps", "methods": ["GET", "POST"] } + * ] + * } + * + * Response example: + * { "token": "..." } + */ + void generateApiToken(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + std::stringstream ss; + ss << request->content.rdbuf(); + const std::string request_body = ss.str(); + auto token_opt = api_token_manager.generate_api_token(request_body, config::sunshine.username); + nlohmann::json output_tree; + if (!token_opt) { + output_tree["error"] = "Invalid token request"; + send_response(response, output_tree); + return; + } + output_tree["token"] = *token_opt; + send_response(response, output_tree); + } + + /** + * @brief List all active API tokens and their scopes. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/tokens| GET| null} + * + * Response example: + * [ + * { + * "hash": "...", + * "username": "admin", + * "created_at": 1719000000, + * "scopes": [ + * { "path": "/api/apps", "methods": ["GET"] } + * ] + * } + * ] + */ + void listApiTokens(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + nlohmann::json output_tree = nlohmann::json::parse(api_token_manager.list_api_tokens_json()); + send_response(response, output_tree); + } + + /** + * @brief List all token-eligible API routes and methods. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void listApiTokenRoutes(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + const auto catalog = snapshot_token_route_catalog(); + + nlohmann::json output_tree; + output_tree["status"] = true; + output_tree["routes"] = nlohmann::json::array(); + + for (const auto &[path, methods] : catalog) { + output_tree["routes"].push_back({{"path", path}, {"methods", ordered_methods_for_catalog(methods)}}); + } + + send_response(response, output_tree); + } + + /** + * @brief Revoke (delete) an API token by its hash. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/token/abcdef1234567890| DELETE| null} + * + * Response example: + * { "status": true } + */ + void revokeApiToken(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + std::string hash; + if (request->path_match.size() > 1) { + hash = request->path_match[1]; + } + bool result = api_token_manager.revoke_api_token_by_hash(hash); + nlohmann::json output_tree; + if (result) { + output_tree["status"] = true; + } else { + output_tree["error"] = "Internal server error"; + } + send_response(response, output_tree); + } + + void listSessions(resp_https_t response, req_https_t request); + void revokeSession(resp_https_t response, req_https_t request); + + /** + * @brief Launch an application. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void launchApp(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + try { + std::stringstream ss; + ss << request->content.rdbuf(); + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + + // Check for required uuid field in body + if (!input_tree.contains("uuid") || !input_tree["uuid"].is_string()) { + bad_request(response, request, "Missing or invalid uuid in request body"); + return; + } + std::string uuid = input_tree["uuid"].get(); + + nlohmann::json output_tree; + const auto &apps = proc::proc.get_apps(); + for (auto &app : apps) { + if (app.uuid == uuid) { + crypto::named_cert_t named_cert { + .name = "", + .uuid = http::unique_id, + .perm = crypto::PERM::_all, + }; + BOOST_LOG(info) << "Launching app ["sv << app.name << "] from web UI"sv; + auto launch_session = nvhttp::make_launch_session(true, false, request->parse_query_string(), &named_cert); + auto err = proc::proc.execute(app, launch_session); + if (err) { + bad_request(response, request, err == 503 ? "Failed to initialize video capture/encoding. Is a display connected and turned on?" : "Failed to start the specified application"); + } else { + output_tree["status"] = true; + send_response(response, output_tree); + } + return; + } + } + BOOST_LOG(error) << "Couldn't find app with uuid ["sv << uuid << ']'; + bad_request(response, request, "Cannot find requested application"); + } catch (std::exception &e) { + BOOST_LOG(warning) << "LaunchApp: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Disconnect a client. + * @param response The HTTP response object. + * @param request The HTTP request object. + */ + void disconnect(resp_https_t response, req_https_t request) { + if (!validateContentType(response, request, "application/json") || !authenticate(response, request)) { + return; + } + + print_req(request); + + try { + std::stringstream ss; + ss << request->content.rdbuf(); + nlohmann::json output_tree; + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + std::string uuid = input_tree.value("uuid", ""); + output_tree["status"] = nvhttp::find_and_stop_session(uuid, true); + send_response(response, output_tree); + } catch (std::exception &e) { + BOOST_LOG(warning) << "Disconnect: "sv << e.what(); + bad_request(response, request, e.what()); + } + } + + /** + * @brief Login the user. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The body for the POST request should be JSON serialized in the following format: + * @code{.json} + * { + * "username": "", + * "password": "" + * } + * @endcode + */ + void login(resp_https_t response, req_https_t request) { + if (!checkIPOrigin(response, request) || !validateContentType(response, request, "application/json")) { + return; + } + + auto fg = util::fail_guard([&] { + response->write(SimpleWeb::StatusCode::client_error_unauthorized); + }); + + try { + std::stringstream ss; + ss << request->content.rdbuf(); + nlohmann::json input_tree = nlohmann::json::parse(ss.str()); + std::string username = input_tree.value("username", ""); + std::string password = input_tree.value("password", ""); + std::string hash = util::hex(crypto::hash(password + config::sunshine.salt)).to_string(); + if (!boost::iequals(username, config::sunshine.username) || hash != config::sunshine.password) { + return; + } + std::string sessionCookieRaw = crypto::rand_alphabet(64); + sessionCookie = util::hex(crypto::hash(sessionCookieRaw + config::sunshine.salt)).to_string(); + cookie_creation_time = std::chrono::steady_clock::now(); + const SimpleWeb::CaseInsensitiveMultimap headers { + {"Set-Cookie", "auth=" + sessionCookieRaw + "; Secure; SameSite=Strict; Max-Age=2592000; Path=/"} + }; + response->write(headers); + fg.disable(); + } catch (std::exception &e) { + BOOST_LOG(warning) << "Web UI Login failed: ["sv << net::addr_to_normalized_string(request->remote_endpoint().address()) + << "]: "sv << e.what(); + response->write(SimpleWeb::StatusCode::server_error_internal_server_error); + fg.disable(); + return; + } + } + + void start() { + auto shutdown_event = mail::man->event(mail::shutdown); + auto port_https = net::map_port(PORT_HTTPS); + auto address_family = net::af_from_enum_string(config::sunshine.address_family); + + https_server_t server(config::nvhttp.cert, config::nvhttp.pkey); + server.default_resource["DELETE"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + server.default_resource["PATCH"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + server.default_resource["POST"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + server.default_resource["PUT"] = [](resp_https_t response, req_https_t request) { + bad_request(response, request); + }; + + // Serve the SPA shell for any unmatched GET route. Explicit static and API + // routes are registered below; UI page routes are deprecated server-side + // and are handled by the SPA entry responder so frontend can manage + // authentication and routing. + server.default_resource["GET"] = getSpaEntry; + server.resource["^/$"]["GET"] = getSpaEntry; + server.resource["^/pin/?$"]["GET"] = getSpaEntry; + server.resource["^/apps/?$"]["GET"] = getSpaEntry; + server.resource["^/clients/?$"]["GET"] = getSpaEntry; + server.resource["^/config/?$"]["GET"] = getSpaEntry; + server.resource["^/password/?$"]["GET"] = getSpaEntry; + server.resource["^/welcome/?$"]["GET"] = getSpaEntry; + server.resource["^/login/?$"]["GET"] = getSpaEntry; + server.resource["^/troubleshooting/?$"]["GET"] = getSpaEntry; + clear_token_route_catalog(); + auto register_api_route = [&](const char *pattern, const char *method, const auto &handler) { + server.resource[pattern][method] = handler; + record_token_route(normalize_route_pattern(pattern), method); + }; + register_api_route("^/api/pin$", "POST", savePin); + register_api_route("^/api/otp$", "POST", getOTP); + register_api_route("^/api/apps$", "GET", getApps); + register_api_route("^/api/apps$", "POST", saveApp); + register_api_route("^/api/apps/([^/]+)/cover$", "GET", getAppCover); + register_api_route("^/api/apps/reorder$", "POST", reorderApps); + register_api_route("^/api/apps/delete$", "POST", deleteApp); + register_api_route("^/api/apps/launch$", "POST", launchApp); + register_api_route("^/api/apps/close$", "POST", closeApp); + register_api_route("^/api/logs$", "GET", getLogs); + register_api_route("^/api/config$", "GET", getConfig); + register_api_route("^/api/config$", "POST", saveConfig); + // Partial updates for config settings; merges with existing file and + // removes keys when value is null or empty string. + register_api_route("^/api/config$", "PATCH", patchConfig); + register_api_route("^/api/metadata$", "GET", getMetadata); + register_api_route("^/api/configLocale$", "GET", getLocale); + register_api_route("^/api/audio-debug$", "GET", getAudioDebug); + register_api_route("^/api/restart$", "POST", restart); + register_api_route("^/api/quit$", "POST", quit); +#if defined(_WIN32) + register_api_route("^/api/display/export_golden$", "POST", postExportGoldenDisplay); + register_api_route("^/api/display/golden_status$", "GET", getGoldenStatus); + register_api_route("^/api/display/golden$", "DELETE", deleteGolden); +#endif + register_api_route("^/api/password$", "POST", savePassword); + register_api_route("^/api/display-devices$", "GET", getDisplayDevices); +#ifdef _WIN32 + register_api_route("^/api/framegen/edid-refresh$", "GET", getFramegenEdidRefresh); + register_api_route("^/api/health/vigem$", "GET", getVigemHealth); + register_api_route("^/api/health/crashdump$", "GET", getCrashDumpStatus); + register_api_route("^/api/health/crashdump/dismiss$", "POST", postCrashDumpDismiss); +#endif + register_api_route("^/api/apps/([A-Fa-f0-9-]+)/cover$", "GET", getAppCover); + register_api_route("^/api/apps/([0-9]+)$", "DELETE", deleteApp); + register_api_route("^/api/clients/unpair-all$", "POST", unpairAll); + register_api_route("^/api/clients/list$", "GET", getClients); + register_api_route("^/api/clients/hdr-profiles$", "GET", getHdrProfiles); + register_api_route("^/api/clients/update$", "POST", updateClient); + register_api_route("^/api/clients/unpair$", "POST", unpair); + register_api_route("^/api/clients/disconnect$", "POST", disconnectClient); + register_api_route("^/api/apps/close$", "POST", closeApp); + register_api_route("^/api/session/status$", "GET", getSessionStatus); + register_api_route("^/api/host/stats$", "GET", getHostStats); + register_api_route("^/api/host/info$", "GET", getHostInfo); + register_api_route("^/api/rtsp/sessions$", "GET", listRTSPSessions); + register_api_route("^/api/webrtc/sessions$", "GET", listWebRTCSessions); + register_api_route("^/api/history/sessions$", "GET", listSessionHistory); + register_api_route("^/api/history/sessions/active$", "GET", getActiveSessionHistory); + register_api_route("^/api/history/sessions/([A-Fa-f0-9-]+)$", "GET", getSessionHistoryDetail); + register_api_route("^/api/history/sessions/([A-Fa-f0-9-]+)$", "DELETE", deleteSessionHistory); + register_api_route("^/api/webrtc/sessions$", "POST", createWebRTCSession); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)$", "GET", getWebRTCSession); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)$", "DELETE", deleteWebRTCSession); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/offer$", "POST", postWebRTCOffer); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/answer$", "GET", getWebRTCAnswer); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice$", "GET", getWebRTCIce); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice$", "POST", postWebRTCIce); + register_api_route("^/api/webrtc/sessions/([A-Fa-f0-9-]+)/ice/stream$", "GET", getWebRTCIceStream); + register_api_route("^/api/webrtc/cert$", "GET", getWebRTCCert); + // Keep legacy cover upload endpoint present in upstream master + register_api_route("^/api/covers/upload$", "POST", uploadCover); + register_api_route("^/api/apps/purge_autosync$", "POST", purgeAutoSyncedApps); +#ifdef _WIN32 + register_api_route("^/api/playnite/status$", "GET", getPlayniteStatus); + register_api_route("^/api/rtss/status$", "GET", getRtssStatus); + register_api_route("^/api/lossless_scaling/status$", "GET", getLosslessScalingStatus); + register_api_route("^/api/playnite/install$", "POST", installPlaynite); + register_api_route("^/api/playnite/uninstall$", "POST", uninstallPlaynite); + register_api_route("^/api/playnite/games$", "GET", getPlayniteGames); + register_api_route("^/api/playnite/categories$", "GET", getPlayniteCategories); + register_api_route("^/api/playnite/force_sync$", "POST", postPlayniteForceSync); + register_api_route("^/api/playnite/launch$", "POST", postPlayniteLaunch); + // Export logs bundle (Windows only) + register_api_route("^/api/logs/export$", "GET", downloadPlayniteLogs); + register_api_route("^/api/logs/export_crash/manifest$", "GET", getCrashBundleManifest); + register_api_route("^/api/logs/export_crash$", "GET", downloadCrashBundle); +#endif + server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage; + server.resource["^/images/logo-apollo-45.png$"]["GET"] = getApolloLogoImage; + server.resource["^/images/logo-sunshine-45.png$"]["GET"] = getApolloLogoImage; // legacy alias + server.resource["^/assets\\/.+$"]["GET"] = getNodeModules; + register_api_route("^/api/token$", "POST", generateApiToken); + register_api_route("^/api/tokens$", "GET", listApiTokens); + register_api_route("^/api/token/routes$", "GET", listApiTokenRoutes); + register_api_route("^/api/token/([a-fA-F0-9]+)$", "DELETE", revokeApiToken); + // Session validation endpoint used by the web UI to detect HttpOnly session cookies + server.resource["^/api-tokens/?$"]["GET"] = getTokenPage; + register_api_route("^/api/auth/login$", "POST", loginUser); + register_api_route("^/api/auth/refresh$", "POST", refreshSession); + register_api_route("^/api/auth/logout$", "POST", logoutUser); + register_api_route("^/api/auth/status$", "GET", authStatus); + register_api_route("^/api/auth/sessions$", "GET", listSessions); + register_api_route("^/api/auth/sessions/([A-Fa-f0-9]+)$", "DELETE", revokeSession); + server.config.reuse_address = true; + server.config.address = net::get_bind_address(address_family); + server.config.port = port_https; + + auto accept_and_run = [&](auto *server) { + try { + server->start([port_https](unsigned short port) { + BOOST_LOG(info) << "Configuration UI available at [https://localhost:"sv << port << "]"; + }); + } catch (boost::system::system_error &err) { + // It's possible the exception gets thrown after calling server->stop() from a different thread + if (shutdown_event->peek()) { + return; + } + BOOST_LOG(fatal) << "Couldn't start Configuration HTTPS server on port ["sv << port_https << "]: "sv << err.what(); + shutdown_event->raise(true); + return; + } + }; + api_token_manager.load_api_tokens(); + session_token_manager.load_session_tokens(); + std::thread tcp {accept_and_run, &server}; + + // Start a background task to clean up expired session tokens every hour + std::jthread cleanup_thread([shutdown_event]() { + while (!shutdown_event->view(std::chrono::hours(1))) { + if (session_token_manager.cleanup_expired_session_tokens()) { + session_token_manager.save_session_tokens(); + } + } + }); + + // Wait for any event + shutdown_event->view(); + + server.stop(); + + tcp.join(); + // std::jthread (cleanup_thread) auto-joins on destruction, no need for joinable/join + } + + /** + * @brief Handles the HTTP request to serve the API token management page. + * + * This function authenticates the incoming request and, if successful, + * reads the "api-tokens.html" file from the web directory and sends its + * contents as an HTTP response with the appropriate content type. + * + * @param response The HTTP response object used to send data back to the client. + * @param request The HTTP request object containing client request data. + */ + void getTokenPage(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + std::string content = file_handler::read_file(WEB_DIR "api-tokens.html"); + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "text/html; charset=utf-8"); + response->write(content, headers); + } + + /** + * @brief Converts a string representation of a token scope to its corresponding TokenScope enum value. + * + * This function takes a string view and returns the matching TokenScope enum value. + * Supported string values are "Read", "read", "Write", and "write". + * If the input string does not match any known scope, an std::invalid_argument exception is thrown. + * + * @param s The string view representing the token scope. + * @return TokenScope The corresponding TokenScope enum value. + * @throws std::invalid_argument If the input string does not match any known scope. + */ + TokenScope scope_from_string(std::string_view s) { + if (s == "Read" || s == "read") { + return TokenScope::Read; + } + if (s == "Write" || s == "write") { + return TokenScope::Write; + } + throw std::invalid_argument("Unknown TokenScope: " + std::string(s)); + } + + /** + * @brief Converts a TokenScope enum value to its string representation. + * @param scope The TokenScope enum value to convert. + * @return The string representation of the scope. + */ + std::string scope_to_string(TokenScope scope) { + switch (scope) { + case TokenScope::Read: + return "Read"; + case TokenScope::Write: + return "Write"; + default: + throw std::invalid_argument("Unknown TokenScope enum value"); + } + } + + /** + * @brief User login endpoint to generate session tokens. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * Expects JSON body: + * { + * "username": "string", + * "password": "string" + * } + * + * Returns: + * { + * "status": true, + * "token": "session_token_string", + * "expires_in": 86400 + * } + * + * @api_examples{/api/auth/login| POST| {"username": "admin", "password": "password"}} + */ + void loginUser(resp_https_t response, req_https_t request) { + print_req(request); + + std::stringstream ss; + ss << request->content.rdbuf(); + try { + nlohmann::json input_tree = nlohmann::json::parse(ss); + if (!input_tree.contains("username") || !input_tree.contains("password")) { + bad_request(response, request, "Missing username or password"); + return; + } + + std::string username = input_tree["username"].get(); + std::string password = input_tree["password"].get(); + std::string redirect_url = input_tree.value("redirect", "/"); + bool remember_me = false; + if (auto it = input_tree.find("remember_me"); it != input_tree.end()) { + try { + remember_me = it->get(); + } catch (const nlohmann::json::exception &) { + remember_me = false; + } + } + + std::string user_agent; + if (auto ua = request->header.find("user-agent"); ua != request->header.end()) { + user_agent = ua->second; + } + std::string remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); + + APIResponse api_response = session_token_api.login(username, password, redirect_url, remember_me, user_agent, remote_address); + write_api_response(response, api_response); + + } catch (const nlohmann::json::exception &e) { + BOOST_LOG(warning) << "Login JSON error:"sv << e.what(); + bad_request(response, request, "Invalid JSON format"); + } + } + + void refreshSession(resp_https_t response, req_https_t request) { + print_req(request); + + std::string refresh_token; + if (auto auth = request->header.find("authorization"); + auth != request->header.end() && auth->second.rfind("Refresh ", 0) == 0) { + refresh_token = auth->second.substr(8); + } + if (refresh_token.empty()) { + refresh_token = extract_refresh_token_from_cookie(request->header); + } + + // Allow JSON body input for API clients that do not rely on cookies/Authorization header + if (refresh_token.empty()) { + std::stringstream ss; + ss << request->content.rdbuf(); + if (!ss.str().empty()) { + try { + auto body = nlohmann::json::parse(ss); + if (auto it = body.find("refresh_token"); it != body.end() && it->is_string()) { + refresh_token = it->get(); + } + } catch (const nlohmann::json::exception &) { + } + } + } + + std::string user_agent; + if (auto ua = request->header.find("user-agent"); ua != request->header.end()) { + user_agent = ua->second; + } + std::string remote_address = net::addr_to_normalized_string(request->remote_endpoint().address()); + + APIResponse api_response = session_token_api.refresh_session(refresh_token, user_agent, remote_address); + write_api_response(response, api_response); + } + + /** + * @brief User logout endpoint to revoke session tokens. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * @api_examples{/api/auth/logout| POST| null} + */ + void logoutUser(resp_https_t response, req_https_t request) { + print_req(request); + + std::string session_token; + if (auto auth = request->header.find("authorization"); + auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { + session_token = auth->second.substr(8); + } + if (session_token.empty()) { + session_token = extract_session_token_from_cookie(request->header); + } + + std::string refresh_token = extract_refresh_token_from_cookie(request->header); + + APIResponse api_response = session_token_api.logout(session_token, refresh_token); + write_api_response(response, api_response); + } + + void listSessions(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + + std::string raw_token; + if (auto auth = request->header.find("authorization"); + auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { + raw_token = auth->second.substr(8); + } + if (raw_token.empty()) { + raw_token = extract_session_token_from_cookie(request->header); + } + std::string active_hash; + if (!raw_token.empty()) { + if (auto hash = session_token_manager.get_hash_for_token(raw_token)) { + active_hash = *hash; + } + } + + APIResponse api_response = session_token_api.list_sessions(config::sunshine.username, active_hash); + write_api_response(response, api_response); + } + + void revokeSession(resp_https_t response, req_https_t request) { + if (!authenticate(response, request)) { + return; + } + print_req(request); + + if (request->path_match.size() < 2) { + bad_request(response, request, "Session id required"); + return; + } + std::string session_hash = request->path_match[1].str(); + + std::string raw_token; + if (auto auth = request->header.find("authorization"); + auth != request->header.end() && auth->second.rfind("Session ", 0) == 0) { + raw_token = auth->second.substr(8); + } + if (raw_token.empty()) { + raw_token = extract_session_token_from_cookie(request->header); + } + bool is_current = false; + if (!raw_token.empty()) { + if (auto hash = session_token_manager.get_hash_for_token(raw_token)) { + is_current = boost::iequals(*hash, session_hash); + } + } + + APIResponse api_response = session_token_api.revoke_session_by_hash(session_hash); + if (api_response.status_code == StatusCode::success_ok && is_current) { + std::string clear_cookie = std::string(session_cookie_name) + "=; Path=/; HttpOnly; SameSite=Strict; Secure; Priority=High; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; + std::string clear_refresh_cookie = std::string(refresh_cookie_name) + "=; Path=/; HttpOnly; SameSite=Strict; Secure; Priority=High; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Max-Age=0"; + api_response.headers.emplace("Set-Cookie", std::move(clear_cookie)); + api_response.headers.emplace("Set-Cookie", std::move(clear_refresh_cookie)); + } + write_api_response(response, api_response); + } + + /** + * @brief Authentication status endpoint. + * Returns whether credentials are configured and if authentication is required for protected API calls. + * This allows the frontend to avoid showing a login modal when not necessary. + * + * Response JSON shape: + * { + * "credentials_configured": true|false, + * "login_required": true|false, + * "authenticated": true|false + * } + * + * login_required becomes true only when credentials are configured and the supplied + * request lacks valid authentication (session token or bearer token) for protected APIs. + */ + void authStatus(resp_https_t response, req_https_t request) { + print_req(request); + + bool credentials_configured = !config::sunshine.username.empty(); + + // Determine if current request has valid auth (session or bearer) using existing check_auth + bool authenticated = false; + if (credentials_configured) { + if (auto result = check_auth(request); result.ok) { + authenticated = true; // check_auth returns ok for public routes; refine below + // We only consider it authenticated if an auth header or cookie was present and validated. + std::string auth_header; + if (auto auth_it = request->header.find("authorization"); auth_it != request->header.end()) { + auth_header = auth_it->second; + } else { + std::string token = extract_session_token_from_cookie(request->header); + if (!token.empty()) { + auth_header = "Session " + token; + } + } + if (auth_header.empty()) { + authenticated = false; // public access granted but no credentials supplied + } else { + // Re-run only auth layer for supplied header specifically to ensure validity + auto address = net::addr_to_normalized_string(request->remote_endpoint().address()); + auto header_check = check_auth(address, auth_header, "/api/config", "GET"); // use protected path for validation + authenticated = header_check.ok; + } + } + } + + bool login_required = credentials_configured && !authenticated; + + nlohmann::json tree; + tree["credentials_configured"] = credentials_configured; + tree["login_required"] = login_required; + tree["authenticated"] = authenticated; + + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Content-Type", "application/json; charset=utf-8"); + add_cors_headers(headers); + response->write(SimpleWeb::StatusCode::success_ok, tree.dump(), headers); + } +} // namespace confighttp diff --git a/src/crypto.cpp b/src/crypto.cpp index 43f71b80a..fdb856f99 100644 --- a/src/crypto.cpp +++ b/src/crypto.cpp @@ -146,6 +146,18 @@ namespace crypto { return 0; } + static int init_decrypt_cbc(cipher_ctx_t &ctx, aes_t *key, aes_t *iv, bool padding) { + ctx.reset(EVP_CIPHER_CTX_new()); + + if (EVP_DecryptInit_ex(ctx.get(), EVP_aes_128_cbc(), nullptr, key->data(), iv->data()) != 1) { + return -1; + } + + EVP_CIPHER_CTX_set_padding(ctx.get(), padding); + + return 0; + } + int gcm_t::decrypt(const std::string_view &tagged_cipher, std::vector &plaintext, aes_t *iv) { if (!decrypt_ctx && init_decrypt_gcm(decrypt_ctx, &key, iv, padding)) { return -1; @@ -305,6 +317,31 @@ namespace crypto { return update_outlen + final_outlen; } + int cbc_t::decrypt(const std::string_view &cipher, std::vector &plaintext, aes_t *iv) { + if (!decrypt_ctx && init_decrypt_cbc(decrypt_ctx, &key, iv, padding)) { + return -1; + } + + if (EVP_DecryptInit_ex(decrypt_ctx.get(), nullptr, nullptr, nullptr, iv->data()) != 1) { + return -1; + } + + plaintext.resize(round_to_pkcs7_padded(cipher.size())); + + int update_outlen, final_outlen; + + if (EVP_DecryptUpdate(decrypt_ctx.get(), plaintext.data(), &update_outlen, (const std::uint8_t *) cipher.data(), cipher.size()) != 1) { + return -1; + } + + if (EVP_DecryptFinal_ex(decrypt_ctx.get(), plaintext.data() + update_outlen, &final_outlen) != 1) { + return -1; + } + + plaintext.resize(update_outlen + final_outlen); + return 0; + } + ecb_t::ecb_t(const aes_t &key, bool padding): cipher_t {EVP_CIPHER_CTX_new(), EVP_CIPHER_CTX_new(), key, padding} { } diff --git a/src/crypto.h b/src/crypto.h index a0c30d61c..27960c784 100644 --- a/src/crypto.h +++ b/src/crypto.h @@ -237,6 +237,7 @@ namespace crypto { * @return The total length of the ciphertext written into cipher. Returns -1 in case of an error. */ int encrypt(const std::string_view &plaintext, std::uint8_t *cipher, aes_t *iv); + int decrypt(const std::string_view &cipher, std::vector &plaintext, aes_t *iv); }; } // namespace cipher } // namespace crypto diff --git a/src/platform/common.h b/src/platform/common.h index 1836ca76d..7c54ad79c 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -6,6 +6,7 @@ // standard includes #include +#include #include #include #include @@ -535,6 +536,10 @@ namespace platf { virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) = 0; + virtual int init_mic_redirect_device() = 0; + virtual void release_mic_redirect_device() = 0; + virtual int write_mic_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) = 0; + /** * @brief Check if the audio sink is available in the system. * @param sink Sink to be checked. diff --git a/src/platform/linux/audio.cpp b/src/platform/linux/audio.cpp index 0e53e939b..f257c6880 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -3,12 +3,14 @@ * @brief Definitions for audio control on Linux. */ // standard includes +#include #include #include #include // lib includes #include +#include #include #include #include @@ -102,6 +104,71 @@ namespace platf { return mic; } + struct mic_redirect_t { + util::safe_ptr sink; + util::safe_ptr decoder; + + int init(const std::string &sink_name) { + int opus_error = OPUS_OK; + decoder.reset(opus_decoder_create(48000, 1, &opus_error)); + if (!decoder || opus_error != OPUS_OK) { + BOOST_LOG(error) << "Couldn't create Opus decoder for microphone redirection: "sv << opus_strerror(opus_error); + return -1; + } + + pa_sample_spec ss {PA_SAMPLE_S16NE, 48000, 1}; + pa_buffer_attr attr { + .maxlength = uint32_t(-1), + .tlength = uint32_t(960 * sizeof(opus_int16) * 6), + .prebuf = uint32_t(-1), + .minreq = uint32_t(-1), + .fragsize = uint32_t(-1), + }; + + int status = 0; + sink.reset(pa_simple_new(nullptr, "sunshine", PA_STREAM_PLAYBACK, sink_name.c_str(), "sunshine-mic", &ss, nullptr, &attr, &status)); + if (!sink) { + BOOST_LOG(error) << "Couldn't open PulseAudio sink for microphone redirection ["sv << sink_name << "]: "sv << pa_strerror(status); + decoder.reset(); + return -1; + } + + return 0; + } + + int write_data(const char *data, std::size_t len, std::uint16_t sequence_number) { + (void) sequence_number; + + if (!sink || !decoder || data == nullptr || len == 0) { + return -1; + } + + std::array pcm {}; + auto decoded = opus_decode(decoder.get(), reinterpret_cast(data), static_cast(len), pcm.data(), static_cast(pcm.size()), 0); + if (decoded <= 0) { + return -1; + } + + int status = 0; + if (pa_simple_write(sink.get(), pcm.data(), decoded * sizeof(opus_int16), &status) < 0) { + BOOST_LOG(debug) << "PulseAudio microphone write failed: "sv << pa_strerror(status); + return -1; + } + + return decoded; + } + + void cleanup() { + if (sink) { + int status = 0; + pa_simple_drain(sink.get(), &status); + } + + sink.reset(); + decoder.reset(); + } + }; + namespace pa { template struct add_const_helper; @@ -186,6 +253,7 @@ namespace platf { loop_t loop; ctx_t ctx; std::string requested_sink; + std::unique_ptr mic_redirect_device; struct { std::uint32_t stereo = PA_INVALID_INDEX; @@ -459,6 +527,43 @@ namespace platf { return ::platf::microphone(mapping, channels, sample_rate, frame_size, get_monitor_name(sink_name)); } + int init_mic_redirect_device() override { + if (mic_redirect_device) { + return 0; + } + + std::string sink_name = config::audio.mic_device; + if (sink_name.empty()) { + BOOST_LOG(warning) << "Set config option [stream_mic] with [mic_device] pointing to a virtual PulseAudio/PipeWire sink to enable microphone redirection"sv; + return -1; + } + + auto device = std::make_unique(); + if (device->init(sink_name) != 0) { + return -1; + } + + BOOST_LOG(info) << "Client microphone redirection target sink: "sv << sink_name; + mic_redirect_device = std::move(device); + return 0; + } + + void release_mic_redirect_device() override { + if (mic_redirect_device) { + mic_redirect_device->cleanup(); + mic_redirect_device.reset(); + } + } + + int write_mic_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) override { + (void) timestamp; + if (!mic_redirect_device) { + return -1; + } + + return mic_redirect_device->write_data(data, len, sequence_number); + } + bool is_sink_available(const std::string &sink) override { BOOST_LOG(warning) << "audio_control_t::is_sink_available() unimplemented: "sv << sink; return true; @@ -495,6 +600,7 @@ namespace platf { } ~server_t() override { + release_mic_redirect_device(); unload_null(index.stereo); unload_null(index.surround51); unload_null(index.surround71); diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 06b9c19a8..1b2396952 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -78,6 +78,22 @@ int set_sink(const std::string &sink) override { return mic; } + int init_mic_redirect_device() override { + BOOST_LOG(warning) << "Client microphone redirection is not implemented on macOS yet"sv; + return -1; + } + + void release_mic_redirect_device() override { + } + + int write_mic_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) override { + (void) data; + (void) len; + (void) sequence_number; + (void) timestamp; + return -1; + } + bool is_sink_available(const std::string &sink) override { BOOST_LOG(warning) << "audio_control_t::is_sink_available() unimplemented: "sv << sink; return true; diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index c6db800c6..01f7c67bb 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -1,1667 +1,1763 @@ -/** - * @file src/platform/windows/audio.cpp - * @brief Definitions for Windows audio capture. - */ -#define INITGUID - -// standard includes -#include -#include -#include -#include - -// platform includes -#include -#include -#include -#include -#include -#include -#include - -// local includes -#include "misc.h" -#include "src/config.h" -#include "src/logging.h" -#include "src/platform/common.h" - -// Must be the last included file -// clang-format off -#include "PolicyConfig.h" -// clang-format on - -DEFINE_PROPERTYKEY(PKEY_Device_DeviceDesc, 0xa45c254e, 0xdf1c, 0x4efd, 0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0, 2); // DEVPROP_TYPE_STRING -DEFINE_PROPERTYKEY(PKEY_Device_FriendlyName, 0xa45c254e, 0xdf1c, 0x4efd, 0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0, 14); // DEVPROP_TYPE_STRING -DEFINE_PROPERTYKEY(PKEY_DeviceInterface_FriendlyName, 0x026e516e, 0xb814, 0x414b, 0x83, 0xcd, 0x85, 0x6d, 0x6f, 0xef, 0x48, 0x22, 2); - -#if defined(__x86_64) || defined(__x86_64__) || defined(__amd64) || defined(__amd64__) || defined(_M_AMD64) - #define STEAM_DRIVER_SUBDIR L"x64" -#else - #warning No known Steam audio driver for this architecture -#endif - -namespace { - - constexpr auto SAMPLE_RATE = 48000; - constexpr auto STEAM_AUDIO_DRIVER_PATH = L"%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\" STEAM_DRIVER_SUBDIR L"\\SteamStreamingSpeakers.inf"; - - constexpr auto waveformat_mask_stereo = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; - - constexpr auto waveformat_mask_surround51_with_backspeakers = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT | - SPEAKER_FRONT_CENTER | SPEAKER_LOW_FREQUENCY | - SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT; - - constexpr auto waveformat_mask_surround51_with_sidespeakers = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT | - SPEAKER_FRONT_CENTER | SPEAKER_LOW_FREQUENCY | - SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT; - - constexpr auto waveformat_mask_surround71 = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT | - SPEAKER_FRONT_CENTER | SPEAKER_LOW_FREQUENCY | - SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT | - SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT; - - enum class sample_format_e { - f32, - s32, - s24in32, - s24, - s16, - _size, - }; - - constexpr WAVEFORMATEXTENSIBLE create_waveformat(sample_format_e sample_format, WORD channel_count, DWORD channel_mask) { - WAVEFORMATEXTENSIBLE waveformat = {}; - - switch (sample_format) { - default: - case sample_format_e::f32: - waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; - waveformat.Format.wBitsPerSample = 32; - waveformat.Samples.wValidBitsPerSample = 32; - break; - - case sample_format_e::s32: - waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; - waveformat.Format.wBitsPerSample = 32; - waveformat.Samples.wValidBitsPerSample = 32; - break; - - case sample_format_e::s24in32: - waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; - waveformat.Format.wBitsPerSample = 32; - waveformat.Samples.wValidBitsPerSample = 24; - break; - - case sample_format_e::s24: - waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; - waveformat.Format.wBitsPerSample = 24; - waveformat.Samples.wValidBitsPerSample = 24; - break; - - case sample_format_e::s16: - waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; - waveformat.Format.wBitsPerSample = 16; - waveformat.Samples.wValidBitsPerSample = 16; - break; - } - - static_assert((int) sample_format_e::_size == 5, "Unrecognized sample_format_e"); - - waveformat.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; - waveformat.Format.nChannels = channel_count; - waveformat.Format.nSamplesPerSec = SAMPLE_RATE; - - waveformat.Format.nBlockAlign = waveformat.Format.nChannels * waveformat.Format.wBitsPerSample / 8; - waveformat.Format.nAvgBytesPerSec = waveformat.Format.nSamplesPerSec * waveformat.Format.nBlockAlign; - waveformat.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); - - waveformat.dwChannelMask = channel_mask; - - return waveformat; - } - - using virtual_sink_waveformats_t = std::vector; - - /** - * @brief List of supported waveformats for an N-channel virtual audio device - * @tparam channel_count Number of virtual audio channels - * @returns std::vector - * @note The list of virtual formats returned are sorted in preference order and the first valid - * format will be used. All bits-per-sample options are listed because we try to match - * this to the default audio device. See also: set_format() below. - */ - template - virtual_sink_waveformats_t create_virtual_sink_waveformats() { - if constexpr (channel_count == 2) { - auto channel_mask = waveformat_mask_stereo; - // The 32-bit formats are a lower priority for stereo because using one will disable Dolby/DTS - // spatial audio mode if the user enabled it on the Steam speaker. - return { - create_waveformat(sample_format_e::s24in32, channel_count, channel_mask), - create_waveformat(sample_format_e::s24, channel_count, channel_mask), - create_waveformat(sample_format_e::s16, channel_count, channel_mask), - create_waveformat(sample_format_e::f32, channel_count, channel_mask), - create_waveformat(sample_format_e::s32, channel_count, channel_mask), - }; - } else if (channel_count == 6) { - auto channel_mask1 = waveformat_mask_surround51_with_backspeakers; - auto channel_mask2 = waveformat_mask_surround51_with_sidespeakers; - return { - create_waveformat(sample_format_e::f32, channel_count, channel_mask1), - create_waveformat(sample_format_e::f32, channel_count, channel_mask2), - create_waveformat(sample_format_e::s32, channel_count, channel_mask1), - create_waveformat(sample_format_e::s32, channel_count, channel_mask2), - create_waveformat(sample_format_e::s24in32, channel_count, channel_mask1), - create_waveformat(sample_format_e::s24in32, channel_count, channel_mask2), - create_waveformat(sample_format_e::s24, channel_count, channel_mask1), - create_waveformat(sample_format_e::s24, channel_count, channel_mask2), - create_waveformat(sample_format_e::s16, channel_count, channel_mask1), - create_waveformat(sample_format_e::s16, channel_count, channel_mask2), - }; - } else if (channel_count == 8) { - auto channel_mask = waveformat_mask_surround71; - return { - create_waveformat(sample_format_e::f32, channel_count, channel_mask), - create_waveformat(sample_format_e::s32, channel_count, channel_mask), - create_waveformat(sample_format_e::s24in32, channel_count, channel_mask), - create_waveformat(sample_format_e::s24, channel_count, channel_mask), - create_waveformat(sample_format_e::s16, channel_count, channel_mask), - }; - } - } - - std::string waveformat_to_pretty_string(const WAVEFORMATEXTENSIBLE &waveformat) { - std::string result = waveformat.SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT ? "F" : - waveformat.SubFormat == KSDATAFORMAT_SUBTYPE_PCM ? "S" : - "UNKNOWN"; - - result += std::format("{} {} ", static_cast(waveformat.Samples.wValidBitsPerSample), static_cast(waveformat.Format.nSamplesPerSec)); - - switch (waveformat.dwChannelMask) { - case waveformat_mask_stereo: - result += "2.0"; - break; - - case waveformat_mask_surround51_with_backspeakers: - result += "5.1"; - break; - - case waveformat_mask_surround51_with_sidespeakers: - result += "5.1 (sidespeakers)"; - break; - - case waveformat_mask_surround71: - result += "7.1"; - break; - - default: - result += std::format("{} channels (unrecognized)", static_cast(waveformat.Format.nChannels)); - break; - } - - return result; - } - -} // namespace - -using namespace std::literals; - -namespace platf::audio { - template - void Release(T *p) { - p->Release(); - } - - template - void co_task_free(T *p) { - CoTaskMemFree((LPVOID) p); - } - - using device_enum_t = util::safe_ptr>; - using device_t = util::safe_ptr>; - using collection_t = util::safe_ptr>; - using audio_client_t = util::safe_ptr>; - using audio_capture_t = util::safe_ptr>; - using wave_format_t = util::safe_ptr>; - using wstring_t = util::safe_ptr>; - using handle_t = util::safe_ptr_v2; - using policy_t = util::safe_ptr>; - using prop_t = util::safe_ptr>; - - class co_init_t: public deinit_t { - public: - co_init_t() { - CoInitializeEx(nullptr, COINIT_MULTITHREADED | COINIT_SPEED_OVER_MEMORY); - } - - ~co_init_t() override { - CoUninitialize(); - } - }; - - class prop_var_t { - public: - prop_var_t() { - PropVariantInit(&prop); - } - - ~prop_var_t() { - PropVariantClear(&prop); - } - - PROPVARIANT prop; - }; - - struct format_t { - WORD channel_count; - std::string name; - int capture_waveformat_channel_mask; - virtual_sink_waveformats_t virtual_sink_waveformats; - }; - - const std::array formats = { - format_t { - 2, - "Stereo", - waveformat_mask_stereo, - create_virtual_sink_waveformats<2>(), - }, - format_t { - 6, - "Surround 5.1", - waveformat_mask_surround51_with_backspeakers, - create_virtual_sink_waveformats<6>(), - }, - format_t { - 8, - "Surround 7.1", - waveformat_mask_surround71, - create_virtual_sink_waveformats<8>(), - }, - }; - - audio_client_t make_audio_client(device_t &device, const format_t &format) { - audio_client_t audio_client; - auto status = device->Activate( - IID_IAudioClient, - CLSCTX_ALL, - nullptr, - (void **) &audio_client - ); - - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't activate Device: [0x"sv << util::hex(status).to_string_view() << ']'; - - return nullptr; - } - - WAVEFORMATEXTENSIBLE capture_waveformat = - create_waveformat(sample_format_e::f32, format.channel_count, format.capture_waveformat_channel_mask); - - { - wave_format_t mixer_waveformat; - status = audio_client->GetMixFormat(&mixer_waveformat); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't get mix format for audio device: [0x"sv << util::hex(status).to_string_view() << ']'; - return nullptr; - } - - // Prefer the native channel layout of captured audio device when channel counts match - if (mixer_waveformat->nChannels == format.channel_count && - mixer_waveformat->wFormatTag == WAVE_FORMAT_EXTENSIBLE && - mixer_waveformat->cbSize >= 22) { - auto waveformatext_pointer = reinterpret_cast(mixer_waveformat.get()); - capture_waveformat.dwChannelMask = waveformatext_pointer->dwChannelMask; - } - - BOOST_LOG(info) << "Audio mixer format is "sv << mixer_waveformat->wBitsPerSample << "-bit, "sv - << mixer_waveformat->nSamplesPerSec << " Hz, "sv - << ((mixer_waveformat->nSamplesPerSec != 48000) ? "will be resampled to 48000 by Windows"sv : "no resampling needed"sv); - } - - status = audio_client->Initialize( - AUDCLNT_SHAREMODE_SHARED, - AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK | - AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, // Enable automatic resampling to 48 KHz - 0, - 0, - (LPWAVEFORMATEX) &capture_waveformat, - nullptr - ); - - if (status) { - BOOST_LOG(error) << "Couldn't initialize audio client for ["sv << format.name << "]: [0x"sv << util::hex(status).to_string_view() << ']'; - return nullptr; - } - - BOOST_LOG(info) << "Audio capture format is "sv << logging::bracket(waveformat_to_pretty_string(capture_waveformat)); - - return audio_client; - } - - device_t default_device(device_enum_t &device_enum) { - device_t device; - HRESULT status; - status = device_enum->GetDefaultAudioEndpoint( - eRender, - eConsole, - &device - ); - - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't get default audio endpoint [0x"sv << util::hex(status).to_string_view() << ']'; - - return nullptr; - } - - return device; - } - - /** - * @brief Lightweight IMMNotificationClient that signals a Win32 Event - * when a non-ignored audio device becomes active or is added. - * Used by reset_default_device() to wait for device arrival. - */ - class device_arrival_notification_t: public ::IMMNotificationClient { - public: - /** - * @param ignored_device_id Device ID to ignore in notifications (e.g., Steam Streaming Speakers). - */ - explicit device_arrival_notification_t(const std::wstring &ignored_device_id): - ignored_id(ignored_device_id) { - arrival_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (!arrival_event) { - BOOST_LOG(warning) << "Failed to create device arrival event"sv; - } - } - - ~device_arrival_notification_t() { - if (arrival_event) { - CloseHandle(arrival_event); - } - } - - ULONG STDMETHODCALLTYPE AddRef() { return 1; } - ULONG STDMETHODCALLTYPE Release() { return 1; } - - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, VOID **ppvInterface) { - if (IID_IUnknown == riid) { - AddRef(); - *ppvInterface = (IUnknown *) this; - return S_OK; - } else if (__uuidof(IMMNotificationClient) == riid) { - AddRef(); - *ppvInterface = (IMMNotificationClient *) this; - return S_OK; - } else { - *ppvInterface = nullptr; - return E_NOINTERFACE; - } - } - - HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged(EDataFlow, ERole, LPCWSTR) { return S_OK; } - HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR) { return S_OK; } - HRESULT STDMETHODCALLTYPE OnPropertyValueChanged(LPCWSTR, const PROPERTYKEY) { return S_OK; } - - HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR pwstrDeviceId) { - if (arrival_event && !is_ignored(pwstrDeviceId)) { - SetEvent(arrival_event); - } - return S_OK; - } - - HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR pwstrDeviceId, DWORD dwNewState) { - if (dwNewState == DEVICE_STATE_ACTIVE && arrival_event && !is_ignored(pwstrDeviceId)) { - SetEvent(arrival_event); - } - return S_OK; - } - - /** - * @brief Wait for the arrival event to be signaled. - * @param timeout_ms Maximum time to wait in milliseconds. - * @return true if signaled, false on timeout. - */ - bool wait(HANDLE cancel_event, DWORD timeout_ms) { - if (!arrival_event) { - if (cancel_event) { - WaitForSingleObject(cancel_event, timeout_ms); - } else { - Sleep(timeout_ms); - } - return false; - } - - HANDLE wait_handles[2] {arrival_event, cancel_event}; - DWORD handle_count = cancel_event ? 2 : 1; - auto result = WaitForMultipleObjects(handle_count, wait_handles, FALSE, timeout_ms); - if (result == WAIT_OBJECT_0) { - ResetEvent(arrival_event); - return true; - } - return false; - } - - private: - bool is_ignored(LPCWSTR device_id) const { - return device_id && !ignored_id.empty() && ignored_id == device_id; - } - - HANDLE arrival_event = nullptr; - std::wstring ignored_id; - }; - - class audio_notification_t: public ::IMMNotificationClient { - public: - audio_notification_t() { - } - - // IUnknown implementation (unused by IMMDeviceEnumerator) - ULONG STDMETHODCALLTYPE AddRef() { - return 1; - } - - ULONG STDMETHODCALLTYPE Release() { - return 1; - } - - HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, VOID **ppvInterface) { - if (IID_IUnknown == riid) { - AddRef(); - *ppvInterface = (IUnknown *) this; - return S_OK; - } else if (__uuidof(IMMNotificationClient) == riid) { - AddRef(); - *ppvInterface = (IMMNotificationClient *) this; - return S_OK; - } else { - *ppvInterface = nullptr; - return E_NOINTERFACE; - } - } - - // IMMNotificationClient - HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged(EDataFlow flow, ERole role, LPCWSTR pwstrDeviceId) { - if (flow == eRender) { - default_render_device_changed_flag.store(true); - } - return S_OK; - } - - HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR pwstrDeviceId) { - return S_OK; - } - - HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR pwstrDeviceId) { - return S_OK; - } - - HRESULT STDMETHODCALLTYPE OnDeviceStateChanged( - LPCWSTR pwstrDeviceId, - DWORD dwNewState - ) { - return S_OK; - } - - HRESULT STDMETHODCALLTYPE OnPropertyValueChanged( - LPCWSTR pwstrDeviceId, - const PROPERTYKEY key - ) { - return S_OK; - } - - /** - * @brief Checks if the default rendering device changed and resets the change flag - * @return `true` if the device changed since last call - */ - bool check_default_render_device_changed() { - return default_render_device_changed_flag.exchange(false); - } - - private: - std::atomic_bool default_render_device_changed_flag; - }; - - class mic_wasapi_t: public mic_t { - public: - capture_e sample(std::vector &sample_out) override { - auto sample_size = sample_out.size(); - - // Refill the sample buffer if needed - while (sample_buf_pos - std::begin(sample_buf) < sample_size) { - auto capture_result = _fill_buffer(); - if (capture_result != capture_e::ok) { - return capture_result; - } - } - - // Fill the output buffer with samples - std::copy_n(std::begin(sample_buf), sample_size, std::begin(sample_out)); - - // Move any excess samples to the front of the buffer - std::move(&sample_buf[sample_size], sample_buf_pos, std::begin(sample_buf)); - sample_buf_pos -= sample_size; - - return capture_e::ok; - } - - int init(std::uint32_t sample_rate, std::uint32_t frame_size, std::uint32_t channels_out) { - audio_event.reset(CreateEventA(nullptr, FALSE, FALSE, nullptr)); - if (!audio_event) { - BOOST_LOG(error) << "Couldn't create Event handle"sv; - - return -1; - } - - HRESULT status; - - status = CoCreateInstance( - CLSID_MMDeviceEnumerator, - nullptr, - CLSCTX_ALL, - IID_IMMDeviceEnumerator, - (void **) &device_enum - ); - - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't create Device Enumerator [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - status = device_enum->RegisterEndpointNotificationCallback(&endpt_notification); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't register endpoint notification [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - auto device = default_device(device_enum); - if (!device) { - return -1; - } - - for (const auto &format : formats) { - if (format.channel_count != channels_out) { - BOOST_LOG(debug) << "Skipping audio format ["sv << format.name << "] with channel count ["sv - << format.channel_count << " != "sv << channels_out << ']'; - continue; - } - - BOOST_LOG(debug) << "Trying audio format ["sv << format.name << ']'; - audio_client = make_audio_client(device, format); - - if (audio_client) { - BOOST_LOG(debug) << "Found audio format ["sv << format.name << ']'; - channels = channels_out; - break; - } - } - - if (!audio_client) { - BOOST_LOG(error) << "Couldn't find supported format for audio"sv; - return -1; - } - - REFERENCE_TIME default_latency; - audio_client->GetDevicePeriod(&default_latency, nullptr); - default_latency_ms = default_latency / 1000; - - std::uint32_t frames; - status = audio_client->GetBufferSize(&frames); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't acquire the number of audio frames [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - // *2 --> needs to fit double - sample_buf = util::buffer_t {std::max(frames, frame_size) * 2 * channels_out}; - sample_buf_pos = std::begin(sample_buf); - - status = audio_client->GetService(IID_IAudioCaptureClient, (void **) &audio_capture); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't initialize audio capture client [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - status = audio_client->SetEventHandle(audio_event.get()); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't set event handle [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - { - DWORD task_index = 0; - mmcss_task_handle = AvSetMmThreadCharacteristics("Pro Audio", &task_index); - if (!mmcss_task_handle) { - BOOST_LOG(error) << "Couldn't associate audio capture thread with Pro Audio MMCSS task [0x" << util::hex(GetLastError()).to_string_view() << ']'; - } - } - - status = audio_client->Start(); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't start recording [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - return 0; - } - - ~mic_wasapi_t() override { - if (device_enum) { - device_enum->UnregisterEndpointNotificationCallback(&endpt_notification); - } - - if (audio_client) { - audio_client->Stop(); - } - - if (mmcss_task_handle) { - AvRevertMmThreadCharacteristics(mmcss_task_handle); - } - } - - private: - capture_e _fill_buffer() { - HRESULT status; - - // Total number of samples - struct sample_aligned_t { - std::uint32_t uninitialized; - float *samples; - } sample_aligned; - - // number of samples / number of channels - struct block_aligned_t { - std::uint32_t audio_sample_size; - } block_aligned; - - // Check if the default audio device has changed - if (endpt_notification.check_default_render_device_changed()) { - // Invoke the audio_control_t's callback if it wants one - if (default_endpt_changed_cb) { - (*default_endpt_changed_cb)(); - } - - // Reinitialize to pick up the new default device - return capture_e::reinit; - } - - status = WaitForSingleObjectEx(audio_event.get(), default_latency_ms, FALSE); - switch (status) { - case WAIT_OBJECT_0: - break; - case WAIT_TIMEOUT: - return capture_e::timeout; - default: - BOOST_LOG(error) << "Couldn't wait for audio event: [0x"sv << util::hex(status).to_string_view() << ']'; - return capture_e::error; - } - - std::uint32_t packet_size {}; - for ( - status = audio_capture->GetNextPacketSize(&packet_size); - SUCCEEDED(status) && packet_size > 0; - status = audio_capture->GetNextPacketSize(&packet_size) - ) { - DWORD buffer_flags; - status = audio_capture->GetBuffer( - (BYTE **) &sample_aligned.samples, - &block_aligned.audio_sample_size, - &buffer_flags, - nullptr, - nullptr - ); - - switch (status) { - case S_OK: - break; - case AUDCLNT_E_DEVICE_INVALIDATED: - return capture_e::reinit; - default: - BOOST_LOG(error) << "Couldn't capture audio [0x"sv << util::hex(status).to_string_view() << ']'; - return capture_e::error; - } - - if (buffer_flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) { - BOOST_LOG(debug) << "Audio capture signaled buffer discontinuity"; - } - - sample_aligned.uninitialized = std::end(sample_buf) - sample_buf_pos; - auto n = std::min(sample_aligned.uninitialized, block_aligned.audio_sample_size * channels); - - if (n < block_aligned.audio_sample_size * channels) { - BOOST_LOG(warning) << "Audio capture buffer overflow"; - } - - if (buffer_flags & AUDCLNT_BUFFERFLAGS_SILENT) { - std::fill_n(sample_buf_pos, n, 0); - } else { - std::copy_n(sample_aligned.samples, n, sample_buf_pos); - } - - sample_buf_pos += n; - - audio_capture->ReleaseBuffer(block_aligned.audio_sample_size); - } - - if (status == AUDCLNT_E_DEVICE_INVALIDATED) { - return capture_e::reinit; - } - - if (FAILED(status)) { - return capture_e::error; - } - - return capture_e::ok; - } - - public: - handle_t audio_event; - - device_enum_t device_enum; - device_t device; - audio_client_t audio_client; - audio_capture_t audio_capture; - - audio_notification_t endpt_notification; - std::optional> default_endpt_changed_cb; - - REFERENCE_TIME default_latency_ms; - - util::buffer_t sample_buf; - float *sample_buf_pos; - int channels; - - HANDLE mmcss_task_handle = nullptr; - }; - - class audio_control_t: public ::platf::audio_control_t { - public: - std::optional sink_info() override { - sink_t sink; - - // Fill host sink name with the device_id of the current default audio device. - { - auto device = default_device(device_enum); - if (!device) { - return std::nullopt; - } - - audio::wstring_t id; - device->GetId(&id); - - std::wstring host_id = id.get(); - auto matched_steam = find_device_id(match_steam_speakers()); - if (matched_steam && host_id == matched_steam->second) { - auto pending_preferred_id = pending_preferred_restore_id(); - if (pending_preferred_id) { - host_id = *pending_preferred_id; - } - } else { - clear_pending_preferred_restore(); - } - - sink.host = to_utf8(host_id.c_str()); - // Pre-populate the restore-cache so we have property snapshots even if - // the device disappears before reset_default_device runs. - (void) preferred_device_match_list(host_id); - } - - // Prepare to search for the device_id of the virtual audio sink device, - // this device can be either user-configured or - // the Steam Streaming Speakers we use by default. - match_fields_list_t match_list; - if (config::audio.virtual_sink.empty()) { - match_list = match_steam_speakers(); - } else { - match_list = match_all_fields(from_utf8(config::audio.virtual_sink)); - } - - // Search for the virtual audio sink device currently present in the system. - auto matched = find_device_id(match_list); - if (matched) { - // Prepare to fill virtual audio sink names with device_id. - auto device_id = to_utf8(matched->second); - // Also prepend format name (basically channel layout at the moment) - // because we don't want to extend the platform interface. - sink.null = std::make_optional(sink_t::null_t { - "virtual-"s + formats[0].name + device_id, - "virtual-"s + formats[1].name + device_id, - "virtual-"s + formats[2].name + device_id, - }); - } else if (!config::audio.virtual_sink.empty()) { - BOOST_LOG(warning) << "Couldn't find the specified virtual audio sink " << config::audio.virtual_sink; - } - - return sink; - } - - bool is_sink_available(const std::string &sink) override { - const auto match_list = match_all_fields(from_utf8(sink)); - const auto matched = find_device_id(match_list); - return static_cast(matched); - } - - /** - * @brief Extract virtual audio sink information possibly encoded in the sink name. - * @param sink The sink name - * @return A pair of device_id and format reference if the sink name matches - * our naming scheme for virtual audio sinks, `std::nullopt` otherwise. - */ - std::optional>> extract_virtual_sink_info(const std::string &sink) { - // Encoding format: - // [virtual-(format name)]device_id - std::string current = sink; - auto prefix = "virtual-"sv; - if (current.find(prefix) == 0) { - current = current.substr(prefix.size(), current.size() - prefix.size()); - - for (const auto &format : formats) { - auto &name = format.name; - if (current.find(name) == 0) { - auto device_id = from_utf8(current.substr(name.size(), current.size() - name.size())); - return std::make_pair(device_id, std::reference_wrapper(format)); - } - } - } - - return std::nullopt; - } - - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) override { - auto mic = std::make_unique(); - - if (mic->init(sample_rate, frame_size, channels)) { - return nullptr; - } - - if (config::audio.keep_default) { - // If this is a virtual sink, set a callback that will change the sink back if it's changed - auto virtual_sink_info = extract_virtual_sink_info(assigned_sink); - if (virtual_sink_info) { - mic->default_endpt_changed_cb = [this] { - BOOST_LOG(info) << "Resetting sink to ["sv << assigned_sink << "] after default changed"; - set_sink(assigned_sink); - }; - } - } - - return mic; - } - - /** - * If the requested sink is a virtual sink, meaning no speakers attached to - * the host, then we can seamlessly set the format to stereo and surround sound. - * - * Any virtual sink detected will be prefixed by: - * virtual-(format name) - * If it doesn't contain that prefix, then the format will not be changed - */ - std::optional set_format(const std::string &sink) { - if (sink.empty()) { - return std::nullopt; - } - - auto virtual_sink_info = extract_virtual_sink_info(sink); - - if (!virtual_sink_info) { - // Sink name does not begin with virtual-(format name), hence it's not a virtual sink - // and we don't want to change playback format of the corresponding device. - // Also need to perform matching, sink name is not necessarily device_id in this case. - auto matched = find_device_id(match_all_fields(from_utf8(sink))); - if (matched) { - return matched->second; - } else { - BOOST_LOG(error) << "Couldn't find audio sink " << sink; - return std::nullopt; - } - } - - // When switching to a Steam virtual speaker device, try to retain the bit depth of the - // default audio device. Switching from a 16-bit device to a 24-bit one has been known to - // cause glitches for some users. - int wanted_bits_per_sample = 32; - auto current_default_dev = default_device(device_enum); - if (current_default_dev) { - audio::prop_t prop; - prop_var_t current_device_format; - - if (SUCCEEDED(current_default_dev->OpenPropertyStore(STGM_READ, &prop)) && SUCCEEDED(prop->GetValue(PKEY_AudioEngine_DeviceFormat, ¤t_device_format.prop))) { - auto *format = (WAVEFORMATEXTENSIBLE *) current_device_format.prop.blob.pBlobData; - wanted_bits_per_sample = format->Samples.wValidBitsPerSample; - BOOST_LOG(info) << "Virtual audio device will use "sv << wanted_bits_per_sample << "-bit to match default device"sv; - } - } - - auto &device_id = virtual_sink_info->first; - auto &waveformats = virtual_sink_info->second.get().virtual_sink_waveformats; - for (const auto &waveformat : waveformats) { - // We're using completely undocumented and unlisted API, - // better not pass objects without copying them first. - auto device_id_copy = device_id; - auto waveformat_copy = waveformat; - auto waveformat_copy_pointer = reinterpret_cast(&waveformat_copy); - - if (wanted_bits_per_sample != waveformat.Samples.wValidBitsPerSample) { - continue; - } - - WAVEFORMATEXTENSIBLE p {}; - if (SUCCEEDED(policy->SetDeviceFormat(device_id_copy.c_str(), waveformat_copy_pointer, (WAVEFORMATEX *) &p))) { - BOOST_LOG(info) << "Changed virtual audio sink format to " << logging::bracket(waveformat_to_pretty_string(waveformat)); - return device_id; - } - } - - BOOST_LOG(error) << "Couldn't set virtual audio sink waveformat"; - return std::nullopt; - } - - int set_sink(const std::string &sink) override { - // Cancel any background restore — it would fight us moving to Steam. - cancel_pending_restore_task(); - - auto device_id = set_format(sink); - if (!device_id) { - return -1; - } - - int failure {}; - for (int x = 0; x < (int) ERole_enum_count; ++x) { - auto status = policy->SetDefaultEndpoint(device_id->c_str(), (ERole) x); - if (status) { - // Depending on the format of the string, we could get either of these errors - if (status == HRESULT_FROM_WIN32(ERROR_NOT_FOUND) || status == E_INVALIDARG) { - BOOST_LOG(warning) << "Audio sink not found: "sv << sink; - } else { - BOOST_LOG(warning) << "Couldn't set ["sv << sink << "] to role ["sv << x << "]: 0x"sv << util::hex(status).to_string_view(); - } - - ++failure; - } - } - - // Remember the assigned sink name, so we have it for later if we need to set it - // back after another application changes it - if (!failure) { - assigned_sink = sink; - } - - return failure; - } - - enum class match_field_e { - device_id, ///< Match device_id - device_friendly_name, ///< Match endpoint friendly name - adapter_friendly_name, ///< Match adapter friendly name - device_description, ///< Match endpoint description - }; - - using match_fields_list_t = std::vector>; - using matched_field_t = std::pair; - - audio_control_t::match_fields_list_t match_steam_speakers() { - return { - {match_field_e::adapter_friendly_name, L"Steam Streaming Speakers"} - }; - } - - audio_control_t::match_fields_list_t match_all_fields(const std::wstring &name) { - return { - {match_field_e::device_id, name}, // {0.0.0.00000000}.{29dd7668-45b2-4846-882d-950f55bf7eb8} - {match_field_e::device_friendly_name, name}, // Digital Audio (S/PDIF) (High Definition Audio Device) - {match_field_e::device_description, name}, // Digital Audio (S/PDIF) - {match_field_e::adapter_friendly_name, name}, // High Definition Audio Device - }; - } - - static std::mutex &preferred_restore_cache_mutex_ref() { - static std::mutex mutex; - return mutex; - } - - static std::unordered_map &preferred_restore_cache_ref() { - static std::unordered_map cache; - return cache; - } - - static std::wstring &pending_preferred_restore_id_ref() { - static std::wstring id; - return id; - } - - static std::optional pending_preferred_restore_id() { - std::lock_guard lock {preferred_restore_cache_mutex_ref()}; - const auto &id = pending_preferred_restore_id_ref(); - if (id.empty()) { - return std::nullopt; - } - - return id; - } - - static void remember_pending_preferred_restore(const std::wstring &preferred_id, const std::wstring &steam_device_id) { - if (preferred_id.empty() || preferred_id == steam_device_id) { - return; - } - - std::lock_guard lock {preferred_restore_cache_mutex_ref()}; - pending_preferred_restore_id_ref() = preferred_id; - } - - static void clear_pending_preferred_restore(const std::wstring &preferred_id = {}) { - std::lock_guard lock {preferred_restore_cache_mutex_ref()}; - auto &pending_id = pending_preferred_restore_id_ref(); - if (preferred_id.empty() || pending_id == preferred_id) { - pending_id.clear(); - } - } - - static void append_match_field(match_fields_list_t &match_list, match_field_e field, const wchar_t *value) { - if (value == nullptr || value[0] == L'\0') { - return; - } - - const std::wstring candidate {value}; - for (const auto &[existing_field, existing_value] : match_list) { - if (existing_field == field && existing_value == candidate) { - return; - } - } - - match_list.emplace_back(field, candidate); - } - - std::optional preferred_device_match_list(const std::wstring &preferred_id) { - { - std::lock_guard lock {preferred_restore_cache_mutex_ref()}; - auto &cache = preferred_restore_cache_ref(); - const auto it = cache.find(preferred_id); - if (it != cache.end()) { - return it->second; - } - } - - audio::device_t device; - if (FAILED(device_enum->GetDevice(preferred_id.c_str(), &device)) || !device) { - return std::nullopt; - } - - match_fields_list_t match_list; - match_list.emplace_back(match_field_e::device_id, preferred_id); - - audio::prop_t prop; - if (FAILED(device->OpenPropertyStore(STGM_READ, &prop)) || !prop) { - return match_list; - } - - prop_var_t device_friendly_name; - prop_var_t adapter_friendly_name; - prop_var_t device_desc; - - append_match_field(match_list, match_field_e::device_friendly_name, - SUCCEEDED(prop->GetValue(PKEY_Device_FriendlyName, &device_friendly_name.prop)) ? device_friendly_name.prop.pwszVal : nullptr); - append_match_field(match_list, match_field_e::device_description, - SUCCEEDED(prop->GetValue(PKEY_Device_DeviceDesc, &device_desc.prop)) ? device_desc.prop.pwszVal : nullptr); - append_match_field(match_list, match_field_e::adapter_friendly_name, - SUCCEEDED(prop->GetValue(PKEY_DeviceInterface_FriendlyName, &adapter_friendly_name.prop)) ? adapter_friendly_name.prop.pwszVal : nullptr); - - { - std::lock_guard lock {preferred_restore_cache_mutex_ref()}; - preferred_restore_cache_ref()[preferred_id] = match_list; - } - - return match_list; - } - - /** - * @brief Search for currently present audio device_id using multiple match fields. - * @param match_list Pairs of match fields and values - * @return Optional pair of matched field and device_id - */ - std::optional find_device_id(const match_fields_list_t &match_list) { - if (match_list.empty()) { - return std::nullopt; - } - - collection_t collection; - auto status = device_enum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't enumerate: [0x"sv << util::hex(status).to_string_view() << ']'; - return std::nullopt; - } - - UINT count = 0; - collection->GetCount(&count); - - std::vector matched(match_list.size()); - for (auto x = 0; x < count; ++x) { - audio::device_t device; - collection->Item(x, &device); - - audio::wstring_t wstring_id; - device->GetId(&wstring_id); - std::wstring device_id = wstring_id.get(); - - audio::prop_t prop; - device->OpenPropertyStore(STGM_READ, &prop); - - prop_var_t adapter_friendly_name; - prop_var_t device_friendly_name; - prop_var_t device_desc; - - prop->GetValue(PKEY_Device_FriendlyName, &device_friendly_name.prop); - prop->GetValue(PKEY_DeviceInterface_FriendlyName, &adapter_friendly_name.prop); - prop->GetValue(PKEY_Device_DeviceDesc, &device_desc.prop); - - for (size_t i = 0; i < match_list.size(); i++) { - if (matched[i].empty()) { - const wchar_t *match_value = nullptr; - switch (match_list[i].first) { - case match_field_e::device_id: - match_value = device_id.c_str(); - break; - - case match_field_e::device_friendly_name: - match_value = device_friendly_name.prop.pwszVal; - break; - - case match_field_e::adapter_friendly_name: - match_value = adapter_friendly_name.prop.pwszVal; - break; - - case match_field_e::device_description: - match_value = device_desc.prop.pwszVal; - break; - } - if (match_value && std::wcscmp(match_value, match_list[i].second.c_str()) == 0) { - matched[i] = device_id; - } - } - } - } - - for (size_t i = 0; i < match_list.size(); i++) { - if (!matched[i].empty()) { - return matched_field_t(match_list[i].first, matched[i]); - } - } - - return std::nullopt; - } - - /** - * @brief Resets the default audio device from Steam Streaming Speakers. - * If a preferred device is supplied, tries to restore that exact device, - * keeping a background retry active if it is temporarily missing (e.g., - * HDMI/DP audio coming back after virtual display teardown). While a - * preferred restore is pending, Steam speakers remain usable instead of - * falling back to another endpoint. - * @param preferred_device The endpoint device_id of the device to restore. - */ - void reset_default_device(const std::string &preferred_device = {}) override { - std::wstring preferred_id; - if (!preferred_device.empty()) { - preferred_id = from_utf8(preferred_device); - } - reset_default_device_impl(true, preferred_id); - } - - /** - * @brief Non-blocking variant of reset_default_device() for startup. - * Tries once to move the default away from Steam speakers without waiting. - */ - void reset_default_device_no_wait() { - reset_default_device_impl(false, {}); - } - - private: - bool is_default_device(const std::wstring &device_id) { - auto current_default_dev = default_device(device_enum); - if (!current_default_dev) { - return false; - } - - audio::wstring_t current_default_id; - if (FAILED(current_default_dev->GetId(¤t_default_id)) || !current_default_id) { - return false; - } - - return device_id == current_default_id.get(); - } - - static std::mutex &pending_restore_mutex_ref() { - static std::mutex mutex; - return mutex; - } - - static std::jthread &pending_restore_thread_ref() { - static std::jthread thread; - return thread; - } - - static void cancel_pending_restore_task() { - std::jthread old_thread; - { - std::scoped_lock lock(pending_restore_mutex_ref()); - old_thread = std::move(pending_restore_thread_ref()); - } - } - - static void start_pending_restore_task(const std::wstring &steam_device_id, const std::wstring &preferred_id) { - std::jthread old_thread; - { - std::scoped_lock lock(pending_restore_mutex_ref()); - old_thread = std::move(pending_restore_thread_ref()); - // Run on a process-scoped worker with its own audio_control_t. The - // caller's audio_control_t is destroyed shortly after teardown returns, - // so the worker cannot safely capture `this`. - pending_restore_thread_ref() = std::jthread([steam_device_id, preferred_id](std::stop_token stop_token) { - co_init_t co_init; - audio_control_t restore_control; - if (restore_control.init() != 0) { - return; - } - restore_control.run_pending_restore_task(stop_token, steam_device_id, preferred_id); - }); - } - } - - void run_pending_restore_task(std::stop_token stop_token, const std::wstring &steam_device_id, const std::wstring &preferred_id) { - bool try_preferred_restore = !preferred_id.empty() && preferred_id != steam_device_id; - bool retry_fallback_reset = true; - if (try_preferred_restore) { - remember_pending_preferred_restore(preferred_id, steam_device_id); - } - - device_arrival_notification_t arrival_notifier(steam_device_id); - HANDLE cancel_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (!cancel_event) { - BOOST_LOG(warning) << "Failed to create background restore cancellation event"sv; - } - auto cancel_event_guard = util::fail_guard([&]() { - if (cancel_event) { - CloseHandle(cancel_event); - } - }); - std::optional>> stop_callback; - if (cancel_event) { - stop_callback.emplace(stop_token, [cancel_event]() { - SetEvent(cancel_event); - }); - } - - auto reg_status = device_enum->RegisterEndpointNotificationCallback(&arrival_notifier); - const bool have_notifications = SUCCEEDED(reg_status); - if (!have_notifications) { - BOOST_LOG(warning) << "Failed to register device arrival notification for background restore: "sv - << util::hex(reg_status).to_string_view(); - } - auto unreg_guard = util::fail_guard([&]() { - if (have_notifications) { - device_enum->UnregisterEndpointNotificationCallback(&arrival_notifier); - } - }); - - if (try_preferred_restore) { - BOOST_LOG(info) << "Waiting in background to restore the original default audio device"sv; - } else { - BOOST_LOG(info) << "Waiting in background for a non-Steam audio device to appear"sv; - } - - while (!stop_token.stop_requested()) { - if (try_preferred_restore) { - auto preferred_result = try_restore_preferred(preferred_id); - if (preferred_result == reset_result_e::success) { - return; - } - if (preferred_result == reset_result_e::fatal) { - clear_pending_preferred_restore(preferred_id); - return; - } - - arrival_notifier.wait(cancel_event, 1000); - continue; - } - - if (retry_fallback_reset && is_default_device(steam_device_id)) { - auto fallback_result = try_reset_from_steam(steam_device_id); - if (fallback_result == reset_result_e::fatal) { - return; - } - if (fallback_result == reset_result_e::success && !try_preferred_restore) { - return; - } - if (fallback_result == reset_result_e::no_device) { - retry_fallback_reset = false; - } - } else if (retry_fallback_reset && !try_preferred_restore) { - return; - } - - if (arrival_notifier.wait(cancel_event, 1000)) { - retry_fallback_reset = true; - } - } - } - - void reset_default_device_impl(bool wait_for_device, const std::wstring &preferred_id) { - cancel_pending_restore_task(); - - auto matched_steam = find_device_id(match_steam_speakers()); - if (!matched_steam) { - return; - } - auto steam_device_id = matched_steam->second; - - // If the user already switched away from Steam speakers, leave the newer - // default alone instead of restoring the previously recorded endpoint. - if (!is_default_device(steam_device_id)) { - clear_pending_preferred_restore(); - return; - } - - // Avoid restoring back to Steam speakers if that's somehow what got - // recorded as the original host sink. - std::wstring effective_preferred_id = preferred_id; - if (effective_preferred_id.empty() || effective_preferred_id == steam_device_id) { - auto pending_preferred_id = pending_preferred_restore_id(); - if (pending_preferred_id) { - effective_preferred_id = *pending_preferred_id; - } - } - bool try_preferred_restore = !effective_preferred_id.empty() && effective_preferred_id != steam_device_id; - - if (try_preferred_restore) { - remember_pending_preferred_restore(effective_preferred_id, steam_device_id); - - auto result = try_restore_preferred(effective_preferred_id); - if (result == reset_result_e::success) { - return; - } - if (result == reset_result_e::fatal) { - clear_pending_preferred_restore(effective_preferred_id); - } else if (wait_for_device) { - start_pending_restore_task(steam_device_id, effective_preferred_id); - return; - } else { - return; - } - } - - auto result = try_reset_from_steam(steam_device_id); - if (result == reset_result_e::success) { - if (try_preferred_restore && wait_for_device) { - start_pending_restore_task(steam_device_id, preferred_id); - } - return; - } - if (result == reset_result_e::fatal) { - return; - } - - if (!wait_for_device) { - return; - } - - start_pending_restore_task(steam_device_id, {}); - } - - enum class reset_result_e { - success, ///< A non-Steam device was set as default - no_device, ///< No non-Steam device is available yet (retriable) - fatal, ///< Unrecoverable failure (do not retry) - }; - - /** - * @brief Attempts to set a specific device as the default for all roles. - * Used to restore the user's original default device after a streaming - * session ends. Verifies the device is currently active before touching the - * policy so we don't bind to a missing endpoint. - * @param preferred_id Endpoint device_id of the device to restore. - * @return success if the device was restored, no_device if the device isn't - * active right now, fatal if the policy call rejected it. - */ - reset_result_e try_restore_preferred(const std::wstring &preferred_id) { - auto match_list = preferred_device_match_list(preferred_id); - if (!match_list) { - return reset_result_e::no_device; - } - - auto matched = find_device_id(*match_list); - if (!matched) { - return reset_result_e::no_device; - } - - const auto &resolved_id = matched->second; - - int failure = 0; - for (int x = 0; x < (int) ERole_enum_count; ++x) { - auto hr = policy->SetDefaultEndpoint(resolved_id.c_str(), (ERole) x); - if (FAILED(hr)) { - BOOST_LOG(warning) << "Couldn't restore preferred audio endpoint for role ["sv << x - << "]: 0x"sv << util::hex(hr).to_string_view(); - ++failure; - } - } - - if (failure) { - return reset_result_e::fatal; - } - - if (resolved_id != preferred_id) { - BOOST_LOG(info) << "Restored original default audio device via re-enumerated endpoint"sv; - } else { - BOOST_LOG(info) << "Restored original default audio device"sv; - } - clear_pending_preferred_restore(preferred_id); - return reset_result_e::success; - } - - /** - * @brief Attempts to move the default audio device away from Steam Streaming Speakers. - * Temporarily disables Steam speakers so the OS picks another default, - * then re-enables them and confirms the new default. - * @param steam_device_id The device ID of Steam Streaming Speakers. - * @return Result indicating success, retriable failure, or fatal failure. - */ - reset_result_e try_reset_from_steam(const std::wstring &steam_device_id) { - // Disable Steam speakers temporarily to let the OS pick a new default - auto hr = policy->SetEndpointVisibility(steam_device_id.c_str(), FALSE); - if (FAILED(hr)) { - BOOST_LOG(warning) << "Failed to disable Steam audio device: "sv << util::hex(hr).to_string_view(); - return reset_result_e::fatal; - } - - auto new_default_dev = default_device(device_enum); - - // Re-enable Steam speakers - hr = policy->SetEndpointVisibility(steam_device_id.c_str(), TRUE); - if (FAILED(hr)) { - BOOST_LOG(warning) << "Failed to enable Steam audio device: "sv << util::hex(hr).to_string_view(); - return reset_result_e::fatal; - } - - if (!new_default_dev) { - return reset_result_e::no_device; - } - - audio::wstring_t new_default_id; - new_default_dev->GetId(&new_default_id); - - int failure = 0; - for (int x = 0; x < (int) ERole_enum_count; ++x) { - auto status = policy->SetDefaultEndpoint(new_default_id.get(), (ERole) x); - if (FAILED(status)) { - BOOST_LOG(warning) << "Couldn't set new default audio endpoint for role ["sv << x << "]: 0x"sv << util::hex(status).to_string_view(); - ++failure; - } - } - - if (failure) { - return reset_result_e::fatal; - } - - BOOST_LOG(info) << "Successfully reset default audio device"sv; - return reset_result_e::success; - } - - public: - - /** - * @brief Installs the Steam Streaming Speakers driver, if present. - * @return `true` if installation was successful. - */ - bool install_steam_audio_drivers() { -#ifdef STEAM_DRIVER_SUBDIR - // MinGW's libnewdev.a is missing DiInstallDriverW() even though the headers have it, - // so we have to load it at runtime. It's Vista or later, so it will always be available. - auto newdev = LoadLibraryExW(L"newdev.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); - if (!newdev) { - BOOST_LOG(error) << "newdev.dll failed to load"sv; - return false; - } - auto fg = util::fail_guard([newdev]() { - FreeLibrary(newdev); - }); - - auto fn_DiInstallDriverW = (decltype(DiInstallDriverW) *) GetProcAddress(newdev, "DiInstallDriverW"); - if (!fn_DiInstallDriverW) { - BOOST_LOG(error) << "DiInstallDriverW() is missing"sv; - return false; - } - - // Get the current default audio device (if present) - auto old_default_dev = default_device(device_enum); - - // Install the Steam Streaming Speakers driver - WCHAR driver_path[MAX_PATH] = {}; - ExpandEnvironmentStringsW(STEAM_AUDIO_DRIVER_PATH, driver_path, ARRAYSIZE(driver_path)); - if (fn_DiInstallDriverW(nullptr, driver_path, 0, nullptr)) { - BOOST_LOG(info) << "Successfully installed Steam Streaming Speakers"sv; - - // Wait for 5 seconds to allow the audio subsystem to reconfigure things before - // modifying the default audio device or enumerating devices again. - Sleep(5000); - - // If there was a previous default device, restore that original device as the - // default output device just in case installing the new one changed it. - if (old_default_dev) { - audio::wstring_t old_default_id; - old_default_dev->GetId(&old_default_id); - - for (int x = 0; x < (int) ERole_enum_count; ++x) { - policy->SetDefaultEndpoint(old_default_id.get(), (ERole) x); - } - } - - return true; - } else { - auto err = GetLastError(); - switch (err) { - case ERROR_ACCESS_DENIED: - BOOST_LOG(warning) << "Administrator privileges are required to install Steam Streaming Speakers"sv; - break; - case ERROR_FILE_NOT_FOUND: - case ERROR_PATH_NOT_FOUND: - BOOST_LOG(info) << "Steam audio drivers not found. This is expected if you don't have Steam installed."sv; - break; - default: - BOOST_LOG(warning) << "Failed to install Steam audio drivers: "sv << err; - break; - } - - return false; - } -#else - BOOST_LOG(warning) << "Unable to install Steam Streaming Speakers on unknown architecture"sv; - return false; -#endif - } - - int init() { - auto status = CoCreateInstance( - CLSID_CPolicyConfigClient, - nullptr, - CLSCTX_ALL, - IID_IPolicyConfig, - (void **) &policy - ); - - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't create audio policy config: [0x"sv << util::hex(status).to_string_view() << ']'; - - return -1; - } - - status = CoCreateInstance( - CLSID_MMDeviceEnumerator, - nullptr, - CLSCTX_ALL, - IID_IMMDeviceEnumerator, - (void **) &device_enum - ); - - if (FAILED(status)) { - BOOST_LOG(error) << "Couldn't create Device Enumerator: [0x"sv << util::hex(status).to_string_view() << ']'; - return -1; - } - - return 0; - } - - ~audio_control_t() override = default; - - policy_t policy; - audio::device_enum_t device_enum; - std::string assigned_sink; - }; -} // namespace platf::audio - -namespace platf { - - // It's not big enough to justify it's own source file :/ - namespace dxgi { - int init(); - } - - std::unique_ptr audio_control() { - auto control = std::make_unique(); - - if (control->init()) { - return nullptr; - } - - // Install Steam Streaming Speakers if needed. We do this during audio_control() to ensure - // the sink information returned includes the new Steam Streaming Speakers device. - if (config::audio.install_steam_drivers && !control->find_device_id(control->match_steam_speakers())) { - // This is best effort. Don't fail if it doesn't work. - control->install_steam_audio_drivers(); - } - - return control; - } - - std::unique_ptr init() { - if (dxgi::init()) { - return nullptr; - } - - // Initialize COM - auto co_init = std::make_unique(); - - // If Steam Streaming Speakers are currently the default audio device, - // change the default to something else (if another device is available). - audio::audio_control_t audio_ctrl; - if (audio_ctrl.init() == 0) { - audio_ctrl.reset_default_device_no_wait(); - } - - return co_init; - } -} // namespace platf +/** + * @file src/platform/windows/audio.cpp + * @brief Definitions for Windows audio capture. + */ +#define INITGUID + +// standard includes +#include +#include +#include +#include +#include + +// platform includes +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include "vibepollo_vmic.h" +#include "mic_write.h" +#include "misc.h" +#include "src/audio.h" +#include "src/config.h" +#include "src/logging.h" +#include "src/platform/common.h" + +// Must be the last included file +// clang-format off +#include "PolicyConfig.h" +// clang-format on + +DEFINE_PROPERTYKEY(PKEY_Device_DeviceDesc, 0xa45c254e, 0xdf1c, 0x4efd, 0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0, 2); // DEVPROP_TYPE_STRING +DEFINE_PROPERTYKEY(PKEY_Device_FriendlyName, 0xa45c254e, 0xdf1c, 0x4efd, 0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0, 14); // DEVPROP_TYPE_STRING +DEFINE_PROPERTYKEY(PKEY_DeviceInterface_FriendlyName, 0x026e516e, 0xb814, 0x414b, 0x83, 0xcd, 0x85, 0x6d, 0x6f, 0xef, 0x48, 0x22, 2); + +#if defined(__x86_64) || defined(__x86_64__) || defined(__amd64) || defined(__amd64__) || defined(_M_AMD64) + #define STEAM_DRIVER_SUBDIR L"x64" +#else + #warning No known Steam audio driver for this architecture +#endif + +namespace { + + constexpr auto SAMPLE_RATE = 48000; + constexpr auto STEAM_SPEAKERS_DRIVER_PATH = L"%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\" STEAM_DRIVER_SUBDIR L"\\SteamStreamingSpeakers.inf"; + constexpr auto STEAM_MICROPHONE_DRIVER_PATH = L"%CommonProgramFiles(x86)%\\Steam\\drivers\\Windows10\\" STEAM_DRIVER_SUBDIR L"\\SteamStreamingMicrophone.inf"; + + constexpr auto waveformat_mask_stereo = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; + + constexpr auto waveformat_mask_surround51_with_backspeakers = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT | + SPEAKER_FRONT_CENTER | SPEAKER_LOW_FREQUENCY | + SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT; + + constexpr auto waveformat_mask_surround51_with_sidespeakers = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT | + SPEAKER_FRONT_CENTER | SPEAKER_LOW_FREQUENCY | + SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT; + + constexpr auto waveformat_mask_surround71 = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT | + SPEAKER_FRONT_CENTER | SPEAKER_LOW_FREQUENCY | + SPEAKER_BACK_LEFT | SPEAKER_BACK_RIGHT | + SPEAKER_SIDE_LEFT | SPEAKER_SIDE_RIGHT; + + enum class sample_format_e { + f32, + s32, + s24in32, + s24, + s16, + _size, + }; + + constexpr WAVEFORMATEXTENSIBLE create_waveformat(sample_format_e sample_format, WORD channel_count, DWORD channel_mask) { + WAVEFORMATEXTENSIBLE waveformat = {}; + + switch (sample_format) { + default: + case sample_format_e::f32: + waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; + waveformat.Format.wBitsPerSample = 32; + waveformat.Samples.wValidBitsPerSample = 32; + break; + + case sample_format_e::s32: + waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; + waveformat.Format.wBitsPerSample = 32; + waveformat.Samples.wValidBitsPerSample = 32; + break; + + case sample_format_e::s24in32: + waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; + waveformat.Format.wBitsPerSample = 32; + waveformat.Samples.wValidBitsPerSample = 24; + break; + + case sample_format_e::s24: + waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; + waveformat.Format.wBitsPerSample = 24; + waveformat.Samples.wValidBitsPerSample = 24; + break; + + case sample_format_e::s16: + waveformat.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; + waveformat.Format.wBitsPerSample = 16; + waveformat.Samples.wValidBitsPerSample = 16; + break; + } + + static_assert((int) sample_format_e::_size == 5, "Unrecognized sample_format_e"); + + waveformat.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + waveformat.Format.nChannels = channel_count; + waveformat.Format.nSamplesPerSec = SAMPLE_RATE; + + waveformat.Format.nBlockAlign = waveformat.Format.nChannels * waveformat.Format.wBitsPerSample / 8; + waveformat.Format.nAvgBytesPerSec = waveformat.Format.nSamplesPerSec * waveformat.Format.nBlockAlign; + waveformat.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + + waveformat.dwChannelMask = channel_mask; + + return waveformat; + } + + using virtual_sink_waveformats_t = std::vector; + + /** + * @brief List of supported waveformats for an N-channel virtual audio device + * @tparam channel_count Number of virtual audio channels + * @returns std::vector + * @note The list of virtual formats returned are sorted in preference order and the first valid + * format will be used. All bits-per-sample options are listed because we try to match + * this to the default audio device. See also: set_format() below. + */ + template + virtual_sink_waveformats_t create_virtual_sink_waveformats() { + if constexpr (channel_count == 2) { + auto channel_mask = waveformat_mask_stereo; + // The 32-bit formats are a lower priority for stereo because using one will disable Dolby/DTS + // spatial audio mode if the user enabled it on the Steam speaker. + return { + create_waveformat(sample_format_e::s24in32, channel_count, channel_mask), + create_waveformat(sample_format_e::s24, channel_count, channel_mask), + create_waveformat(sample_format_e::s16, channel_count, channel_mask), + create_waveformat(sample_format_e::f32, channel_count, channel_mask), + create_waveformat(sample_format_e::s32, channel_count, channel_mask), + }; + } else if (channel_count == 6) { + auto channel_mask1 = waveformat_mask_surround51_with_backspeakers; + auto channel_mask2 = waveformat_mask_surround51_with_sidespeakers; + return { + create_waveformat(sample_format_e::f32, channel_count, channel_mask1), + create_waveformat(sample_format_e::f32, channel_count, channel_mask2), + create_waveformat(sample_format_e::s32, channel_count, channel_mask1), + create_waveformat(sample_format_e::s32, channel_count, channel_mask2), + create_waveformat(sample_format_e::s24in32, channel_count, channel_mask1), + create_waveformat(sample_format_e::s24in32, channel_count, channel_mask2), + create_waveformat(sample_format_e::s24, channel_count, channel_mask1), + create_waveformat(sample_format_e::s24, channel_count, channel_mask2), + create_waveformat(sample_format_e::s16, channel_count, channel_mask1), + create_waveformat(sample_format_e::s16, channel_count, channel_mask2), + }; + } else if (channel_count == 8) { + auto channel_mask = waveformat_mask_surround71; + return { + create_waveformat(sample_format_e::f32, channel_count, channel_mask), + create_waveformat(sample_format_e::s32, channel_count, channel_mask), + create_waveformat(sample_format_e::s24in32, channel_count, channel_mask), + create_waveformat(sample_format_e::s24, channel_count, channel_mask), + create_waveformat(sample_format_e::s16, channel_count, channel_mask), + }; + } + } + + std::string waveformat_to_pretty_string(const WAVEFORMATEXTENSIBLE &waveformat) { + std::string result = waveformat.SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT ? "F" : + waveformat.SubFormat == KSDATAFORMAT_SUBTYPE_PCM ? "S" : + "UNKNOWN"; + + result += std::format("{} {} ", static_cast(waveformat.Samples.wValidBitsPerSample), static_cast(waveformat.Format.nSamplesPerSec)); + + switch (waveformat.dwChannelMask) { + case waveformat_mask_stereo: + result += "2.0"; + break; + + case waveformat_mask_surround51_with_backspeakers: + result += "5.1"; + break; + + case waveformat_mask_surround51_with_sidespeakers: + result += "5.1 (sidespeakers)"; + break; + + case waveformat_mask_surround71: + result += "7.1"; + break; + + default: + result += std::format("{} channels (unrecognized)", static_cast(waveformat.Format.nChannels)); + break; + } + + return result; + } + + std::optional normalize_mic_backend_name(const std::string &backend_name) { + if (backend_name.empty() || backend_name == "steam_streaming_microphone") { + return "steam_streaming_microphone"; + } + + BOOST_LOG(error) << "Windows microphone backend ["sv << backend_name + << "] is not supported in Apollo Mic. Use [steam_streaming_microphone]."; + return std::nullopt; + } + +} // namespace + +using namespace std::literals; + +namespace platf::audio { + template + void Release(T *p) { + p->Release(); + } + + template + void co_task_free(T *p) { + CoTaskMemFree((LPVOID) p); + } + + using device_enum_t = util::safe_ptr>; + using device_t = util::safe_ptr>; + using collection_t = util::safe_ptr>; + using audio_client_t = util::safe_ptr>; + using audio_capture_t = util::safe_ptr>; + using wave_format_t = util::safe_ptr>; + using wstring_t = util::safe_ptr>; + using handle_t = util::safe_ptr_v2; + using policy_t = util::safe_ptr>; + using prop_t = util::safe_ptr>; + + class co_init_t: public deinit_t { + public: + co_init_t() { + CoInitializeEx(nullptr, COINIT_MULTITHREADED | COINIT_SPEED_OVER_MEMORY); + } + + ~co_init_t() override { + CoUninitialize(); + } + }; + + class prop_var_t { + public: + prop_var_t() { + PropVariantInit(&prop); + } + + ~prop_var_t() { + PropVariantClear(&prop); + } + + PROPVARIANT prop; + }; + + struct format_t { + WORD channel_count; + std::string name; + int capture_waveformat_channel_mask; + virtual_sink_waveformats_t virtual_sink_waveformats; + }; + + const std::array formats = { + format_t { + 2, + "Stereo", + waveformat_mask_stereo, + create_virtual_sink_waveformats<2>(), + }, + format_t { + 6, + "Surround 5.1", + waveformat_mask_surround51_with_backspeakers, + create_virtual_sink_waveformats<6>(), + }, + format_t { + 8, + "Surround 7.1", + waveformat_mask_surround71, + create_virtual_sink_waveformats<8>(), + }, + }; + + audio_client_t make_audio_client(device_t &device, const format_t &format) { + audio_client_t audio_client; + auto status = device->Activate( + IID_IAudioClient, + CLSCTX_ALL, + nullptr, + (void **) &audio_client + ); + + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't activate Device: [0x"sv << util::hex(status).to_string_view() << ']'; + + return nullptr; + } + + WAVEFORMATEXTENSIBLE capture_waveformat = + create_waveformat(sample_format_e::f32, format.channel_count, format.capture_waveformat_channel_mask); + + { + wave_format_t mixer_waveformat; + status = audio_client->GetMixFormat(&mixer_waveformat); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't get mix format for audio device: [0x"sv << util::hex(status).to_string_view() << ']'; + return nullptr; + } + + // Prefer the native channel layout of captured audio device when channel counts match + if (mixer_waveformat->nChannels == format.channel_count && + mixer_waveformat->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + mixer_waveformat->cbSize >= 22) { + auto waveformatext_pointer = reinterpret_cast(mixer_waveformat.get()); + capture_waveformat.dwChannelMask = waveformatext_pointer->dwChannelMask; + } + + BOOST_LOG(info) << "Audio mixer format is "sv << mixer_waveformat->wBitsPerSample << "-bit, "sv + << mixer_waveformat->nSamplesPerSec << " Hz, "sv + << ((mixer_waveformat->nSamplesPerSec != 48000) ? "will be resampled to 48000 by Windows"sv : "no resampling needed"sv); + } + + status = audio_client->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK | AUDCLNT_STREAMFLAGS_EVENTCALLBACK | + AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, // Enable automatic resampling to 48 KHz + 0, + 0, + (LPWAVEFORMATEX) &capture_waveformat, + nullptr + ); + + if (status) { + BOOST_LOG(error) << "Couldn't initialize audio client for ["sv << format.name << "]: [0x"sv << util::hex(status).to_string_view() << ']'; + return nullptr; + } + + BOOST_LOG(info) << "Audio capture format is "sv << logging::bracket(waveformat_to_pretty_string(capture_waveformat)); + + return audio_client; + } + + device_t default_device(device_enum_t &device_enum) { + device_t device; + HRESULT status; + status = device_enum->GetDefaultAudioEndpoint( + eRender, + eConsole, + &device + ); + + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't get default audio endpoint [0x"sv << util::hex(status).to_string_view() << ']'; + + return nullptr; + } + + return device; + } + + /** + * @brief Lightweight IMMNotificationClient that signals a Win32 Event + * when a non-ignored audio device becomes active or is added. + * Used by reset_default_device() to wait for device arrival. + */ + class device_arrival_notification_t: public ::IMMNotificationClient { + public: + /** + * @param ignored_device_id Device ID to ignore in notifications (e.g., Steam Streaming Speakers). + */ + explicit device_arrival_notification_t(const std::wstring &ignored_device_id): + ignored_id(ignored_device_id) { + arrival_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!arrival_event) { + BOOST_LOG(warning) << "Failed to create device arrival event"sv; + } + } + + ~device_arrival_notification_t() { + if (arrival_event) { + CloseHandle(arrival_event); + } + } + + ULONG STDMETHODCALLTYPE AddRef() { return 1; } + ULONG STDMETHODCALLTYPE Release() { return 1; } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, VOID **ppvInterface) { + if (IID_IUnknown == riid) { + AddRef(); + *ppvInterface = (IUnknown *) this; + return S_OK; + } else if (__uuidof(IMMNotificationClient) == riid) { + AddRef(); + *ppvInterface = (IMMNotificationClient *) this; + return S_OK; + } else { + *ppvInterface = nullptr; + return E_NOINTERFACE; + } + } + + HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged(EDataFlow, ERole, LPCWSTR) { return S_OK; } + HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR) { return S_OK; } + HRESULT STDMETHODCALLTYPE OnPropertyValueChanged(LPCWSTR, const PROPERTYKEY) { return S_OK; } + + HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR pwstrDeviceId) { + if (arrival_event && !is_ignored(pwstrDeviceId)) { + SetEvent(arrival_event); + } + return S_OK; + } + + HRESULT STDMETHODCALLTYPE OnDeviceStateChanged(LPCWSTR pwstrDeviceId, DWORD dwNewState) { + if (dwNewState == DEVICE_STATE_ACTIVE && arrival_event && !is_ignored(pwstrDeviceId)) { + SetEvent(arrival_event); + } + return S_OK; + } + + /** + * @brief Wait for the arrival event to be signaled. + * @param timeout_ms Maximum time to wait in milliseconds. + * @return true if signaled, false on timeout. + */ + bool wait(HANDLE cancel_event, DWORD timeout_ms) { + if (!arrival_event) { + if (cancel_event) { + WaitForSingleObject(cancel_event, timeout_ms); + } else { + Sleep(timeout_ms); + } + return false; + } + + HANDLE wait_handles[2] {arrival_event, cancel_event}; + DWORD handle_count = cancel_event ? 2 : 1; + auto result = WaitForMultipleObjects(handle_count, wait_handles, FALSE, timeout_ms); + if (result == WAIT_OBJECT_0) { + ResetEvent(arrival_event); + return true; + } + return false; + } + + private: + bool is_ignored(LPCWSTR device_id) const { + return device_id && !ignored_id.empty() && ignored_id == device_id; + } + + HANDLE arrival_event = nullptr; + std::wstring ignored_id; + }; + + class audio_notification_t: public ::IMMNotificationClient { + public: + audio_notification_t() { + } + + // IUnknown implementation (unused by IMMDeviceEnumerator) + ULONG STDMETHODCALLTYPE AddRef() { + return 1; + } + + ULONG STDMETHODCALLTYPE Release() { + return 1; + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, VOID **ppvInterface) { + if (IID_IUnknown == riid) { + AddRef(); + *ppvInterface = (IUnknown *) this; + return S_OK; + } else if (__uuidof(IMMNotificationClient) == riid) { + AddRef(); + *ppvInterface = (IMMNotificationClient *) this; + return S_OK; + } else { + *ppvInterface = nullptr; + return E_NOINTERFACE; + } + } + + // IMMNotificationClient + HRESULT STDMETHODCALLTYPE OnDefaultDeviceChanged(EDataFlow flow, ERole role, LPCWSTR pwstrDeviceId) { + if (flow == eRender) { + default_render_device_changed_flag.store(true); + } + return S_OK; + } + + HRESULT STDMETHODCALLTYPE OnDeviceAdded(LPCWSTR pwstrDeviceId) { + return S_OK; + } + + HRESULT STDMETHODCALLTYPE OnDeviceRemoved(LPCWSTR pwstrDeviceId) { + return S_OK; + } + + HRESULT STDMETHODCALLTYPE OnDeviceStateChanged( + LPCWSTR pwstrDeviceId, + DWORD dwNewState + ) { + return S_OK; + } + + HRESULT STDMETHODCALLTYPE OnPropertyValueChanged( + LPCWSTR pwstrDeviceId, + const PROPERTYKEY key + ) { + return S_OK; + } + + /** + * @brief Checks if the default rendering device changed and resets the change flag + * @return `true` if the device changed since last call + */ + bool check_default_render_device_changed() { + return default_render_device_changed_flag.exchange(false); + } + + private: + std::atomic_bool default_render_device_changed_flag; + }; + + class mic_wasapi_t: public mic_t { + public: + capture_e sample(std::vector &sample_out) override { + auto sample_size = sample_out.size(); + + // Refill the sample buffer if needed + while (sample_buf_pos - std::begin(sample_buf) < sample_size) { + auto capture_result = _fill_buffer(); + if (capture_result != capture_e::ok) { + return capture_result; + } + } + + // Fill the output buffer with samples + std::copy_n(std::begin(sample_buf), sample_size, std::begin(sample_out)); + + // Move any excess samples to the front of the buffer + std::move(&sample_buf[sample_size], sample_buf_pos, std::begin(sample_buf)); + sample_buf_pos -= sample_size; + + return capture_e::ok; + } + + int init(std::uint32_t sample_rate, std::uint32_t frame_size, std::uint32_t channels_out) { + audio_event.reset(CreateEventA(nullptr, FALSE, FALSE, nullptr)); + if (!audio_event) { + BOOST_LOG(error) << "Couldn't create Event handle"sv; + + return -1; + } + + HRESULT status; + + status = CoCreateInstance( + CLSID_MMDeviceEnumerator, + nullptr, + CLSCTX_ALL, + IID_IMMDeviceEnumerator, + (void **) &device_enum + ); + + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't create Device Enumerator [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + status = device_enum->RegisterEndpointNotificationCallback(&endpt_notification); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't register endpoint notification [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + auto device = default_device(device_enum); + if (!device) { + return -1; + } + + for (const auto &format : formats) { + if (format.channel_count != channels_out) { + BOOST_LOG(debug) << "Skipping audio format ["sv << format.name << "] with channel count ["sv + << format.channel_count << " != "sv << channels_out << ']'; + continue; + } + + BOOST_LOG(debug) << "Trying audio format ["sv << format.name << ']'; + audio_client = make_audio_client(device, format); + + if (audio_client) { + BOOST_LOG(debug) << "Found audio format ["sv << format.name << ']'; + channels = channels_out; + break; + } + } + + if (!audio_client) { + BOOST_LOG(error) << "Couldn't find supported format for audio"sv; + return -1; + } + + REFERENCE_TIME default_latency; + audio_client->GetDevicePeriod(&default_latency, nullptr); + default_latency_ms = default_latency / 1000; + + std::uint32_t frames; + status = audio_client->GetBufferSize(&frames); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't acquire the number of audio frames [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + // *2 --> needs to fit double + sample_buf = util::buffer_t {std::max(frames, frame_size) * 2 * channels_out}; + sample_buf_pos = std::begin(sample_buf); + + status = audio_client->GetService(IID_IAudioCaptureClient, (void **) &audio_capture); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't initialize audio capture client [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + status = audio_client->SetEventHandle(audio_event.get()); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't set event handle [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + { + DWORD task_index = 0; + mmcss_task_handle = AvSetMmThreadCharacteristics("Pro Audio", &task_index); + if (!mmcss_task_handle) { + BOOST_LOG(error) << "Couldn't associate audio capture thread with Pro Audio MMCSS task [0x" << util::hex(GetLastError()).to_string_view() << ']'; + } + } + + status = audio_client->Start(); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't start recording [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + return 0; + } + + ~mic_wasapi_t() override { + if (device_enum) { + device_enum->UnregisterEndpointNotificationCallback(&endpt_notification); + } + + if (audio_client) { + audio_client->Stop(); + } + + if (mmcss_task_handle) { + AvRevertMmThreadCharacteristics(mmcss_task_handle); + } + } + + private: + capture_e _fill_buffer() { + HRESULT status; + + // Total number of samples + struct sample_aligned_t { + std::uint32_t uninitialized; + float *samples; + } sample_aligned; + + // number of samples / number of channels + struct block_aligned_t { + std::uint32_t audio_sample_size; + } block_aligned; + + // Check if the default audio device has changed + if (endpt_notification.check_default_render_device_changed()) { + // Invoke the audio_control_t's callback if it wants one + if (default_endpt_changed_cb) { + (*default_endpt_changed_cb)(); + } + + // Reinitialize to pick up the new default device + return capture_e::reinit; + } + + status = WaitForSingleObjectEx(audio_event.get(), default_latency_ms, FALSE); + switch (status) { + case WAIT_OBJECT_0: + break; + case WAIT_TIMEOUT: + return capture_e::timeout; + default: + BOOST_LOG(error) << "Couldn't wait for audio event: [0x"sv << util::hex(status).to_string_view() << ']'; + return capture_e::error; + } + + std::uint32_t packet_size {}; + for ( + status = audio_capture->GetNextPacketSize(&packet_size); + SUCCEEDED(status) && packet_size > 0; + status = audio_capture->GetNextPacketSize(&packet_size) + ) { + DWORD buffer_flags; + status = audio_capture->GetBuffer( + (BYTE **) &sample_aligned.samples, + &block_aligned.audio_sample_size, + &buffer_flags, + nullptr, + nullptr + ); + + switch (status) { + case S_OK: + break; + case AUDCLNT_E_DEVICE_INVALIDATED: + return capture_e::reinit; + default: + BOOST_LOG(error) << "Couldn't capture audio [0x"sv << util::hex(status).to_string_view() << ']'; + return capture_e::error; + } + + if (buffer_flags & AUDCLNT_BUFFERFLAGS_DATA_DISCONTINUITY) { + BOOST_LOG(debug) << "Audio capture signaled buffer discontinuity"; + } + + sample_aligned.uninitialized = std::end(sample_buf) - sample_buf_pos; + auto n = std::min(sample_aligned.uninitialized, block_aligned.audio_sample_size * channels); + + if (n < block_aligned.audio_sample_size * channels) { + BOOST_LOG(warning) << "Audio capture buffer overflow"; + } + + if (buffer_flags & AUDCLNT_BUFFERFLAGS_SILENT) { + std::fill_n(sample_buf_pos, n, 0); + } else { + std::copy_n(sample_aligned.samples, n, sample_buf_pos); + } + + sample_buf_pos += n; + + audio_capture->ReleaseBuffer(block_aligned.audio_sample_size); + } + + if (status == AUDCLNT_E_DEVICE_INVALIDATED) { + return capture_e::reinit; + } + + if (FAILED(status)) { + return capture_e::error; + } + + return capture_e::ok; + } + + public: + handle_t audio_event; + + device_enum_t device_enum; + device_t device; + audio_client_t audio_client; + audio_capture_t audio_capture; + + audio_notification_t endpt_notification; + std::optional> default_endpt_changed_cb; + + REFERENCE_TIME default_latency_ms; + + util::buffer_t sample_buf; + float *sample_buf_pos; + int channels; + + HANDLE mmcss_task_handle = nullptr; + }; + + class audio_control_t: public ::platf::audio_control_t { + public: + std::optional sink_info() override { + sink_t sink; + + // Fill host sink name with the device_id of the current default audio device. + { + auto device = default_device(device_enum); + if (!device) { + return std::nullopt; + } + + audio::wstring_t id; + device->GetId(&id); + + std::wstring host_id = id.get(); + auto matched_steam = find_device_id(match_steam_speakers()); + if (matched_steam && host_id == matched_steam->second) { + auto pending_preferred_id = pending_preferred_restore_id(); + if (pending_preferred_id) { + host_id = *pending_preferred_id; + } + } else { + clear_pending_preferred_restore(); + } + + sink.host = to_utf8(host_id.c_str()); + // Pre-populate the restore-cache so we have property snapshots even if + // the device disappears before reset_default_device runs. + (void) preferred_device_match_list(host_id); + } + + // Prepare to search for the device_id of the virtual audio sink device, + // this device can be either user-configured or + // the Steam Streaming Speakers we use by default. + match_fields_list_t match_list; + if (config::audio.virtual_sink.empty()) { + match_list = match_steam_speakers(); + } else { + match_list = match_all_fields(from_utf8(config::audio.virtual_sink)); + } + + // Search for the virtual audio sink device currently present in the system. + auto matched = find_device_id(match_list); + if (matched) { + // Prepare to fill virtual audio sink names with device_id. + auto device_id = to_utf8(matched->second); + // Also prepend format name (basically channel layout at the moment) + // because we don't want to extend the platform interface. + sink.null = std::make_optional(sink_t::null_t { + "virtual-"s + formats[0].name + device_id, + "virtual-"s + formats[1].name + device_id, + "virtual-"s + formats[2].name + device_id, + }); + } else if (!config::audio.virtual_sink.empty()) { + BOOST_LOG(warning) << "Couldn't find the specified virtual audio sink " << config::audio.virtual_sink; + } + + return sink; + } + + bool is_sink_available(const std::string &sink) override { + const auto match_list = match_all_fields(from_utf8(sink)); + const auto matched = find_device_id(match_list); + return static_cast(matched); + } + + /** + * @brief Extract virtual audio sink information possibly encoded in the sink name. + * @param sink The sink name + * @return A pair of device_id and format reference if the sink name matches + * our naming scheme for virtual audio sinks, `std::nullopt` otherwise. + */ + std::optional>> extract_virtual_sink_info(const std::string &sink) { + // Encoding format: + // [virtual-(format name)]device_id + std::string current = sink; + auto prefix = "virtual-"sv; + if (current.find(prefix) == 0) { + current = current.substr(prefix.size(), current.size() - prefix.size()); + + for (const auto &format : formats) { + auto &name = format.name; + if (current.find(name) == 0) { + auto device_id = from_utf8(current.substr(name.size(), current.size() - name.size())); + return std::make_pair(device_id, std::reference_wrapper(format)); + } + } + } + + return std::nullopt; + } + + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) override { + auto mic = std::make_unique(); + + if (mic->init(sample_rate, frame_size, channels)) { + return nullptr; + } + + if (config::audio.keep_default) { + // If this is a virtual sink, set a callback that will change the sink back if it's changed + auto virtual_sink_info = extract_virtual_sink_info(assigned_sink); + if (virtual_sink_info) { + mic->default_endpt_changed_cb = [this] { + BOOST_LOG(info) << "Resetting sink to ["sv << assigned_sink << "] after default changed"; + set_sink(assigned_sink); + }; + } + } + + return mic; + } + + int init_mic_redirect_device() override { + if (mic_redirect_device) { + return 0; + } + + auto normalized_backend = normalize_mic_backend_name(config::audio.mic_backend); + if (!normalized_backend) { + ::audio::mic_debug_on_backend_error("Unsupported Windows microphone backend [" + config::audio.mic_backend + "]. Use steam_streaming_microphone."); + active_mic_backend.clear(); + return -1; + } + + config::audio.mic_backend = *normalized_backend; + + auto try_create_device = [this]() { + auto device = std::make_unique(); + if (device->init() != 0) { + return false; + } + + active_mic_backend = std::string {device->backend_id()}; + BOOST_LOG(info) << "Client microphone redirection backend: " << active_mic_backend; + mic_redirect_device = std::move(device); + return true; + }; + + if (try_create_device()) { + return 0; + } + + if (config::audio.install_steam_drivers) { + BOOST_LOG(info) << "Attempting to install missing Steam audio drivers for microphone redirection"sv; + install_steam_audio_drivers(); + if (try_create_device()) { + return 0; + } + } + + BOOST_LOG(warning) << "Client microphone redirection is unavailable because Steam Streaming Microphone is not installed or not accessible. " + << "Install the local Steam audio drivers and use \"Microphone (Steam Streaming Microphone)\" as the host microphone in your applications."; + active_mic_backend.clear(); + return -1; + } + + void release_mic_redirect_device() override { + mic_redirect_device.reset(); + active_mic_backend.clear(); + } + + int write_mic_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) override { + if (!mic_redirect_device) { + BOOST_LOG(warning) << "Client microphone packet rejected before decode because no Windows microphone redirect device is active" + << " [seq=" << sequence_number << ", ts=" << timestamp << ", len=" << len << ']'; + return -1; + } + + return mic_redirect_device->write_data(data, len, sequence_number, timestamp); + } + + /** + * If the requested sink is a virtual sink, meaning no speakers attached to + * the host, then we can seamlessly set the format to stereo and surround sound. + * + * Any virtual sink detected will be prefixed by: + * virtual-(format name) + * If it doesn't contain that prefix, then the format will not be changed + */ + std::optional set_format(const std::string &sink) { + if (sink.empty()) { + return std::nullopt; + } + + auto virtual_sink_info = extract_virtual_sink_info(sink); + + if (!virtual_sink_info) { + // Sink name does not begin with virtual-(format name), hence it's not a virtual sink + // and we don't want to change playback format of the corresponding device. + // Also need to perform matching, sink name is not necessarily device_id in this case. + auto matched = find_device_id(match_all_fields(from_utf8(sink))); + if (matched) { + return matched->second; + } else { + BOOST_LOG(error) << "Couldn't find audio sink " << sink; + return std::nullopt; + } + } + + // When switching to a Steam virtual speaker device, try to retain the bit depth of the + // default audio device. Switching from a 16-bit device to a 24-bit one has been known to + // cause glitches for some users. + int wanted_bits_per_sample = 32; + auto current_default_dev = default_device(device_enum); + if (current_default_dev) { + audio::prop_t prop; + prop_var_t current_device_format; + + if (SUCCEEDED(current_default_dev->OpenPropertyStore(STGM_READ, &prop)) && SUCCEEDED(prop->GetValue(PKEY_AudioEngine_DeviceFormat, ¤t_device_format.prop))) { + auto *format = (WAVEFORMATEXTENSIBLE *) current_device_format.prop.blob.pBlobData; + wanted_bits_per_sample = format->Samples.wValidBitsPerSample; + BOOST_LOG(info) << "Virtual audio device will use "sv << wanted_bits_per_sample << "-bit to match default device"sv; + } + } + + auto &device_id = virtual_sink_info->first; + auto &waveformats = virtual_sink_info->second.get().virtual_sink_waveformats; + for (const auto &waveformat : waveformats) { + // We're using completely undocumented and unlisted API, + // better not pass objects without copying them first. + auto device_id_copy = device_id; + auto waveformat_copy = waveformat; + auto waveformat_copy_pointer = reinterpret_cast(&waveformat_copy); + + if (wanted_bits_per_sample != waveformat.Samples.wValidBitsPerSample) { + continue; + } + + WAVEFORMATEXTENSIBLE p {}; + if (SUCCEEDED(policy->SetDeviceFormat(device_id_copy.c_str(), waveformat_copy_pointer, (WAVEFORMATEX *) &p))) { + BOOST_LOG(info) << "Changed virtual audio sink format to " << logging::bracket(waveformat_to_pretty_string(waveformat)); + return device_id; + } + } + + BOOST_LOG(error) << "Couldn't set virtual audio sink waveformat"; + return std::nullopt; + } + + int set_sink(const std::string &sink) override { + // Cancel any background restore — it would fight us moving to Steam. + cancel_pending_restore_task(); + + auto device_id = set_format(sink); + if (!device_id) { + return -1; + } + + int failure {}; + for (int x = 0; x < (int) ERole_enum_count; ++x) { + auto status = policy->SetDefaultEndpoint(device_id->c_str(), (ERole) x); + if (status) { + // Depending on the format of the string, we could get either of these errors + if (status == HRESULT_FROM_WIN32(ERROR_NOT_FOUND) || status == E_INVALIDARG) { + BOOST_LOG(warning) << "Audio sink not found: "sv << sink; + } else { + BOOST_LOG(warning) << "Couldn't set ["sv << sink << "] to role ["sv << x << "]: 0x"sv << util::hex(status).to_string_view(); + } + + ++failure; + } + } + + // Remember the assigned sink name, so we have it for later if we need to set it + // back after another application changes it + if (!failure) { + assigned_sink = sink; + } + + return failure; + } + + enum class match_field_e { + device_id, ///< Match device_id + device_friendly_name, ///< Match endpoint friendly name + adapter_friendly_name, ///< Match adapter friendly name + device_description, ///< Match endpoint description + }; + + using match_fields_list_t = std::vector>; + using matched_field_t = std::pair; + + audio_control_t::match_fields_list_t match_steam_speakers() { + return { + {match_field_e::adapter_friendly_name, L"Steam Streaming Speakers"} + }; + } + + audio_control_t::match_fields_list_t match_steam_microphone() { + return { + {match_field_e::device_friendly_name, L"Speakers (Steam Streaming Microphone)"}, + {match_field_e::adapter_friendly_name, L"Steam Streaming Microphone"}, + {match_field_e::device_description, L"Steam Streaming Microphone"}, + }; + } + + audio_control_t::match_fields_list_t match_all_fields(const std::wstring &name) { + return { + {match_field_e::device_id, name}, // {0.0.0.00000000}.{29dd7668-45b2-4846-882d-950f55bf7eb8} + {match_field_e::device_friendly_name, name}, // Digital Audio (S/PDIF) (High Definition Audio Device) + {match_field_e::device_description, name}, // Digital Audio (S/PDIF) + {match_field_e::adapter_friendly_name, name}, // High Definition Audio Device + }; + } + + static std::mutex &preferred_restore_cache_mutex_ref() { + static std::mutex mutex; + return mutex; + } + + static std::unordered_map &preferred_restore_cache_ref() { + static std::unordered_map cache; + return cache; + } + + static std::wstring &pending_preferred_restore_id_ref() { + static std::wstring id; + return id; + } + + static std::optional pending_preferred_restore_id() { + std::lock_guard lock {preferred_restore_cache_mutex_ref()}; + const auto &id = pending_preferred_restore_id_ref(); + if (id.empty()) { + return std::nullopt; + } + + return id; + } + + static void remember_pending_preferred_restore(const std::wstring &preferred_id, const std::wstring &steam_device_id) { + if (preferred_id.empty() || preferred_id == steam_device_id) { + return; + } + + std::lock_guard lock {preferred_restore_cache_mutex_ref()}; + pending_preferred_restore_id_ref() = preferred_id; + } + + static void clear_pending_preferred_restore(const std::wstring &preferred_id = {}) { + std::lock_guard lock {preferred_restore_cache_mutex_ref()}; + auto &pending_id = pending_preferred_restore_id_ref(); + if (preferred_id.empty() || pending_id == preferred_id) { + pending_id.clear(); + } + } + + static void append_match_field(match_fields_list_t &match_list, match_field_e field, const wchar_t *value) { + if (value == nullptr || value[0] == L'\0') { + return; + } + + const std::wstring candidate {value}; + for (const auto &[existing_field, existing_value] : match_list) { + if (existing_field == field && existing_value == candidate) { + return; + } + } + + match_list.emplace_back(field, candidate); + } + + std::optional preferred_device_match_list(const std::wstring &preferred_id) { + { + std::lock_guard lock {preferred_restore_cache_mutex_ref()}; + auto &cache = preferred_restore_cache_ref(); + const auto it = cache.find(preferred_id); + if (it != cache.end()) { + return it->second; + } + } + + audio::device_t device; + if (FAILED(device_enum->GetDevice(preferred_id.c_str(), &device)) || !device) { + return std::nullopt; + } + + match_fields_list_t match_list; + match_list.emplace_back(match_field_e::device_id, preferred_id); + + audio::prop_t prop; + if (FAILED(device->OpenPropertyStore(STGM_READ, &prop)) || !prop) { + return match_list; + } + + prop_var_t device_friendly_name; + prop_var_t adapter_friendly_name; + prop_var_t device_desc; + + append_match_field(match_list, match_field_e::device_friendly_name, + SUCCEEDED(prop->GetValue(PKEY_Device_FriendlyName, &device_friendly_name.prop)) ? device_friendly_name.prop.pwszVal : nullptr); + append_match_field(match_list, match_field_e::device_description, + SUCCEEDED(prop->GetValue(PKEY_Device_DeviceDesc, &device_desc.prop)) ? device_desc.prop.pwszVal : nullptr); + append_match_field(match_list, match_field_e::adapter_friendly_name, + SUCCEEDED(prop->GetValue(PKEY_DeviceInterface_FriendlyName, &adapter_friendly_name.prop)) ? adapter_friendly_name.prop.pwszVal : nullptr); + + { + std::lock_guard lock {preferred_restore_cache_mutex_ref()}; + preferred_restore_cache_ref()[preferred_id] = match_list; + } + + return match_list; + } + + /** + * @brief Search for currently present audio device_id using multiple match fields. + * @param match_list Pairs of match fields and values + * @return Optional pair of matched field and device_id + */ + std::optional find_device_id(const match_fields_list_t &match_list) { + if (match_list.empty()) { + return std::nullopt; + } + + collection_t collection; + auto status = device_enum->EnumAudioEndpoints(eRender, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't enumerate: [0x"sv << util::hex(status).to_string_view() << ']'; + return std::nullopt; + } + + UINT count = 0; + collection->GetCount(&count); + + std::vector matched(match_list.size()); + for (auto x = 0; x < count; ++x) { + audio::device_t device; + collection->Item(x, &device); + + audio::wstring_t wstring_id; + device->GetId(&wstring_id); + std::wstring device_id = wstring_id.get(); + + audio::prop_t prop; + device->OpenPropertyStore(STGM_READ, &prop); + + prop_var_t adapter_friendly_name; + prop_var_t device_friendly_name; + prop_var_t device_desc; + + prop->GetValue(PKEY_Device_FriendlyName, &device_friendly_name.prop); + prop->GetValue(PKEY_DeviceInterface_FriendlyName, &adapter_friendly_name.prop); + prop->GetValue(PKEY_Device_DeviceDesc, &device_desc.prop); + + for (size_t i = 0; i < match_list.size(); i++) { + if (matched[i].empty()) { + const wchar_t *match_value = nullptr; + switch (match_list[i].first) { + case match_field_e::device_id: + match_value = device_id.c_str(); + break; + + case match_field_e::device_friendly_name: + match_value = device_friendly_name.prop.pwszVal; + break; + + case match_field_e::adapter_friendly_name: + match_value = adapter_friendly_name.prop.pwszVal; + break; + + case match_field_e::device_description: + match_value = device_desc.prop.pwszVal; + break; + } + if (match_value && std::wcscmp(match_value, match_list[i].second.c_str()) == 0) { + matched[i] = device_id; + } + } + } + } + + for (size_t i = 0; i < match_list.size(); i++) { + if (!matched[i].empty()) { + return matched_field_t(match_list[i].first, matched[i]); + } + } + + return std::nullopt; + } + + /** + * @brief Resets the default audio device from Steam Streaming Speakers. + * If a preferred device is supplied, tries to restore that exact device, + * keeping a background retry active if it is temporarily missing (e.g., + * HDMI/DP audio coming back after virtual display teardown). While a + * preferred restore is pending, Steam speakers remain usable instead of + * falling back to another endpoint. + * @param preferred_device The endpoint device_id of the device to restore. + */ + void reset_default_device(const std::string &preferred_device = {}) override { + std::wstring preferred_id; + if (!preferred_device.empty()) { + preferred_id = from_utf8(preferred_device); + } + reset_default_device_impl(true, preferred_id); + } + + /** + * @brief Non-blocking variant of reset_default_device() for startup. + * Tries once to move the default away from Steam speakers without waiting. + */ + void reset_default_device_no_wait() { + reset_default_device_impl(false, {}); + } + + private: + bool is_default_device(const std::wstring &device_id) { + auto current_default_dev = default_device(device_enum); + if (!current_default_dev) { + return false; + } + + audio::wstring_t current_default_id; + if (FAILED(current_default_dev->GetId(¤t_default_id)) || !current_default_id) { + return false; + } + + return device_id == current_default_id.get(); + } + + static std::mutex &pending_restore_mutex_ref() { + static std::mutex mutex; + return mutex; + } + + static std::jthread &pending_restore_thread_ref() { + static std::jthread thread; + return thread; + } + + static void cancel_pending_restore_task() { + std::jthread old_thread; + { + std::scoped_lock lock(pending_restore_mutex_ref()); + old_thread = std::move(pending_restore_thread_ref()); + } + } + + static void start_pending_restore_task(const std::wstring &steam_device_id, const std::wstring &preferred_id) { + std::jthread old_thread; + { + std::scoped_lock lock(pending_restore_mutex_ref()); + old_thread = std::move(pending_restore_thread_ref()); + // Run on a process-scoped worker with its own audio_control_t. The + // caller's audio_control_t is destroyed shortly after teardown returns, + // so the worker cannot safely capture `this`. + pending_restore_thread_ref() = std::jthread([steam_device_id, preferred_id](std::stop_token stop_token) { + co_init_t co_init; + audio_control_t restore_control; + if (restore_control.init() != 0) { + return; + } + restore_control.run_pending_restore_task(stop_token, steam_device_id, preferred_id); + }); + } + } + + void run_pending_restore_task(std::stop_token stop_token, const std::wstring &steam_device_id, const std::wstring &preferred_id) { + bool try_preferred_restore = !preferred_id.empty() && preferred_id != steam_device_id; + bool retry_fallback_reset = true; + if (try_preferred_restore) { + remember_pending_preferred_restore(preferred_id, steam_device_id); + } + + device_arrival_notification_t arrival_notifier(steam_device_id); + HANDLE cancel_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (!cancel_event) { + BOOST_LOG(warning) << "Failed to create background restore cancellation event"sv; + } + auto cancel_event_guard = util::fail_guard([&]() { + if (cancel_event) { + CloseHandle(cancel_event); + } + }); + std::optional>> stop_callback; + if (cancel_event) { + stop_callback.emplace(stop_token, [cancel_event]() { + SetEvent(cancel_event); + }); + } + + auto reg_status = device_enum->RegisterEndpointNotificationCallback(&arrival_notifier); + const bool have_notifications = SUCCEEDED(reg_status); + if (!have_notifications) { + BOOST_LOG(warning) << "Failed to register device arrival notification for background restore: "sv + << util::hex(reg_status).to_string_view(); + } + auto unreg_guard = util::fail_guard([&]() { + if (have_notifications) { + device_enum->UnregisterEndpointNotificationCallback(&arrival_notifier); + } + }); + + if (try_preferred_restore) { + BOOST_LOG(info) << "Waiting in background to restore the original default audio device"sv; + } else { + BOOST_LOG(info) << "Waiting in background for a non-Steam audio device to appear"sv; + } + + while (!stop_token.stop_requested()) { + if (try_preferred_restore) { + auto preferred_result = try_restore_preferred(preferred_id); + if (preferred_result == reset_result_e::success) { + return; + } + if (preferred_result == reset_result_e::fatal) { + clear_pending_preferred_restore(preferred_id); + return; + } + + arrival_notifier.wait(cancel_event, 1000); + continue; + } + + if (retry_fallback_reset && is_default_device(steam_device_id)) { + auto fallback_result = try_reset_from_steam(steam_device_id); + if (fallback_result == reset_result_e::fatal) { + return; + } + if (fallback_result == reset_result_e::success && !try_preferred_restore) { + return; + } + if (fallback_result == reset_result_e::no_device) { + retry_fallback_reset = false; + } + } else if (retry_fallback_reset && !try_preferred_restore) { + return; + } + + if (arrival_notifier.wait(cancel_event, 1000)) { + retry_fallback_reset = true; + } + } + } + + void reset_default_device_impl(bool wait_for_device, const std::wstring &preferred_id) { + cancel_pending_restore_task(); + + auto matched_steam = find_device_id(match_steam_speakers()); + if (!matched_steam) { + return; + } + auto steam_device_id = matched_steam->second; + + // If the user already switched away from Steam speakers, leave the newer + // default alone instead of restoring the previously recorded endpoint. + if (!is_default_device(steam_device_id)) { + clear_pending_preferred_restore(); + return; + } + + // Avoid restoring back to Steam speakers if that's somehow what got + // recorded as the original host sink. + std::wstring effective_preferred_id = preferred_id; + if (effective_preferred_id.empty() || effective_preferred_id == steam_device_id) { + auto pending_preferred_id = pending_preferred_restore_id(); + if (pending_preferred_id) { + effective_preferred_id = *pending_preferred_id; + } + } + bool try_preferred_restore = !effective_preferred_id.empty() && effective_preferred_id != steam_device_id; + + if (try_preferred_restore) { + remember_pending_preferred_restore(effective_preferred_id, steam_device_id); + + auto result = try_restore_preferred(effective_preferred_id); + if (result == reset_result_e::success) { + return; + } + if (result == reset_result_e::fatal) { + clear_pending_preferred_restore(effective_preferred_id); + } else if (wait_for_device) { + start_pending_restore_task(steam_device_id, effective_preferred_id); + return; + } else { + return; + } + } + + auto result = try_reset_from_steam(steam_device_id); + if (result == reset_result_e::success) { + if (try_preferred_restore && wait_for_device) { + start_pending_restore_task(steam_device_id, preferred_id); + } + return; + } + if (result == reset_result_e::fatal) { + return; + } + + if (!wait_for_device) { + return; + } + + start_pending_restore_task(steam_device_id, {}); + } + + enum class reset_result_e { + success, ///< A non-Steam device was set as default + no_device, ///< No non-Steam device is available yet (retriable) + fatal, ///< Unrecoverable failure (do not retry) + }; + + /** + * @brief Attempts to set a specific device as the default for all roles. + * Used to restore the user's original default device after a streaming + * session ends. Verifies the device is currently active before touching the + * policy so we don't bind to a missing endpoint. + * @param preferred_id Endpoint device_id of the device to restore. + * @return success if the device was restored, no_device if the device isn't + * active right now, fatal if the policy call rejected it. + */ + reset_result_e try_restore_preferred(const std::wstring &preferred_id) { + auto match_list = preferred_device_match_list(preferred_id); + if (!match_list) { + return reset_result_e::no_device; + } + + auto matched = find_device_id(*match_list); + if (!matched) { + return reset_result_e::no_device; + } + + const auto &resolved_id = matched->second; + + int failure = 0; + for (int x = 0; x < (int) ERole_enum_count; ++x) { + auto hr = policy->SetDefaultEndpoint(resolved_id.c_str(), (ERole) x); + if (FAILED(hr)) { + BOOST_LOG(warning) << "Couldn't restore preferred audio endpoint for role ["sv << x + << "]: 0x"sv << util::hex(hr).to_string_view(); + ++failure; + } + } + + if (failure) { + return reset_result_e::fatal; + } + + if (resolved_id != preferred_id) { + BOOST_LOG(info) << "Restored original default audio device via re-enumerated endpoint"sv; + } else { + BOOST_LOG(info) << "Restored original default audio device"sv; + } + clear_pending_preferred_restore(preferred_id); + return reset_result_e::success; + } + + /** + * @brief Attempts to move the default audio device away from Steam Streaming Speakers. + * Temporarily disables Steam speakers so the OS picks another default, + * then re-enables them and confirms the new default. + * @param steam_device_id The device ID of Steam Streaming Speakers. + * @return Result indicating success, retriable failure, or fatal failure. + */ + reset_result_e try_reset_from_steam(const std::wstring &steam_device_id) { + // Disable Steam speakers temporarily to let the OS pick a new default + auto hr = policy->SetEndpointVisibility(steam_device_id.c_str(), FALSE); + if (FAILED(hr)) { + BOOST_LOG(warning) << "Failed to disable Steam audio device: "sv << util::hex(hr).to_string_view(); + return reset_result_e::fatal; + } + + auto new_default_dev = default_device(device_enum); + + // Re-enable Steam speakers + hr = policy->SetEndpointVisibility(steam_device_id.c_str(), TRUE); + if (FAILED(hr)) { + BOOST_LOG(warning) << "Failed to enable Steam audio device: "sv << util::hex(hr).to_string_view(); + return reset_result_e::fatal; + } + + if (!new_default_dev) { + return reset_result_e::no_device; + } + + audio::wstring_t new_default_id; + new_default_dev->GetId(&new_default_id); + + int failure = 0; + for (int x = 0; x < (int) ERole_enum_count; ++x) { + auto status = policy->SetDefaultEndpoint(new_default_id.get(), (ERole) x); + if (FAILED(status)) { + BOOST_LOG(warning) << "Couldn't set new default audio endpoint for role ["sv << x << "]: 0x"sv << util::hex(status).to_string_view(); + ++failure; + } + } + + if (failure) { + return reset_result_e::fatal; + } + + BOOST_LOG(info) << "Successfully reset default audio device"sv; + return reset_result_e::success; + } + + bool install_driver_from_local_steam_inf(const wchar_t *driver_path_template, std::wstring_view driver_name, bool restore_default_output_device) { +#ifdef STEAM_DRIVER_SUBDIR + // MinGW's libnewdev.a is missing DiInstallDriverW() even though the headers have it, + // so we have to load it at runtime. It's Vista or later, so it will always be available. + auto newdev = LoadLibraryExW(L"newdev.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (!newdev) { + BOOST_LOG(error) << "newdev.dll failed to load"sv; + return false; + } + auto fg = util::fail_guard([newdev]() { + FreeLibrary(newdev); + }); + + auto fn_DiInstallDriverW = (decltype(DiInstallDriverW) *) GetProcAddress(newdev, "DiInstallDriverW"); + if (!fn_DiInstallDriverW) { + BOOST_LOG(error) << "DiInstallDriverW() is missing"sv; + return false; + } + + audio::device_t old_default_dev; + if (restore_default_output_device) { + old_default_dev = default_device(device_enum); + } + + WCHAR driver_path[MAX_PATH] = {}; + ExpandEnvironmentStringsW(driver_path_template, driver_path, ARRAYSIZE(driver_path)); + if (fn_DiInstallDriverW(nullptr, driver_path, 0, nullptr)) { + BOOST_LOG(info) << "Successfully installed "sv << driver_name; + + // Wait for 5 seconds to allow the audio subsystem to reconfigure things before + // modifying the default audio device or enumerating devices again. + Sleep(5000); + + if (restore_default_output_device && old_default_dev) { + // If there was a previous default device, restore that original device as the + // default output device just in case installing the new one changed it. + audio::wstring_t old_default_id; + old_default_dev->GetId(&old_default_id); + + for (int x = 0; x < (int) ERole_enum_count; ++x) { + policy->SetDefaultEndpoint(old_default_id.get(), (ERole) x); + } + } + + return true; + } else { + auto err = GetLastError(); + switch (err) { + case ERROR_ACCESS_DENIED: + BOOST_LOG(warning) << "Administrator privileges are required to install "sv << driver_name; + break; + case ERROR_FILE_NOT_FOUND: + case ERROR_PATH_NOT_FOUND: + BOOST_LOG(info) << "Steam audio drivers not found locally. Install Steam on the host to use "sv << driver_name << '.'; + break; + default: + BOOST_LOG(warning) << "Failed to install "sv << driver_name << ": "sv << err; + break; + } + + return false; + } +#else + BOOST_LOG(warning) << "Unable to install "sv << driver_name << " on unknown architecture"sv; + return false; +#endif + } + + public: + bool install_steam_audio_drivers() { + bool ok = true; + + if (!find_device_id(match_steam_speakers())) { + ok = install_driver_from_local_steam_inf(STEAM_SPEAKERS_DRIVER_PATH, L"Steam Streaming Speakers", true) && ok; + } + + if (!find_device_id(match_steam_microphone())) { + ok = install_driver_from_local_steam_inf(STEAM_MICROPHONE_DRIVER_PATH, L"Steam Streaming Microphone", false) && ok; + } + + return ok; + } + + int init() { + auto status = CoCreateInstance( + CLSID_CPolicyConfigClient, + nullptr, + CLSCTX_ALL, + IID_IPolicyConfig, + (void **) &policy + ); + + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't create audio policy config: [0x"sv << util::hex(status).to_string_view() << ']'; + + return -1; + } + + status = CoCreateInstance( + CLSID_MMDeviceEnumerator, + nullptr, + CLSCTX_ALL, + IID_IMMDeviceEnumerator, + (void **) &device_enum + ); + + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't create Device Enumerator: [0x"sv << util::hex(status).to_string_view() << ']'; + return -1; + } + + return 0; + } + + ~audio_control_t() override = default; + + policy_t policy; + audio::device_enum_t device_enum; + std::string assigned_sink; + std::string active_mic_backend; + std::unique_ptr mic_redirect_device; + }; +} // namespace platf::audio + +namespace platf { + + // It's not big enough to justify it's own source file :/ + namespace dxgi { + int init(); + } + + std::unique_ptr audio_control() { + auto control = std::make_unique(); + + if (control->init()) { + return nullptr; + } + + // Install Steam Streaming audio drivers if needed. We do this during audio_control() to ensure + // the sink information returned includes the new Steam endpoints before any later enumeration. + if (config::audio.install_steam_drivers && + (!control->find_device_id(control->match_steam_speakers()) || + !control->find_device_id(control->match_steam_microphone()))) { + // This is best effort. Don't fail if it doesn't work. + control->install_steam_audio_drivers(); + } + + return control; + } + + std::unique_ptr init() { + if (dxgi::init()) { + return nullptr; + } + + // Initialize COM + auto co_init = std::make_unique(); + + // If Steam Streaming Speakers are currently the default audio device, + // change the default to something else (if another device is available). + audio::audio_control_t audio_ctrl; + if (audio_ctrl.init() == 0) { + audio_ctrl.reset_default_device_no_wait(); + } + + return co_init; + } +} // namespace platf diff --git a/src/platform/windows/mic_write.cpp b/src/platform/windows/mic_write.cpp new file mode 100644 index 000000000..33ec6304a --- /dev/null +++ b/src/platform/windows/mic_write.cpp @@ -0,0 +1,967 @@ +/** + * @file src/platform/windows/mic_write.cpp + * @brief Windows microphone redirection writer. + */ +#include "mic_write.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "PolicyConfig.h" +#include "misc.h" +#include "src/audio.h" +#include "src/config.h" +#include "src/logging.h" + +namespace platf::audio { + namespace { + constexpr PROPERTYKEY PKEY_Device_DeviceDesc { + {0xa45c254e, 0xdf1c, 0x4efd, {0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0}}, + 2 + }; + constexpr PROPERTYKEY PKEY_Device_FriendlyName { + {0xa45c254e, 0xdf1c, 0x4efd, {0x80, 0x20, 0x67, 0xd1, 0x46, 0xa8, 0x50, 0xe0}}, + 14 + }; + constexpr PROPERTYKEY PKEY_DeviceInterface_FriendlyName { + {0x026e516e, 0xb814, 0x414b, {0x83, 0xcd, 0x85, 0x6d, 0x6f, 0xef, 0x48, 0x22}}, + 2 + }; + + constexpr std::uint32_t decoded_sample_rate = 48000; + constexpr REFERENCE_TIME buffer_duration_100ns = 1000000; + constexpr std::uint32_t default_packet_duration_samples = 960; + constexpr std::uint32_t max_packet_duration_samples = 5760; + constexpr std::size_t max_queued_frames = decoded_sample_rate; + constexpr std::size_t max_queued_packets = 64; + constexpr std::size_t target_prebuffer_packets = 4; + constexpr std::size_t target_prebuffer_frames = default_packet_duration_samples * target_prebuffer_packets; + + template + void co_task_free(T *ptr) { + if (ptr) { + CoTaskMemFree(ptr); + } + } + + using device_t = util::safe_ptr>; + using collection_t = util::safe_ptr>; + using prop_t = util::safe_ptr>; + using policy_t = util::safe_ptr>; + using wstring_t = util::safe_ptr>; + using waveformat_t = util::safe_ptr>; + + class prop_var_t { + public: + prop_var_t() { + PropVariantInit(&value); + } + + ~prop_var_t() { + PropVariantClear(&value); + } + + PROPVARIANT value; + }; + + struct parsed_waveformat_t { + WORD channels {}; + DWORD sample_rate {}; + WORD bits_per_sample {}; + WORD valid_bits_per_sample {}; + WORD block_align {}; + DWORD channel_mask {}; + bool is_float {}; + }; + + struct endpoint_format_info_t { + std::string mix_format {"unavailable"}; + std::string device_format {"unavailable"}; + bool recommended_active {}; + }; + + std::wstring get_prop_string(IPropertyStore *prop, REFPROPERTYKEY key) { + prop_var_t value; + if (FAILED(prop->GetValue(key, &value.value)) || value.value.vt != VT_LPWSTR || value.value.pwszVal == nullptr) { + return {}; + } + + return value.value.pwszVal; + } + + bool contains_case_insensitive(std::wstring haystack, std::wstring needle) { + std::transform(haystack.begin(), haystack.end(), haystack.begin(), ::towlower); + std::transform(needle.begin(), needle.end(), needle.begin(), ::towlower); + return haystack.find(needle) != std::wstring::npos; + } + + std::wstring endpoint_label(EDataFlow flow) { + return flow == eCapture ? L"capture" : L"render"; + } + + parsed_waveformat_t parse_waveformat(const WAVEFORMATEX *format) { + parsed_waveformat_t parsed {}; + if (format == nullptr) { + return parsed; + } + + parsed.channels = format->nChannels; + parsed.sample_rate = format->nSamplesPerSec; + parsed.bits_per_sample = format->wBitsPerSample; + parsed.valid_bits_per_sample = format->wBitsPerSample; + parsed.block_align = format->nBlockAlign; + + if (format->wFormatTag == WAVE_FORMAT_EXTENSIBLE && format->cbSize >= 22) { + const auto *extensible = reinterpret_cast(format); + parsed.valid_bits_per_sample = extensible->Samples.wValidBitsPerSample ? extensible->Samples.wValidBitsPerSample : format->wBitsPerSample; + parsed.channel_mask = extensible->dwChannelMask; + + parsed.is_float = extensible->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT && + format->wBitsPerSample == 32 && + parsed.valid_bits_per_sample == 32; + } else if (format->wFormatTag == WAVE_FORMAT_IEEE_FLOAT && + format->wBitsPerSample == 32) { + parsed.is_float = true; + } + + return parsed; + } + + std::string waveformat_to_pretty_string(const WAVEFORMATEX *format) { + const auto parsed = parse_waveformat(format); + if (format == nullptr) { + return "unavailable"; + } + + std::string result = parsed.is_float ? "float32" : "pcm"; + result += ", "; + result += std::to_string(parsed.valid_bits_per_sample ? parsed.valid_bits_per_sample : parsed.bits_per_sample); + result += "-bit"; + result += ", "; + result += std::to_string(parsed.sample_rate); + result += " Hz, "; + result += std::to_string(parsed.channels); + result += "ch"; + if (parsed.channel_mask != 0) { + result += ", mask=0x"; + result += util::hex(parsed.channel_mask).to_string(); + } + return result; + } + + bool is_recoverable_device_error(HRESULT status) { + return status == AUDCLNT_E_DEVICE_INVALIDATED || + status == AUDCLNT_E_RESOURCES_INVALIDATED || + status == AUDCLNT_E_SERVICE_NOT_RUNNING; + } + + std::uint16_t sequence_distance(std::uint16_t newer, std::uint16_t older) { + return static_cast(newer - older); + } + + std::uint32_t timestamp_distance(std::uint32_t newer, std::uint32_t older) { + return static_cast(newer - older); + } + + bool recover_device(mic_write_wasapi_t &writer, HRESULT status, const char *operation) { + if (!is_recoverable_device_error(status)) { + return false; + } + + BOOST_LOG(warning) << "Microphone playback device needs reinitialization after failure while " << operation + << ": 0x" << util::hex(status).to_string_view(); + + writer.cleanup(); + return writer.init() == 0; + } + + std::vector make_recommended_steam_mic_device_waveformat() { + WAVEFORMATEXTENSIBLE pcm_format {}; + pcm_format.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + pcm_format.Format.nChannels = 2; + pcm_format.Format.nSamplesPerSec = decoded_sample_rate; + pcm_format.Format.wBitsPerSample = 32; + pcm_format.Samples.wValidBitsPerSample = 32; + pcm_format.Format.nBlockAlign = static_cast(pcm_format.Format.nChannels * (pcm_format.Format.wBitsPerSample / 8)); + pcm_format.Format.nAvgBytesPerSec = pcm_format.Format.nSamplesPerSec * pcm_format.Format.nBlockAlign; + pcm_format.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + pcm_format.SubFormat = KSDATAFORMAT_SUBTYPE_PCM; + pcm_format.dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; + + std::vector storage(sizeof(pcm_format)); + std::memcpy(storage.data(), &pcm_format, sizeof(pcm_format)); + return storage; + } + + std::vector make_required_steam_mic_render_waveformat() { + WAVEFORMATEXTENSIBLE float_format {}; + float_format.Format.wFormatTag = WAVE_FORMAT_EXTENSIBLE; + float_format.Format.nChannels = 2; + float_format.Format.nSamplesPerSec = decoded_sample_rate; + float_format.Format.wBitsPerSample = 32; + float_format.Samples.wValidBitsPerSample = 32; + float_format.Format.nBlockAlign = static_cast(float_format.Format.nChannels * sizeof(float)); + float_format.Format.nAvgBytesPerSec = float_format.Format.nSamplesPerSec * float_format.Format.nBlockAlign; + float_format.Format.cbSize = sizeof(WAVEFORMATEXTENSIBLE) - sizeof(WAVEFORMATEX); + float_format.SubFormat = KSDATAFORMAT_SUBTYPE_IEEE_FLOAT; + float_format.dwChannelMask = SPEAKER_FRONT_LEFT | SPEAKER_FRONT_RIGHT; + + std::vector storage(sizeof(float_format)); + std::memcpy(storage.data(), &float_format, sizeof(float_format)); + return storage; + } + + bool is_recommended_steam_mic_device_format(const WAVEFORMATEX *format) { + const auto parsed = parse_waveformat(format); + return parsed.channels == 2 && + parsed.sample_rate == decoded_sample_rate && + parsed.valid_bits_per_sample == 32; + } + + endpoint_format_info_t query_endpoint_format_info(IMMDeviceEnumerator *device_enum, const std::wstring &device_id) { + endpoint_format_info_t info; + + policy_t policy; + auto status = CoCreateInstance( + CLSID_CPolicyConfigClient, + nullptr, + CLSCTX_ALL, + IID_IPolicyConfig, + reinterpret_cast(&policy) + ); + if (FAILED(status) || !policy) { + return info; + } + + waveformat_t current_format; + status = policy->GetDeviceFormat(device_id.c_str(), false, ¤t_format); + if (SUCCEEDED(status) && current_format) { + info.device_format = waveformat_to_pretty_string(current_format.get()); + info.recommended_active = is_recommended_steam_mic_device_format(current_format.get()); + } + + device_t device; + status = device_enum ? device_enum->GetDevice(device_id.c_str(), &device) : E_FAIL; + if (FAILED(status) || !device) { + return info; + } + + util::safe_ptr> local_audio_client; + status = device->Activate(IID_IAudioClient, CLSCTX_ALL, nullptr, reinterpret_cast(&local_audio_client)); + if (FAILED(status) || !local_audio_client) { + return info; + } + + waveformat_t mix_format; + status = local_audio_client->GetMixFormat(&mix_format); + if (SUCCEEDED(status) && mix_format) { + info.mix_format = waveformat_to_pretty_string(mix_format.get()); + } + + return info; + } + + bool ensure_recommended_steam_mic_format(const std::wstring &device_id, const std::string &target_device_name, EDataFlow flow) { + policy_t policy; + auto status = CoCreateInstance( + CLSID_CPolicyConfigClient, + nullptr, + CLSCTX_ALL, + IID_IPolicyConfig, + reinterpret_cast(&policy) + ); + if (FAILED(status) || !policy) { + BOOST_LOG(warning) << "Couldn't create audio policy config for Steam microphone format setup: 0x" + << util::hex(status).to_string_view(); + return false; + } + + waveformat_t current_format; + status = policy->GetDeviceFormat(device_id.c_str(), false, ¤t_format); + if (FAILED(status) || !current_format) { + BOOST_LOG(warning) << "Couldn't query Steam microphone " << to_utf8(endpoint_label(flow)) << " device format for [" << target_device_name << "]: 0x" + << util::hex(status).to_string_view(); + return false; + } + + if (is_recommended_steam_mic_device_format(current_format.get())) { + return true; + } + + auto recommended_format_storage = make_recommended_steam_mic_device_waveformat(); + auto *recommended_format = reinterpret_cast(recommended_format_storage.data()); + WAVEFORMATEXTENSIBLE previous_format {}; + status = policy->SetDeviceFormat(device_id.c_str(), recommended_format, reinterpret_cast(&previous_format)); + if (FAILED(status)) { + BOOST_LOG(warning) << "Couldn't set Steam microphone " << to_utf8(endpoint_label(flow)) + << " device format to stereo 32-bit 48k for [" << target_device_name << "]: 0x" + << util::hex(status).to_string_view(); + return false; + } + + BOOST_LOG(info) << "Changed Steam microphone " << to_utf8(endpoint_label(flow)) << " device format for [" << target_device_name + << "] to [pcm, 32-bit, 48000 Hz, 2ch]"; + return true; + } + + HRESULT initialize_shared_audio_client(IAudioClient *audio_client, const WAVEFORMATEX *format, DWORD stream_flags) { + return audio_client->Initialize( + AUDCLNT_SHAREMODE_SHARED, + stream_flags, + buffer_duration_100ns, + 0, + format, + nullptr + ); + } + + } // namespace + + mic_write_wasapi_t::mic_write_wasapi_t(std::string backend_name, + std::vector autodetect_patterns, + std::string requested_device_name): + backend_name {std::move(backend_name)}, + requested_device_name {std::move(requested_device_name)}, + autodetect_patterns {std::move(autodetect_patterns)} { + } + + mic_write_wasapi_t::~mic_write_wasapi_t() { + cleanup(); + } + + std::string_view mic_write_wasapi_t::backend_id() const { + return backend_name; + } + + bool mic_write_wasapi_t::find_target_device(EDataFlow flow, std::wstring &device_id, std::string &device_name) { + collection_t collection; + HRESULT status = device_enum->EnumAudioEndpoints(flow, DEVICE_STATE_ACTIVE, &collection); + if (FAILED(status) || !collection) { + BOOST_LOG(error) << "Couldn't enumerate " << to_utf8(endpoint_label(flow)) + << " devices for microphone redirection: 0x" << util::hex(status).to_string_view(); + return false; + } + + std::wstring requested_name = requested_device_name.empty() ? std::wstring {} : from_utf8(requested_device_name); + auto patterns = autodetect_patterns; + if (patterns.empty()) { + patterns = flow == eCapture ? + std::vector {L"Microphone (Steam Streaming Microphone)", L"Steam Streaming Microphone"} : + std::vector {L"Steam Streaming Microphone", L"Speakers (Steam Streaming Microphone)"}; + } + + UINT count = 0; + collection->GetCount(&count); + for (UINT index = 0; index < count; ++index) { + device_t device; + if (FAILED(collection->Item(index, &device)) || !device) { + continue; + } + + wstring_t id; + if (FAILED(device->GetId(&id)) || !id) { + continue; + } + + prop_t prop; + if (FAILED(device->OpenPropertyStore(STGM_READ, &prop)) || !prop) { + continue; + } + + auto friendly_name = get_prop_string(prop.get(), PKEY_Device_FriendlyName); + auto interface_name = get_prop_string(prop.get(), PKEY_DeviceInterface_FriendlyName); + auto description = get_prop_string(prop.get(), PKEY_Device_DeviceDesc); + + if (requested_name.empty() && + (contains_case_insensitive(friendly_name, L"16ch") || + contains_case_insensitive(interface_name, L"16ch") || + contains_case_insensitive(description, L"16ch"))) { + continue; + } + + bool matched = false; + if (!requested_name.empty()) { + matched = friendly_name == requested_name || interface_name == requested_name || description == requested_name || id.get() == requested_name; + } else { + for (const auto &pattern : patterns) { + if (contains_case_insensitive(friendly_name, pattern) || + contains_case_insensitive(interface_name, pattern) || + contains_case_insensitive(description, pattern)) { + matched = true; + break; + } + } + } + + if (!matched) { + continue; + } + + device_id = id.get(); + device_name = to_utf8(!friendly_name.empty() ? friendly_name : interface_name); + return true; + } + + return false; + } + + bool mic_write_wasapi_t::initialize_device() { + std::wstring render_device_id; + if (!find_target_device(eRender, render_device_id, target_device_name)) { + if (requested_device_name.empty()) { + BOOST_LOG(warning) << "No supported Steam Streaming Microphone playback device found. Install the Steam audio drivers and ensure " + << "\"Speakers (Steam Streaming Microphone)\" is available."; + ::audio::mic_debug_on_backend_error("Steam Streaming Microphone was not found on the host. Install the local Steam audio drivers and ensure Speakers (Steam Streaming Microphone) is available."); + } else { + BOOST_LOG(warning) << "Requested microphone device not found: " << requested_device_name; + ::audio::mic_debug_on_backend_error("Requested microphone render device was not found: " + requested_device_name); + } + return false; + } + + std::wstring capture_device_id; + std::string capture_device_name; + if (!find_target_device(eCapture, capture_device_id, capture_device_name)) { + BOOST_LOG(warning) << "Couldn't find the paired Steam microphone capture endpoint. Host applications may read from a stale or mismatched format."; + ::audio::mic_debug_on_backend_error("Could not find the paired Microphone (Steam Streaming Microphone) capture endpoint"); + return false; + } + + const bool render_format_enforced = ensure_recommended_steam_mic_format(render_device_id, target_device_name, eRender); + const bool capture_format_enforced = ensure_recommended_steam_mic_format(capture_device_id, capture_device_name, eCapture); + const auto render_endpoint_info = query_endpoint_format_info(device_enum.get(), render_device_id); + const auto capture_endpoint_info = query_endpoint_format_info(device_enum.get(), capture_device_id); + const bool recommended_format_active = render_endpoint_info.recommended_active && capture_endpoint_info.recommended_active; + const bool recommended_format_enforced = render_format_enforced || capture_format_enforced; + + device_t device; + HRESULT status = device_enum->GetDevice(render_device_id.c_str(), &device); + if (FAILED(status) || !device) { + BOOST_LOG(error) << "Couldn't open microphone playback device [" << target_device_name << "]: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not open host microphone render device [" + target_device_name + "]"); + return false; + } + + status = device->Activate(IID_IAudioClient, CLSCTX_ALL, nullptr, (void **) &audio_client); + if (FAILED(status) || !audio_client) { + BOOST_LOG(error) << "Couldn't activate microphone playback client [" << target_device_name << "]: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not activate the host microphone playback client for [" + target_device_name + "]"); + return false; + } + + waveformat_t mix_format; + status = audio_client->GetMixFormat(&mix_format); + if (FAILED(status) || !mix_format) { + BOOST_LOG(error) << "Couldn't get microphone playback mix format for [" << target_device_name << "]: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not query the Steam Streaming Microphone endpoint mix format"); + return false; + } + + const auto endpoint_mix_string = waveformat_to_pretty_string(mix_format.get()); + active_format_storage = make_required_steam_mic_render_waveformat(); + auto *required_render_format = reinterpret_cast(active_format_storage.data()); + + const auto init_status = initialize_shared_audio_client(audio_client.get(), required_render_format, AUDCLNT_STREAMFLAGS_EVENTCALLBACK); + if (FAILED(init_status)) { + BOOST_LOG(error) << "Couldn't initialize microphone playback client [" << target_device_name + << "] with required format [float32, 32-bit, 48000 Hz, 2ch]: 0x" + << util::hex(init_status).to_string_view(); + ::audio::mic_debug_on_backend_error("Steam Streaming Microphone must support 2ch, 32-bit float, 48000 Hz"); + return false; + } + + std::memset(&active_format, 0, sizeof(active_format)); + std::memcpy(&active_format, active_format_storage.data(), std::min(active_format_storage.size(), sizeof(active_format))); + + status = audio_client->GetBufferSize(&buffer_frame_count); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't query microphone playback buffer size: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not query the microphone playback buffer size"); + return false; + } + + status = audio_client->GetService(IID_IAudioRenderClient, (void **) &audio_render); + if (FAILED(status) || !audio_render) { + BOOST_LOG(error) << "Couldn't acquire microphone playback render client: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not acquire the microphone playback render client"); + return false; + } + + render_event.reset(CreateEvent(nullptr, FALSE, FALSE, nullptr)); + if (!render_event) { + BOOST_LOG(error) << "Couldn't create microphone playback event handle: 0x" << util::hex(GetLastError()).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not create the microphone playback event handle"); + return false; + } + + status = audio_client->SetEventHandle(render_event.get()); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't set microphone playback event handle: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not set the microphone playback event handle"); + return false; + } + + const auto render_format_string = waveformat_to_pretty_string(required_render_format); + const std::string channel_mapping = "Duplicate mono microphone input to stereo render channels"; + + BOOST_LOG(info) << "Client microphone redirection target: " << target_device_name + << " [mix=" << endpoint_mix_string + << ", render-device=" << render_endpoint_info.device_format + << ", capture-device=" << capture_endpoint_info.device_format + << ", render=" << render_format_string + << ", init=required float32 shared-mode render format" + << ", resampling=off" + << ']'; + + BOOST_LOG(info) << "Paired Steam microphone capture endpoint: " << capture_device_name + << " [mix=" << capture_endpoint_info.mix_format + << ", device=" << capture_endpoint_info.device_format + << ", recommended=" << (recommended_format_active ? "active" : "inactive") + << ", enforced=" << (recommended_format_enforced ? "yes" : "no") + << ']'; + + ::audio::mic_debug_on_backend_target(target_device_name, active_format.nChannels, active_format.nSamplesPerSec); + ::audio::mic_debug_on_backend_format(endpoint_mix_string, render_format_string, false, channel_mapping); + ::audio::mic_debug_on_backend_endpoint_formats( + render_endpoint_info.device_format, + capture_device_name, + capture_endpoint_info.mix_format, + capture_endpoint_info.device_format, + recommended_format_enforced, + recommended_format_active + ); + + status = audio_client->Start(); + if (FAILED(status)) { + BOOST_LOG(error) << "Couldn't start microphone playback client: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not start the microphone playback client"); + return false; + } + + stop_render_thread = false; + render_thread = std::thread {[this]() { render_loop(); }}; + + return true; + } + + int mic_write_wasapi_t::init() { + int opus_error = OPUS_OK; + opus_decoder = opus_decoder_create(decoded_sample_rate, 1, &opus_error); + if (opus_error != OPUS_OK || opus_decoder == nullptr) { + BOOST_LOG(error) << "Couldn't create Opus decoder for microphone redirection: " << opus_strerror(opus_error); + ::audio::mic_debug_on_backend_error("Could not create the Opus decoder for microphone redirection"); + return -1; + } + + HRESULT status = CoCreateInstance(CLSID_MMDeviceEnumerator, nullptr, CLSCTX_ALL, IID_IMMDeviceEnumerator, (void **) &device_enum); + if (FAILED(status) || !device_enum) { + BOOST_LOG(error) << "Couldn't create device enumerator for microphone redirection: 0x" << util::hex(status).to_string_view(); + ::audio::mic_debug_on_backend_error("Could not create the Windows audio device enumerator for microphone redirection"); + return -1; + } + + if (!initialize_device()) { + return -1; + } + + ::audio::mic_debug_on_backend_initialized(std::string {backend_name}); + + return 0; + } + + int mic_write_wasapi_t::write_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) { + if (!audio_client || audio_render == nullptr || opus_decoder == nullptr || data == nullptr || len == 0 || !render_event) { + BOOST_LOG(warning) << "Client microphone packet rejected before decode because the WASAPI write path is not ready" + << " [seq=" << sequence_number + << ", ts=" << timestamp + << ", len=" << len + << ", audio_client=" << static_cast(audio_client) + << ", audio_render=" << static_cast(audio_render != nullptr) + << ", opus_decoder=" << static_cast(opus_decoder != nullptr) + << ", render_event=" << static_cast(render_event) + << ", data=" << static_cast(data != nullptr) << ']'; + return -1; + } + + if (active_format.nChannels != 2 || active_format.nSamplesPerSec != decoded_sample_rate || active_format.wBitsPerSample != 32) { + ::audio::mic_debug_on_render_error(sequence_number, "Steam Streaming Microphone is not running at the required 2ch, 32-bit float, 48000 Hz format"); + return -1; + } + + bool stale_packet = false; + bool duplicate_packet = false; + bool trimmed_packet_queue = false; + { + std::lock_guard lock(queue_mutex); + + if (has_playout_cursor) { + const auto behind = sequence_distance(expected_sequence_number, sequence_number); + if (behind != 0 && behind < 0x8000) { + stale_packet = true; + } + } + + if (!stale_packet) { + auto [_, inserted] = pending_packets.emplace(sequence_number, queued_mic_packet_t { + std::vector {reinterpret_cast(data), reinterpret_cast(data) + len}, + sequence_number, + timestamp, + std::chrono::steady_clock::now() + }); + + duplicate_packet = !inserted; + if (inserted && pending_packets.size() > max_queued_packets) { + pending_packets.erase(pending_packets.begin()); + trimmed_packet_queue = true; + } + } + } + + if (stale_packet) { + ::audio::mic_debug_on_packet_dropped(sequence_number, "Dropped a stale microphone packet that arrived after its playout deadline"); + return 0; + } + + if (duplicate_packet) { + ::audio::mic_debug_on_packet_dropped(sequence_number, "Dropped a duplicate microphone packet"); + return 0; + } + + if (trimmed_packet_queue) { + BOOST_LOG(debug) << "Trimmed queued microphone packets for [" << target_device_name << "] to keep jitter-buffer latency bounded"; + ::audio::mic_debug_on_render_error(sequence_number, "Queued microphone packets grew too large, so older packets were dropped to keep latency bounded"); + } + + SetEvent(render_event.get()); + return static_cast(len); + } + + std::uint32_t mic_write_wasapi_t::infer_packet_duration_samples(std::uint32_t current_timestamp, std::uint32_t next_timestamp) const { + const auto delta = timestamp_distance(next_timestamp, current_timestamp); + if (delta >= 120 && delta <= max_packet_duration_samples) { + return delta; + } + return default_packet_duration_samples; + } + + bool mic_write_wasapi_t::should_conceal_missing_packet_locked() const { + if (pending_packets.empty()) { + return false; + } + + if (pending_packets.find(static_cast(expected_sequence_number + 1)) != pending_packets.end()) { + return true; + } + + const auto delta = sequence_distance(pending_packets.begin()->first, expected_sequence_number); + return delta != 0 && delta < 0x8000; + } + + void mic_write_wasapi_t::append_decoded_frames(const float *samples, int decoded_frames, std::uint16_t sequence_number) { + if (samples == nullptr || decoded_frames <= 0) { + return; + } + + float peak = 0.0f; + { + std::lock_guard lock(queue_mutex); + for (int frame = 0; frame < decoded_frames; ++frame) { + const float sample = std::clamp(samples[frame], -1.0f, 1.0f); + peak = std::max(peak, std::fabs(sample)); + pending_frames.push_back(sample); + } + + if (pending_frames.size() > max_queued_frames) { + const auto frames_to_trim = pending_frames.size() - max_queued_frames; + pending_frames.erase(pending_frames.begin(), pending_frames.begin() + static_cast(frames_to_trim)); + } + } + + const double normalized_level = std::clamp(static_cast(peak), 0.0, 1.0); + const bool silent = peak < 0.015625f; + ::audio::mic_debug_on_packet_decoded(sequence_number, normalized_level, silent); + ::audio::mic_debug_on_packet_rendered(sequence_number, normalized_level, silent); + } + + bool mic_write_wasapi_t::decode_next_packet() { + queued_mic_packet_t packet; + std::uint16_t packet_sequence = 0; + std::uint32_t frame_duration_samples = default_packet_duration_samples; + bool decode_fec = false; + bool decode_plc = false; + + { + std::lock_guard lock(queue_mutex); + + if (!has_playout_cursor) { + if (pending_packets.size() < target_prebuffer_packets) { + return false; + } + + expected_sequence_number = pending_packets.begin()->first; + expected_timestamp = pending_packets.begin()->second.timestamp; + has_playout_cursor = true; + } + + packet_sequence = expected_sequence_number; + + if (auto current = pending_packets.find(expected_sequence_number); current != pending_packets.end()) { + if (auto next = std::next(current); next != pending_packets.end()) { + frame_duration_samples = infer_packet_duration_samples(current->second.timestamp, next->second.timestamp); + } + + packet = std::move(current->second); + pending_packets.erase(current); + } else if (auto next = pending_packets.find(static_cast(expected_sequence_number + 1)); next != pending_packets.end()) { + frame_duration_samples = infer_packet_duration_samples(expected_timestamp, next->second.timestamp); + packet = next->second; + decode_fec = true; + } else if (should_conceal_missing_packet_locked()) { + decode_plc = true; + } else { + return false; + } + } + + std::vector decoded_pcm(max_packet_duration_samples); + int decoded_frames = 0; + if (decode_plc) { + decoded_frames = opus_decode_float(opus_decoder, nullptr, 0, decoded_pcm.data(), static_cast(frame_duration_samples), 0); + BOOST_LOG(debug) << "Applying Opus PLC for missing microphone packet on [" << target_device_name << "] sequence " << packet_sequence; + } else if (decode_fec) { + decoded_frames = opus_decode_float( + opus_decoder, + packet.payload.data(), + static_cast(packet.payload.size()), + decoded_pcm.data(), + static_cast(frame_duration_samples), + 1 + ); + BOOST_LOG(debug) << "Applying Opus FEC for missing microphone packet on [" << target_device_name << "] sequence " << packet_sequence; + } else { + decoded_frames = opus_decode_float( + opus_decoder, + packet.payload.data(), + static_cast(packet.payload.size()), + decoded_pcm.data(), + static_cast(decoded_pcm.size()), + 0 + ); + } + + if (decoded_frames <= 0) { + ::audio::mic_debug_on_decode_error(packet_sequence, "The host could not decode a microphone frame from the jitter buffer"); + std::vector silent_pcm(frame_duration_samples, 0.0f); + append_decoded_frames(silent_pcm.data(), static_cast(silent_pcm.size()), packet_sequence); + decoded_frames = static_cast(silent_pcm.size()); + } else { + append_decoded_frames(decoded_pcm.data(), decoded_frames, packet_sequence); + + if (!first_packet_written_logged) { + first_packet_written_logged = true; + BOOST_LOG(info) << "Client microphone audio is being rendered into [" << target_device_name << ']'; + } + } + + { + std::lock_guard lock(queue_mutex); + expected_sequence_number = static_cast(expected_sequence_number + 1); + expected_timestamp += static_cast(decoded_frames); + } + + return decoded_frames > 0; + } + + void mic_write_wasapi_t::render_loop() { + CoInitializeEx(nullptr, COINIT_MULTITHREADED | COINIT_SPEED_OVER_MEMORY); + platf::adjust_thread_priority(platf::thread_priority_e::high); + + while (!stop_render_thread) { + if (!audio_client || audio_render == nullptr || !render_event) { + break; + } + + const auto wait_result = WaitForSingleObject(render_event.get(), 20); + if (stop_render_thread) { + break; + } + if (wait_result != WAIT_OBJECT_0 && wait_result != WAIT_TIMEOUT) { + BOOST_LOG(debug) << "Microphone render wait failed for [" << target_device_name << "]: 0x" + << util::hex(GetLastError()).to_string_view(); + continue; + } + + UINT32 padding = 0; + auto status = audio_client->GetCurrentPadding(&padding); + if (FAILED(status)) { + BOOST_LOG(debug) << "Couldn't query microphone playback padding for [" << target_device_name << "]: 0x" + << util::hex(status).to_string_view(); + if (is_recoverable_device_error(status)) { + ::audio::mic_debug_on_backend_error("Steam microphone playback device was invalidated during rendering. Restart the stream."); + break; + } + continue; + } + + if (padding > buffer_frame_count) { + padding = 0; + } + + const auto frames_available = buffer_frame_count - padding; + if (frames_available == 0) { + continue; + } + + while (true) { + std::size_t queued_frames = 0; + std::size_t queued_packets = 0; + { + std::lock_guard lock(queue_mutex); + queued_frames = pending_frames.size(); + queued_packets = pending_packets.size(); + } + + if (queued_frames >= target_prebuffer_frames || queued_packets == 0) { + break; + } + + if (!decode_next_packet()) { + break; + } + } + + UINT32 queued_frames = 0; + std::size_t queued_packets = 0; + { + std::lock_guard lock(queue_mutex); + queued_frames = std::min(frames_available, static_cast(pending_frames.size())); + queued_packets = pending_packets.size(); + } + + const auto buffered_frames_total = padding + queued_frames; + + if (!playout_started) { + if (buffered_frames_total < target_prebuffer_frames) { + if (!playout_wait_logged) { + playout_wait_logged = true; + BOOST_LOG(debug) << "Waiting for microphone playout prebuffer on [" << target_device_name << "], queued " + << queued_frames << " frames, " << queued_packets << " buffered packets, padding " << padding << ", total buffered " + << buffered_frames_total << " of " << target_prebuffer_frames << " target frames"; + } + continue; + } + + playout_started = true; + playout_wait_logged = false; + BOOST_LOG(debug) << "Microphone playout prebuffer ready on [" << target_device_name << "] with " + << queued_frames << " queued frames, " << queued_packets << " buffered packets, padding " << padding + << ", total buffered " << buffered_frames_total << " frames"; + } + + if (queued_frames == 0) { + while (decode_next_packet()) { + std::lock_guard lock(queue_mutex); + queued_frames = std::min(frames_available, static_cast(pending_frames.size())); + if (queued_frames != 0) { + break; + } + } + } + + if (queued_frames == 0) { + continue; + } + + BYTE *buffer = nullptr; + status = audio_render->GetBuffer(queued_frames, &buffer); + if (FAILED(status) || buffer == nullptr) { + BOOST_LOG(debug) << "Couldn't acquire microphone playback buffer for [" << target_device_name << "]: 0x" + << util::hex(status).to_string_view(); + if (FAILED(status) && is_recoverable_device_error(status)) { + ::audio::mic_debug_on_backend_error("Steam microphone playback device was invalidated while acquiring a render buffer. Restart the stream."); + break; + } + continue; + } + + auto *dst = reinterpret_cast(buffer); + { + std::lock_guard lock(queue_mutex); + for (UINT32 frame = 0; frame < queued_frames; ++frame) { + const float sample = pending_frames.front(); + pending_frames.pop_front(); + dst[static_cast(frame) * 2] = sample; + dst[static_cast(frame) * 2 + 1] = sample; + } + } + + status = audio_render->ReleaseBuffer(queued_frames, 0); + if (FAILED(status)) { + BOOST_LOG(debug) << "Couldn't release microphone playback buffer for [" << target_device_name << "]: 0x" + << util::hex(status).to_string_view(); + if (is_recoverable_device_error(status)) { + ::audio::mic_debug_on_backend_error("Steam microphone playback device was invalidated while releasing a render buffer. Restart the stream."); + break; + } + } + } + + CoUninitialize(); + } + + void mic_write_wasapi_t::cleanup() { + stop_render_thread = true; + if (render_event) { + SetEvent(render_event.get()); + } + + if (render_thread.joinable()) { + render_thread.join(); + } + + if (audio_client) { + audio_client->Stop(); + } + + if (audio_render != nullptr) { + audio_render->Release(); + audio_render = nullptr; + } + + audio_client.reset(); + device_enum.reset(); + + if (opus_decoder != nullptr) { + opus_decoder_destroy(opus_decoder); + opus_decoder = nullptr; + } + + active_format_storage.clear(); + buffer_frame_count = 0; + active_format = {}; + target_device_name.clear(); + first_packet_written_logged = false; + render_event.reset(); + { + std::lock_guard lock(queue_mutex); + pending_packets.clear(); + pending_frames.clear(); + } + expected_sequence_number = 0; + expected_timestamp = 0; + has_playout_cursor = false; + playout_started = false; + playout_wait_logged = false; + } +} // namespace platf::audio diff --git a/src/platform/windows/mic_write.h b/src/platform/windows/mic_write.h new file mode 100644 index 000000000..ec52c637f --- /dev/null +++ b/src/platform/windows/mic_write.h @@ -0,0 +1,86 @@ +/** + * @file src/platform/windows/mic_write.h + * @brief Windows microphone redirection writer. + */ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "vibepollo_vmic.h" +#include "src/platform/common.h" + +struct OpusDecoder; + +namespace platf::audio { + template + inline void release_com(T *ptr) { + if (ptr) { + ptr->Release(); + } + } + + class mic_write_wasapi_t: public mic_redirect_backend_t { + public: + struct queued_mic_packet_t { + std::vector payload; + std::uint16_t sequence_number {}; + std::uint32_t timestamp {}; + std::chrono::steady_clock::time_point arrival_time {}; + }; + + mic_write_wasapi_t(std::string backend_name = "steam_streaming_microphone", + std::vector autodetect_patterns = {}, + std::string requested_device_name = {}); + ~mic_write_wasapi_t(); + + std::string_view backend_id() const override; + int init() override; + int write_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) override; + void cleanup(); + + private: + bool initialize_device(); + bool find_target_device(EDataFlow flow, std::wstring &device_id, std::string &device_name); + void render_loop(); + bool decode_next_packet(); + std::uint32_t infer_packet_duration_samples(std::uint32_t current_timestamp, std::uint32_t next_timestamp) const; + bool should_conceal_missing_packet_locked() const; + void append_decoded_frames(const float *samples, int decoded_frames, std::uint16_t sequence_number); + + util::safe_ptr> device_enum; + util::safe_ptr> audio_client; + IAudioRenderClient *audio_render = nullptr; + OpusDecoder *opus_decoder = nullptr; + std::vector active_format_storage; + WAVEFORMATEX active_format {}; + UINT32 buffer_frame_count = 0; + std::string backend_name; + std::string requested_device_name; + std::vector autodetect_patterns; + std::string target_device_name; + bool first_packet_written_logged = false; + util::safe_ptr_v2 render_event; + std::mutex queue_mutex; + std::map pending_packets; + std::deque pending_frames; + std::thread render_thread; + std::atomic stop_render_thread {false}; + std::uint16_t expected_sequence_number = 0; + std::uint32_t expected_timestamp = 0; + bool has_playout_cursor = false; + bool playout_started = false; + bool playout_wait_logged = false; + }; +} // namespace platf::audio diff --git a/src/platform/windows/vibepollo_vmic.cpp b/src/platform/windows/vibepollo_vmic.cpp new file mode 100644 index 000000000..ff776747a --- /dev/null +++ b/src/platform/windows/vibepollo_vmic.cpp @@ -0,0 +1,60 @@ +/** + * @file src/platform/windows/vibepollo_vmic.cpp + * @brief Steam Streaming Microphone backend for Windows host-side mic injection. + */ +#include "vibepollo_vmic.h" + +#include "mic_write.h" +#include "src/logging.h" + +namespace platf::audio { + vibepollo_vmic_t::~vibepollo_vmic_t() = default; + + std::string_view vibepollo_vmic_t::backend_id() const { + return "steam_streaming_microphone"; + } + + bool vibepollo_vmic_t::log_missing_driver_once() { + if (missing_driver_logged) { + return false; + } + + missing_driver_logged = true; + BOOST_LOG(warning) + << "Steam Streaming Microphone is unavailable. Install the local Steam audio drivers and ensure the " + << "\"Speakers (Steam Streaming Microphone)\" playback endpoint is present. Host applications should capture from " + << "\"Microphone (Steam Streaming Microphone)\"."; + return true; + } + + int vibepollo_vmic_t::init() { + if (!speaker_backend) { + speaker_backend = std::make_unique( + "steam_streaming_microphone", + std::vector { + L"Steam Streaming Microphone", + L"Speakers (Steam Streaming Microphone)", + } + ); + } + + if (speaker_backend->init() == 0) { + return 0; + } + + speaker_backend.reset(); + log_missing_driver_once(); + return -1; + } + + int vibepollo_vmic_t::write_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) { + if (!speaker_backend) { + BOOST_LOG(warning) << "Client microphone packet rejected before decode because the Steam Streaming Microphone backend is missing" + << " [seq=" << sequence_number << ", ts=" << timestamp << ", len=" << len << ']'; + log_missing_driver_once(); + return -1; + } + + return speaker_backend->write_data(data, len, sequence_number, timestamp); + } +} // namespace platf::audio diff --git a/src/platform/windows/vibepollo_vmic.h b/src/platform/windows/vibepollo_vmic.h new file mode 100644 index 000000000..f8f60fbe9 --- /dev/null +++ b/src/platform/windows/vibepollo_vmic.h @@ -0,0 +1,38 @@ +/** + * @file src/platform/windows/vibepollo_vmic.h + * @brief Steam Streaming Microphone backend definitions. + */ +#pragma once + +#include +#include +#include +#include + +namespace platf::audio { + class mic_write_wasapi_t; + + class mic_redirect_backend_t { + public: + virtual ~mic_redirect_backend_t() = default; + + virtual std::string_view backend_id() const = 0; + virtual int init() = 0; + virtual int write_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) = 0; + }; + + class vibepollo_vmic_t final: public mic_redirect_backend_t { + public: + ~vibepollo_vmic_t() override; + + std::string_view backend_id() const override; + int init() override; + int write_data(const char *data, std::size_t len, std::uint16_t sequence_number, std::uint32_t timestamp) override; + + private: + bool log_missing_driver_once(); + + bool missing_driver_logged = false; + std::unique_ptr speaker_backend; + }; +} // namespace platf::audio diff --git a/src/rswrapper.h b/src/rswrapper.h index 6a3e38784..ec066af8f 100644 --- a/src/rswrapper.h +++ b/src/rswrapper.h @@ -15,6 +15,9 @@ typedef void (*reed_solomon_release_t)(reed_solomon *rs); typedef int (*reed_solomon_encode_t)(reed_solomon *rs, uint8_t **shards, int nr_shards, int bs); typedef int (*reed_solomon_decode_t)(reed_solomon *rs, uint8_t **shards, uint8_t *marks, int nr_shards, int bs); +// Preserve the nanors shard limit expected by the streaming code. +#define DATA_SHARDS_MAX 255 + extern reed_solomon_new_t reed_solomon_new_fn; extern reed_solomon_release_t reed_solomon_release_fn; extern reed_solomon_encode_t reed_solomon_encode_fn; diff --git a/src/rtsp.cpp b/src/rtsp.cpp index 867fd2de6..9b3b75ade 100644 --- a/src/rtsp.cpp +++ b/src/rtsp.cpp @@ -912,6 +912,11 @@ namespace rtsp_stream { uint32_t encryption_flags_supported = SS_ENC_CONTROL_V2 | SS_ENC_AUDIO; uint32_t encryption_flags_requested = SS_ENC_CONTROL_V2; + if (config::audio.stream_mic) { + encryption_flags_supported |= SS_ENC_MICROPHONE; + encryption_flags_requested |= SS_ENC_MICROPHONE; + } + // Determine the encryption desired for this remote endpoint auto encryption_mode = net::encryption_mode_for_address(sock.remote_endpoint().address()); if (encryption_mode != config::ENCRYPTION_MODE_NEVER) { @@ -947,6 +952,12 @@ namespace rtsp_stream { ss << "a=fmtp:97 surround-params="sv << session.surround_params << std::endl; } + if (config::audio.stream_mic) { + ss << "m=audio " << net::map_port(stream::MIC_STREAM_PORT) << " RTP/AVP 96" << std::endl; + ss << "a=rtpmap:96 opus/48000/1"sv << std::endl; + ss << "a=fmtp:96 minptime=10;useinbandfec=1"sv << std::endl; + } + for (int x = 0; x < audio::MAX_STREAM_CONFIG; ++x) { auto &stream_config = audio::stream_configs[x]; std::uint8_t mapping[platf::speaker::MAX_SPEAKERS]; @@ -1002,6 +1013,9 @@ namespace rtsp_stream { port = net::map_port(stream::VIDEO_STREAM_PORT); } else if (type == "control"sv) { port = net::map_port(stream::CONTROL_PORT); + } else if (type == "mic"sv && config::audio.stream_mic) { + port = net::map_port(stream::MIC_STREAM_PORT); + session.enable_mic = true; } else { cmd_not_found(sock, session, std::move(req)); @@ -1329,6 +1343,15 @@ namespace rtsp_stream { config.frame_generation_provider = session.frame_generation_provider; config.lossless_scaling_target_fps = session.lossless_scaling_target_fps; config.lossless_scaling_rtss_limit = session.lossless_scaling_rtss_limit; + + if (session.enable_mic && + !(config.encryptionFlagsEnabled & SS_ENC_MICROPHONE)) { + BOOST_LOG(warning) << "Disabling microphone redirection for ["sv << session.device_name + << "] because the client did not negotiate microphone encryption"; + audio::mic_debug_on_session_stop("Microphone redirection requires encrypted transport. This client negotiated plaintext microphone packets, so mic passthrough was disabled for the session."); + session.enable_mic = false; + } + auto stream_session = stream::session::alloc(config, session); server->insert(stream_session, session.client_uuid); diff --git a/src/rtsp.h b/src/rtsp.h index c310a0361..6d899b9a5 100644 --- a/src/rtsp.h +++ b/src/rtsp.h @@ -53,6 +53,7 @@ namespace rtsp_stream { bool input_only; bool host_audio; + bool enable_mic; int width; int height; int fps; diff --git a/src/stream.cpp b/src/stream.cpp index 4ec709800..fc3f7d283 100644 --- a/src/stream.cpp +++ b/src/stream.cpp @@ -148,7 +148,8 @@ namespace stream { enum class socket_e : int { video, ///< Video - audio ///< Audio + audio, ///< Audio + microphone ///< Microphone }; namespace session { @@ -333,6 +334,14 @@ namespace stream { AUDIO_FEC_HEADER fecHeader; }; + struct mic_packet_header_t { + std::uint8_t flags; + std::uint8_t packetType; + boost::endian::little_uint16_at sequenceNumber; + boost::endian::little_uint32_at timestamp; + boost::endian::little_uint32_at ssrc; + }; + #pragma pack(pop) constexpr std::size_t round_to_pkcs7_padded(std::size_t size) { @@ -340,7 +349,6 @@ namespace stream { } constexpr std::size_t MAX_AUDIO_PACKET_SIZE = 1400; - using audio_aes_t = std::array; using av_session_id_t = std::variant; // IP address or SS-Ping-Payload from RTSP handshake @@ -430,6 +438,7 @@ namespace stream { message_queue_queue_t message_queue_queue; std::thread recv_thread; + std::thread mic_thread; std::thread video_thread; std::thread audio_thread; std::thread control_thread; @@ -438,6 +447,7 @@ namespace stream { udp::socket video_sock {io_context}; udp::socket audio_sock {io_context}; + udp::socket mic_sock {io_context}; control_server_t control_server; }; @@ -490,6 +500,8 @@ namespace stream { audio_fec_packet_t fec_packet; std::unique_ptr qos; + bool enable_mic; + bool first_mic_packet_logged; } audio; struct { @@ -1748,6 +1760,100 @@ namespace stream { } } + session_t *find_mic_session(broadcast_ctx_t &ctx, const udp::endpoint &peer) { + auto lg = ctx.control_server._sessions.lock(); + for (auto *stream_session : *ctx.control_server._sessions) { + if (!stream_session->audio.enable_mic) { + continue; + } + + if (stream_session->state.load(std::memory_order_relaxed) != stream::session::state_e::RUNNING) { + continue; + } + + if (stream_session->audio.peer.address() == peer.address()) { + return stream_session; + } + } + + return nullptr; + } + + void micRecvThread(broadcast_ctx_t &ctx) { + auto broadcast_shutdown_event = mail::man->event(mail::broadcast_shutdown); + std::array buf {}; + udp::endpoint peer; + + while (!broadcast_shutdown_event->peek()) { + boost::system::error_code ec; + auto bytes = ctx.mic_sock.receive_from(asio::buffer(buf), peer, 0, ec); + + if (broadcast_shutdown_event->peek()) { + break; + } + + if (ec) { + if (ec == boost::asio::error::operation_aborted || + ec == boost::asio::error::bad_descriptor || + ec == boost::asio::error::connection_refused || + ec == boost::asio::error::connection_reset) { + continue; + } + + BOOST_LOG(debug) << "Couldn't receive microphone packet: "sv << ec.message(); + continue; + } + + if (bytes <= sizeof(mic_packet_header_t)) { + continue; + } + + auto *header = reinterpret_cast(buf.data()); + if (header->packetType != MIC_PACKET_TYPE_OPUS || header->ssrc != MIC_PACKET_MAGIC) { + continue; + } + + auto *session = find_mic_session(ctx, peer); + if (session == nullptr) { + continue; + } + + const auto sequence_number = static_cast(header->sequenceNumber); + const auto timestamp = static_cast(header->timestamp); + const auto payload_len = bytes - sizeof(mic_packet_header_t); + const auto *payload = reinterpret_cast(buf.data() + sizeof(mic_packet_header_t)); + audio::mic_debug_on_packet_received(sequence_number, payload_len); + + if (!session->audio.first_mic_packet_logged) { + session->audio.first_mic_packet_logged = true; + BOOST_LOG(info) << "Received first client microphone packet for ["sv << session->device_name + << "] from ["sv << peer.address().to_string() << ':' << peer.port() + << "] with payload "sv << payload_len << " bytes"; + } + + std::vector decrypted_payload; + if (session->config.encryptionFlagsEnabled & SS_ENC_MICROPHONE) { + crypto::aes_t iv(16); + *(std::uint32_t *) iv.data() = util::endian::big(session->audio.avRiKeyId + sequence_number); + + if (session->audio.cipher.decrypt(std::string_view {reinterpret_cast(payload), payload_len}, decrypted_payload, &iv) != 0) { + BOOST_LOG(warning) << "Dropping encrypted microphone packet with invalid payload for ["sv << session->device_name + << "] sequence "sv << sequence_number; + audio::mic_debug_on_packet_decrypt_error(sequence_number, "Encrypted microphone packet could not be decrypted"); + continue; + } + + payload = decrypted_payload.data(); + } + + const auto decoded_payload_len = decrypted_payload.empty() ? payload_len : decrypted_payload.size(); + if (audio::write_mic_data(reinterpret_cast(payload), decoded_payload_len, sequence_number, timestamp) < 0) { + BOOST_LOG(debug) << "Dropping microphone packet for ["sv << session->device_name << ']'; + audio::mic_debug_on_packet_dropped(sequence_number, "Host microphone render path rejected the packet"); + } + } + } + void videoBroadcastThread(udp::socket &sock) { auto shutdown_event = mail::man->event(mail::broadcast_shutdown); auto packets = mail::man->queue(mail::video_packets); @@ -2214,6 +2320,7 @@ namespace stream { auto control_port = net::map_port(CONTROL_PORT); auto video_port = net::map_port(VIDEO_STREAM_PORT); auto audio_port = net::map_port(AUDIO_STREAM_PORT); + auto mic_port = net::map_port(MIC_STREAM_PORT); if (ctx.control_server.bind(address_family, control_port)) { BOOST_LOG(error) << "Couldn't bind Control server to port ["sv << control_port << "], likely another process already bound to the port"sv; @@ -2264,6 +2371,20 @@ namespace stream { return -1; } + if (config::audio.stream_mic) { + ctx.mic_sock.open(protocol, ec); + if (ec) { + BOOST_LOG(fatal) << "Couldn't open socket for Microphone server: "sv << ec.message(); + return -1; + } + + ctx.mic_sock.bind(udp::endpoint(protocol, mic_port), ec); + if (ec) { + BOOST_LOG(fatal) << "Couldn't bind Microphone server to port ["sv << mic_port << "]: "sv << ec.message(); + return -1; + } + } + ctx.message_queue_queue = std::make_shared(30); // Restart the io_context in case it was stopped from a previous session. @@ -2275,6 +2396,9 @@ namespace stream { ctx.control_thread = std::thread {controlBroadcastThread, &ctx.control_server}; ctx.recv_thread = std::thread {recvThread, std::ref(ctx)}; + if (config::audio.stream_mic) { + ctx.mic_thread = std::thread {micRecvThread, std::ref(ctx)}; + } return 0; } @@ -2296,6 +2420,9 @@ namespace stream { ctx.video_sock.close(); ctx.audio_sock.close(); + if (ctx.mic_sock.is_open()) { + ctx.mic_sock.close(); + } video_packets.reset(); audio_packets.reset(); @@ -2308,6 +2435,10 @@ namespace stream { ctx.audio_thread.join(); BOOST_LOG(debug) << "Waiting for main control thread to end..."sv; ctx.control_thread.join(); + if (ctx.mic_thread.joinable()) { + BOOST_LOG(debug) << "Waiting for main microphone thread to end..."sv; + ctx.mic_thread.join(); + } BOOST_LOG(debug) << "All broadcasting threads ended"sv; broadcast_shutdown_event->reset(); @@ -2410,6 +2541,7 @@ namespace stream { namespace session { std::atomic_uint running_sessions; + std::atomic_uint running_mic_sessions; state_e state(session_t &session) { return session.state.load(std::memory_order_relaxed); @@ -2538,6 +2670,11 @@ namespace stream { exec_thread.detach(); } + if (session.audio.enable_mic && running_mic_sessions.fetch_sub(1, std::memory_order_acq_rel) == 1) { + audio::release_mic_redirect_device(); + audio::mic_debug_on_session_stop("Remote microphone session ended"); + } + // If this is the last session, invoke the platform callbacks const bool last_rtsp_session = --running_sessions == 0; if (last_rtsp_session) { @@ -2647,6 +2784,27 @@ namespace stream { session.audio.peer.address(addr); session.audio.peer.port(0); + if (session.audio.enable_mic) { + audio::mic_debug_on_session_start(session.device_name, (session.config.encryptionFlagsEnabled & SS_ENC_MICROPHONE) != 0); + if (running_mic_sessions.fetch_add(1, std::memory_order_acq_rel) == 0) { + if (audio::init_mic_redirect_device() != 0) { + running_mic_sessions.fetch_sub(1, std::memory_order_acq_rel); + session.audio.enable_mic = false; + audio::mic_debug_on_backend_error("Microphone backend could not initialize on the host"); + audio::mic_debug_on_session_stop("Microphone redirection requested, but the host backend could not initialize"); + BOOST_LOG(warning) << "Client microphone redirection is unavailable for ["sv << session.device_name << ']'; + } else { + BOOST_LOG(info) << "Client microphone redirection requested for ["sv << session.device_name + << "] with encryption "sv + << ((session.config.encryptionFlagsEnabled & SS_ENC_MICROPHONE) ? "enabled"sv : "disabled"sv); + } + } else { + BOOST_LOG(info) << "Client microphone redirection requested for ["sv << session.device_name + << "] with encryption "sv + << ((session.config.encryptionFlagsEnabled & SS_ENC_MICROPHONE) ? "enabled"sv : "disabled"sv); + } + } + session.pingTimeout = std::chrono::steady_clock::now() + config::stream.ping_timeout; session.audioThread = std::thread {audioThread, &session}; @@ -2870,6 +3028,8 @@ namespace stream { session->audio.avRiKeyId = util::endian::big(*(std::uint32_t *) launch_session.iv.data()); session->audio.sequenceNumber = 0; session->audio.timestamp = 0; + session->audio.enable_mic = launch_session.enable_mic && config::audio.stream_mic; + session->audio.first_mic_packet_logged = false; session->control.peer = nullptr; session->state.store(state_e::STOPPED, std::memory_order_relaxed); diff --git a/src/stream.h b/src/stream.h index 363c8d2fa..e721e5bd4 100644 --- a/src/stream.h +++ b/src/stream.h @@ -26,6 +26,7 @@ namespace stream { constexpr auto VIDEO_STREAM_PORT = 9; constexpr auto CONTROL_PORT = 10; constexpr auto AUDIO_STREAM_PORT = 11; + constexpr auto MIC_STREAM_PORT = 12; constexpr std::string_view video_format_name(int video_format) { switch (video_format) { diff --git a/src_assets/common/assets/web/Troubleshooting.vue b/src_assets/common/assets/web/Troubleshooting.vue index 90a2609d4..2a54f573e 100644 --- a/src_assets/common/assets/web/Troubleshooting.vue +++ b/src_assets/common/assets/web/Troubleshooting.vue @@ -1,1160 +1,1476 @@ - - - - - + + + + + diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index 227c156c6..c997210ce 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -1,4 +1,4 @@ -