diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc29672..7565c7d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -462,13 +462,6 @@ jobs: - name: Install run: cmake --install cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --prefix cmake-build-ci/install - - name: Upload install artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: install-${{ matrix.name }} - path: cmake-build-ci/install - if-no-files-found: error - - name: Upload report artifact if: >- always() && @@ -483,6 +476,67 @@ jobs: path: cmake-build-ci/reports if-no-files-found: error + package_consumer: + name: Installed Package Consumer (Linux) + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Install package dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + cmake \ + libevdev-dev \ + libx11-dev \ + libxtst-dev \ + ninja-build \ + pkg-config + + # This intentionally uses a separate BUILD_TESTS=OFF configuration. Test + # builds add lizardbyte-common for test support and therefore cannot prove + # that a normal installed package is independently consumable. Debug keeps + # this packaging regression isolated from the open optimized-GCC P2; switch + # this job to Release when that warning is resolved. + - name: Configure tests-disabled package + run: | + cmake \ + -DBUILD_DOCS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTS=OFF \ + -DCMAKE_BUILD_TYPE:STRING=Debug \ + -DCMAKE_INSTALL_PREFIX="${GITHUB_WORKSPACE}/cmake-build-package/install" \ + -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ + -DLIBVIRTUALHID_ENABLE_PACKAGING=OFF \ + -B cmake-build-package \ + -G Ninja \ + -S . + + - name: Build and install package + run: | + cmake --build cmake-build-package --parallel 2 + cmake --install cmake-build-package + test ! -d cmake-build-package/install/lib/cmake/lizardbyte-common + + # A separate CMake project and real symbol reference force find_package, + # imported-target generation, compilation, and the final static-library link. + - name: Configure, compile, and link downstream consumer + run: | + cmake \ + -DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ + -DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ + -DCMAKE_PREFIX_PATH="${GITHUB_WORKSPACE}/cmake-build-package/install" \ + -B cmake-build-package-consumer \ + -G Ninja \ + -S tests/package-consumer + cmake --build cmake-build-package-consumer --parallel 2 + windows_driver: name: Windows Driver Installer needs: setup_release @@ -759,65 +813,27 @@ jobs: always() && needs.setup_release.outputs.publish_release == 'true' && needs.build.result == 'success' && + needs.package_consumer.result == 'success' && needs.windows_driver.result == 'success' && startsWith(github.repository, 'LizardByte/') needs: - build + - package_consumer - windows_driver - setup_release permissions: contents: read runs-on: ubuntu-latest steps: - - name: Download install artifact (Linux-GCC) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: install-Linux-GCC - path: install-Linux-GCC - - - name: Download install artifact (Linux-Clang) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: install-Linux-Clang - path: install-Linux-Clang - - - name: Download install artifact (macOS) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: install-macOS - path: install-macOS - - - name: Download install artifact (Windows-MinGW-UCRT64) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: install-Windows-MinGW-UCRT64 - path: install-Windows-MinGW-UCRT64 - - - name: Download install artifact (Windows-MSVC) - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: install-Windows-MSVC - path: install-Windows-MSVC - - name: Download Windows driver installer artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-driver-installer path: windows-driver-installer - - name: Package install artifacts + - name: Prepare release artifact run: | mkdir -p artifacts - for name in Linux-GCC Linux-Clang macOS Windows-MinGW-UCRT64 Windows-MSVC; do - release_name="${name}" - case "${name}" in - Windows-MinGW-UCRT64) release_name="Windows-AMD64-MinGW-UCRT64" ;; - Windows-MSVC) release_name="Windows-AMD64-MSVC" ;; - esac - zip -r \ - "artifacts/libvirtualhid-${release_name}.zip" \ - "install-${name}" - done cp windows-driver-installer/*.msi artifacts/ - name: Validate release metadata @@ -829,12 +845,7 @@ jobs: test -n "${RELEASE_TAG}" test -n "${RELEASE_VERSION}" test "${RELEASE_COMMIT}" = "${GITHUB_SHA}" - test -s "artifacts/libvirtualhid-Linux-GCC.zip" - test -s "artifacts/libvirtualhid-Linux-Clang.zip" - test -s "artifacts/libvirtualhid-macOS.zip" test -s "artifacts/libvirtualhid-Windows-AMD64-driver-installer.msi" - test -s "artifacts/libvirtualhid-Windows-AMD64-MinGW-UCRT64.zip" - test -s "artifacts/libvirtualhid-Windows-AMD64-MSVC.zip" - name: Create/Update GitHub Release if: needs.setup_release.outputs.publish_release == 'true' diff --git a/CMakeLists.txt b/CMakeLists.txt index e3b7c79..37560a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,6 +60,7 @@ set(LIBVIRTUALHID_USES_THREADS OFF) if(CMAKE_SYSTEM_NAME STREQUAL "Linux") set(LIBVIRTUALHID_USES_THREADS ON) endif() +set(LIBVIRTUALHID_USES_XTEST OFF) # # Additional setup for coverage @@ -71,7 +72,7 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTS AND NOT CMAKE_CXX_CO endif() set(LIBVIRTUALHID_USES_LIZARDBYTE_COMMON OFF) -if(WIN32) +if(WIN32 AND LIBVIRTUALHID_BUILD_WINDOWS_BROKER) set(LIBVIRTUALHID_USES_LIZARDBYTE_COMMON ON) endif() @@ -80,7 +81,7 @@ if(LIBVIRTUALHID_USES_LIZARDBYTE_COMMON OR BUILD_TESTS) ${BUILD_TESTS} CACHE BOOL "Build lizardbyte-common GoogleTest support helpers" FORCE) set(LIZARDBYTE_COMMON_INSTALL - ${LIBVIRTUALHID_INSTALL} + OFF CACHE BOOL "Install lizardbyte-common targets and package configuration" FORCE) if(NOT TARGET lizardbyte::common) add_subdirectory(third-party/lizardbyte-common) @@ -157,6 +158,18 @@ endif() # Package config # if(LIBVIRTUALHID_INSTALL) + set(LIBVIRTUALHID_CONFIG_DEPENDENCIES "") + if(LIBVIRTUALHID_USES_THREADS) + string(APPEND LIBVIRTUALHID_CONFIG_DEPENDENCIES + "find_dependency(Threads)\n" + "find_dependency(PkgConfig)\n" + "pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev)\n") + endif() + if(LIBVIRTUALHID_USES_XTEST) + string(APPEND LIBVIRTUALHID_CONFIG_DEPENDENCIES + "find_dependency(X11 COMPONENTS Xtst)\n") + endif() + install(FILES "${CMAKE_CURRENT_SOURCE_DIR}/LICENSE" DESTINATION "${CMAKE_INSTALL_DATADIR}/licenses/libvirtualhid" COMPONENT library) diff --git a/cmake/libvirtualhid-config.cmake.in b/cmake/libvirtualhid-config.cmake.in index 8b5855c..7d32bf4 100644 --- a/cmake/libvirtualhid-config.cmake.in +++ b/cmake/libvirtualhid-config.cmake.in @@ -2,13 +2,7 @@ include(CMakeFindDependencyMacro) -if(@LIBVIRTUALHID_USES_THREADS@) - find_dependency(Threads) - find_dependency(PkgConfig) - pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev) -endif() - -find_dependency(lizardbyte-common) +@LIBVIRTUALHID_CONFIG_DEPENDENCIES@ include("${CMAKE_CURRENT_LIST_DIR}/libvirtualhid-targets.cmake") diff --git a/docs/usage.md b/docs/usage.md index b03332d..c8088c8 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -106,10 +106,23 @@ normalized gamepad output such as rumble, RGB LED, adaptive trigger, trigger rumble, and raw report events delivered through the normal callback path. Button controls are momentary by default, so they behave like physical gamepad buttons; on Windows, the UI also displays broker license status and can activate, -refresh, or deactivate a machine license without elevation. Every Windows UMDF -gamepad creation requires a current successful license validation response and -there is no offline grace period. Purchase and account-management buttons use -the compiled URLs in +refresh, or deactivate a machine license without elevation. Windows UMDF +gamepad creation requires a current machine authorization, but does not perform +an online request per controller. The broker validates in the background at +startup and once per day. If Polar cannot be reached, it retries every 60 +seconds. Existing gamepads are retained for one hour, but a new gamepad can be +created only when no licensed gamepad is active. When the outage reaches one +hour, the broker removes excess licensed gamepads and retains at most one. +If the broker service restarts, it removes gamepads left by the previous broker +instance before accepting new creation requests. Failed removals are retried. +Yearly licenses stop at their exact `expires_at`; this outage behavior never +extends expiration. Lifetime licenses have no expiration. Polar server time, +Windows uptime, and a per-boot marker track time without relying on the +user-adjustable Windows date. After Windows restarts, a yearly license must +reconnect to Polar before gamepad creation; lifetime licenses can use the +one-gamepad outage fallback. A confirmed missing, revoked, disabled, expired, or +mismatched entitlement invalidates the license and removes all licensed gamepads. Purchase and +account-management buttons use the compiled URLs in `src/platform/windows/shared/lvh_windows_broker_config.hpp`. Enable `Lock buttons` to click-to-toggle behavior for held inputs. The resizable window supports a compact width. Its device and control panels diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 88cdafb..63f17de 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -68,14 +68,15 @@ submit/destroy requests include that token so stale or unrelated clients cannot control devices they did not create. Input reports are submitted through VHF, and HID output writes are normalized back to the C++ output callback path. -The driver rejects gamepad create and destroy IOCTLs unless the requestor token -contains the `NT SERVICE\libvirtualhid_broker` service SID. On the first boot -after installation, before Windows applies a newly configured service SID to -the process token, the driver instead requires the requestor PID to match the -SCM-registered, currently running broker service. Administrators still control -installation, repair, replacement, and service diagnostics through the normal -Windows service and driver-management tools, but they are not a separate runtime -bypass for creating or destroying virtual devices. +The driver rejects gamepad create, destroy, and broker-instance reset IOCTLs +unless the requestor token contains the `NT SERVICE\libvirtualhid_broker` +service SID. On the first boot after installation, before Windows applies a +newly configured service SID to the process token, the driver instead requires +the requestor PID to match the SCM-registered, currently running broker service. +Administrators still control installation, repair, replacement, and service +diagnostics through the normal Windows service and driver-management tools, but +they are not a separate runtime bypass for creating or destroying virtual +devices. The library and installed driver must use the same control-protocol version. Protocol version 2 expands the report-descriptor capacity to 2048 bytes for the @@ -94,7 +95,10 @@ parents that target to the control-file handle that created it. If the creating process exits or crashes, Windows cleans up gamepads that were not explicitly destroyed. In brokered driver packages, the broker owns that control-file handle. The broker tracks the requesting client process for each created device and -destroys broker-owned devices when that client process exits unexpectedly. +destroys broker-owned devices when that client process exits unexpectedly. A new +broker process first asks the driver to remove every gamepad left by the previous +broker instance and refuses new creation until that reset succeeds. Clients must +recreate their gamepads after the broker service restarts. The backend reports `requires_installed_driver = true` and only advertises gamepad/output-report support when the broker is reachable and the control @@ -276,8 +280,29 @@ opens the shared persistent Polar Checkout Link. Account management opens the [LizardByte LLC Polar customer portal](https://polar.sh/lizardbyte-llc/portal), where customers can manage their five allowed machine activations. -Normal Windows UMDF gamepad creation requires a current successful license -validation response before the broker calls the driver. The sole exception is +Normal Windows UMDF gamepad creation requires a current machine authorization, +but controller creation itself does not contact Polar. The broker validates the +saved activation immediately after service startup and then once per day in the +background. If validation cannot complete because of a temporary network or +provider failure, the broker retries every 60 seconds. Controllers that already +exist are retained for one hour unless the broker service restarts, but no +additional controller can be created while at least one licensed controller +remains active. When the outage reaches one hour, the broker removes excess +licensed controllers and retains at most one until online validation succeeds. +Failed driver destruction requests remain tracked and are retried instead of +being treated as successful revocations. + +Polar's HTTPS `Date` response header supplies trusted time when a new +authorization is issued. A yearly license ends exactly at its reported +`expires_at`; the one-hour outage retention does not extend that expiration. A +lifetime license has no calendar expiration. The broker advances Polar's trusted +timestamp using Windows uptime and stores a random marker in a volatile registry +key for the current boot session. This works across broker service restarts and +includes sleep or hibernation, but never consults the user-adjustable Windows +date. After Windows restarts, the marker changes, so a yearly license must +reconnect to Polar before gamepad creation; lifetime licenses can use the +one-gamepad outage fallback. Explicit validation requests always contact the +provider. The sole exception to normal licensing is for CI runners where the broker service itself has the `GITHUB_ACTIONS` environment marker. That environment receives one machine-scoped five-minute evaluation window beginning with its first unlicensed creation attempt. The @@ -290,7 +315,13 @@ Polar's `limit_activations` value is the machine limit and is configured as `5` on both license-key benefits. The broker gives yearly and lifetime licenses the same full local access when the provider reports the key status as `granted`. Polar revokes a subscription benefit when its entitlement ends. Licensed access has -no local active-device cap, and there is no production offline grace period. +no local active-device cap after successful validation. A definitive missing +activation, revoked or disabled key, activation mismatch, disallowed benefit, +explicit deactivation, or exact yearly expiration prevents new gamepads and +causes the broker to destroy existing licensed gamepads. A timeout or other +transient provider failure starts the one-hour retention period and one-gamepad +creation limit instead of immediately revoking existing controllers. WinHTTP resolve, connect, send, and receive +operations have explicit timeouts of 5, 5, 5, and 10 seconds respectively. ## Profile Compatibility @@ -338,8 +369,10 @@ label because VHF does not provide a product/manufacturer string callback. - The published Windows driver installer is AMD64-only. Windows ARM64 release packages require a Microsoft dashboard signing path that is not part of the current Azure Trusted Signing workflow. -- Every production gamepad creation requires a successful online license - validation response. There is no offline grace period. +- A temporary Polar outage limits a previously activated machine to one active + licensed gamepad until validation succeeds. Yearly licenses receive no + post-expiration grace. Definitive invalidation or exact expiration prevents + new gamepads and removes active licensed gamepads. ## Signing diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 66c66f0..fe29eb3 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,7 +22,7 @@ if(LIBVIRTUALHID_USES_THREADS) pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev) endif() if(LIBVIRTUALHID_ENABLE_XTEST) - find_package(X11 QUIET) + find_package(X11 QUIET COMPONENTS Xtst) endif() target_sources(${PROJECT_NAME} @@ -34,26 +34,25 @@ if(LIBVIRTUALHID_USES_THREADS) PRIVATE PkgConfig::LIBEVDEV) if(LIBVIRTUALHID_ENABLE_XTEST AND X11_FOUND AND X11_XTest_FOUND) + set(LIBVIRTUALHID_USES_XTEST ON PARENT_SCOPE) target_compile_definitions(${PROJECT_NAME} PRIVATE LIBVIRTUALHID_HAVE_XTEST=1) - target_include_directories(${PROJECT_NAME} - PRIVATE - ${X11_INCLUDE_DIR} - ${X11_XTest_INCLUDE_PATH}) target_link_libraries(${PROJECT_NAME} PRIVATE - ${X11_LIBRARIES} - ${X11_XTest_LIB}) + X11::X11 + X11::Xtst) endif() elseif(WIN32) target_sources(${PROJECT_NAME} PRIVATE + "${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/src/common/env.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/platform/windows/windows_backend.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/platform/windows/windows_broker_client.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/platform/windows/windows_license.cpp") target_include_directories(${PROJECT_NAME} PRIVATE + "${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/src/include" "${CMAKE_CURRENT_SOURCE_DIR}/platform/windows/shared") target_compile_definitions(${PROJECT_NAME} PRIVATE @@ -63,8 +62,7 @@ elseif(WIN32) target_link_libraries(${PROJECT_NAME} PRIVATE advapi32 - setupapi - lizardbyte::common) + setupapi) elseif(APPLE) target_sources(${PROJECT_NAME} PRIVATE diff --git a/src/platform/windows/broker/CMakeLists.txt b/src/platform/windows/broker/CMakeLists.txt index c53b67c..2b94fe8 100644 --- a/src/platform/windows/broker/CMakeLists.txt +++ b/src/platform/windows/broker/CMakeLists.txt @@ -23,6 +23,7 @@ target_link_libraries(libvirtualhid_broker lizardbyte::common nlohmann_json::nlohmann_json advapi32 + bcrypt crypt32 winhttp) diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp index ae6cc9c..628862c 100644 --- a/src/platform/windows/broker/libvirtualhid_broker.cpp +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -17,6 +17,7 @@ // clang-format off #include #include +#include #include #include #include @@ -38,6 +39,8 @@ #include #include #include +#include +#include #include #include #include @@ -55,6 +58,8 @@ #include #include #include +#include +#include #include #include @@ -62,6 +67,7 @@ namespace lvh::detail::windows_broker_service { using UniqueHandle = std::unique_ptr; using UniqueLocalMemory = std::unique_ptr; + using UniqueRegistryKey = std::unique_ptr, decltype(&::RegCloseKey)>; using UniqueWinHttpHandle = std::unique_ptr; constexpr auto service_name = L"libvirtualhid_broker"; @@ -70,6 +76,17 @@ namespace lvh::detail::windows_broker_service { constexpr auto pipe_buffer_size = 8192U; constexpr auto pipe_connect_timeout = 1000U; constexpr auto pipe_io_timeout = 5000U; + constexpr int polar_resolve_timeout = 5000; + constexpr int polar_connect_timeout = 5000; + constexpr int polar_send_timeout = 5000; + constexpr int polar_receive_timeout = 10000; + constexpr auto license_validation_interval = std::chrono::days {1}; + constexpr auto license_validation_retry_interval = std::chrono::seconds {60}; + constexpr auto license_outage_device_retention = std::chrono::hours {1}; + constexpr std::size_t unvalidated_active_gamepad_limit = 1U; + constexpr auto boot_session_registry_path = + L"SYSTEM\\CurrentControlSet\\Services\\libvirtualhid_broker\\Runtime"; + constexpr auto boot_session_registry_value = L"BootMarker"; // Message-mode clients need the complete GENERIC_READ mapping plus individual write rights. constexpr auto pipe_client_granted_access = FILE_GENERIC_READ | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES; constexpr auto pipe_security_descriptor = L"D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x0012018B;;;AU)"; @@ -109,10 +126,261 @@ namespace lvh::detail::windows_broker_service { return {handle, &::CloseHandle}; } + UniqueRegistryKey make_unique_registry_key(HKEY key) { + return {key, &::RegCloseKey}; + } + UniqueWinHttpHandle make_unique_winhttp_handle(HINTERNET handle) { return {handle, &::WinHttpCloseHandle}; } + using LicenseValidationClock = std::chrono::steady_clock; + // Calendar time is anchored only by Polar's HTTPS Date header. Windows + // uptime advances that anchor within one boot session, including across + // broker restarts, without consulting the user-adjustable calendar clock. + using LicenseCalendarClock = std::chrono::system_clock; + + using BootMarker = std::array; + + std::optional generate_boot_marker() { + BootMarker marker {}; + if (::BCryptGenRandom(nullptr, std::bit_cast(marker.data()), static_cast(marker.size()), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + return std::nullopt; + } + return marker; + } + + std::string encode_boot_marker(const BootMarker &marker) { + constexpr std::string_view hex = "0123456789abcdef"; + std::string encoded(marker.size() * 2U, '0'); + for (std::size_t index = 0; index < marker.size(); ++index) { + const auto value = std::to_integer(marker[index]); + encoded[index * 2U] = hex[value >> 4U]; + encoded[index * 2U + 1U] = hex[value & 0x0FU]; + } + return encoded; + } + + std::string load_or_create_boot_session_marker() { + const auto generated = generate_boot_marker(); + if (!generated) { + return {}; + } + + HKEY raw_key = nullptr; + DWORD disposition = 0; + if (::RegCreateKeyExW(HKEY_LOCAL_MACHINE, boot_session_registry_path, 0, nullptr, REG_OPTION_VOLATILE, KEY_QUERY_VALUE | KEY_SET_VALUE, nullptr, &raw_key, &disposition) != ERROR_SUCCESS) { + // This process can still use the marker. A later process receives a new + // marker and therefore fails yearly expiration checks closed. + return encode_boot_marker(*generated); + } + auto key = make_unique_registry_key(raw_key); + + BootMarker stored {}; + DWORD type = 0; + if (auto size = static_cast(stored.size()); disposition == REG_OPENED_EXISTING_KEY && ::RegQueryValueExW(key.get(), boot_session_registry_value, nullptr, &type, std::bit_cast(stored.data()), &size) == ERROR_SUCCESS && type == REG_BINARY && size == stored.size()) { + return encode_boot_marker(stored); + } + + static_cast(::RegSetValueExW(key.get(), boot_session_registry_value, 0, REG_BINARY, std::bit_cast(generated->data()), static_cast(generated->size()))); + return encode_boot_marker(*generated); + } + + bool valid_rfc3339_date_time_parts( + const std::optional &year, + const std::optional &month, + const std::optional &day, + const std::optional &hour, + const std::optional &minute, + const std::optional &second + ) { + return year.has_value() && month.has_value() && day.has_value() && + hour.has_value() && minute.has_value() && second.has_value() && + *hour <= 23U && *minute <= 59U && *second <= 60U; + } + + std::optional parse_rfc3339_part( + std::string_view value, + std::size_t offset, + std::size_t size + ) { + if (offset > value.size() || size > value.size() - offset) { + return std::nullopt; + } + + unsigned parsed = 0; + const auto *begin = value.data() + offset; + const auto *end = begin + size; + const auto result = std::from_chars(begin, end, parsed); + return result.ec == std::errc {} && result.ptr == end ? + std::optional {parsed} : + std::nullopt; + } + + std::optional parse_rfc3339_utc_offset( + std::string_view value + ) { + std::size_t timezone_offset = 19U; + if (timezone_offset < value.size() && value[timezone_offset] == '.') { + ++timezone_offset; + const auto fraction_start = timezone_offset; + while (timezone_offset < value.size() && value[timezone_offset] >= '0' && value[timezone_offset] <= '9') { + ++timezone_offset; + } + if (timezone_offset == fraction_start) { + return std::nullopt; + } + } + + if (timezone_offset >= value.size()) { + return std::nullopt; + } + if (value[timezone_offset] == 'Z' || value[timezone_offset] == 'z') { + return timezone_offset + 1U == value.size() ? + std::optional {std::chrono::minutes::zero()} : + std::nullopt; + } + if (timezone_offset + 6U != value.size() || (value[timezone_offset] != '+' && value[timezone_offset] != '-') || value[timezone_offset + 3U] != ':') { + return std::nullopt; + } + + const auto offset_hours = parse_rfc3339_part(value, timezone_offset + 1U, 2U); + const auto offset_minutes = parse_rfc3339_part(value, timezone_offset + 4U, 2U); + if (!offset_hours.has_value() || !offset_minutes.has_value() || *offset_hours > 23U || *offset_minutes > 59U) { + return std::nullopt; + } + + auto utc_offset = std::chrono::hours {*offset_hours} + std::chrono::minutes {*offset_minutes}; + return value[timezone_offset] == '-' ? -utc_offset : utc_offset; + } + + std::optional parse_rfc3339_timestamp( + std::string_view value + ) { + if (value.size() < 20U || value[4] != '-' || value[7] != '-' || (value[10] != 'T' && value[10] != 't') || value[13] != ':' || value[16] != ':') { + return std::nullopt; + } + + const auto year_value = parse_rfc3339_part(value, 0U, 4U); + const auto month_value = parse_rfc3339_part(value, 5U, 2U); + const auto day_value = parse_rfc3339_part(value, 8U, 2U); + const auto hour_value = parse_rfc3339_part(value, 11U, 2U); + const auto minute_value = parse_rfc3339_part(value, 14U, 2U); + const auto second_value = parse_rfc3339_part(value, 17U, 2U); + if (!valid_rfc3339_date_time_parts(year_value, month_value, day_value, hour_value, minute_value, second_value)) { + return std::nullopt; + } + + const auto utc_offset = parse_rfc3339_utc_offset(value); + if (!utc_offset.has_value()) { + return std::nullopt; + } + + const auto date = std::chrono::year_month_day { + std::chrono::year {static_cast(*year_value)}, + std::chrono::month {*month_value}, + std::chrono::day {*day_value}, + }; + if (!date.ok()) { + return std::nullopt; + } + + return std::chrono::sys_days {date} + + std::chrono::hours {*hour_value} + + std::chrono::minutes {*minute_value} + + std::chrono::seconds {std::min(*second_value, 59U)} - + *utc_offset; + } + + std::optional license_server_time( + const SYSTEMTIME &system_time + ) { + FILETIME file_time {}; + if (::SystemTimeToFileTime(&system_time, &file_time) == FALSE) { + return std::nullopt; + } + const auto ticks = std::bit_cast(file_time); + constexpr std::uint64_t windows_to_unix_epoch_ticks = 116444736000000000ULL; + constexpr std::uint64_t ticks_per_second = 10000000ULL; + if (ticks < windows_to_unix_epoch_ticks) { + return std::nullopt; + } + return LicenseCalendarClock::time_point { + std::chrono::seconds { + static_cast( + (ticks - windows_to_unix_epoch_ticks) / ticks_per_second + ) + } + }; + } + + std::uint64_t license_calendar_timestamp(LicenseCalendarClock::time_point time) { + const auto seconds = std::chrono::duration_cast( + time.time_since_epoch() + ) + .count(); + return seconds > 0 ? static_cast(seconds) : 0U; + } + + std::optional license_monotonic_calendar_timestamp( + std::uint64_t calendar_anchor, + std::uint64_t uptime_anchor, + std::uint64_t current_uptime, + bool same_boot_session + ) { + if (!same_boot_session || calendar_anchor == 0U || current_uptime < uptime_anchor) { + return std::nullopt; + } + const auto elapsed_unsigned = (current_uptime - uptime_anchor) / 1000U; + return calendar_anchor > std::numeric_limits::max() - elapsed_unsigned ? + std::optional {std::numeric_limits::max()} : + std::optional {calendar_anchor + elapsed_unsigned}; + } + + bool license_expiration_is_current( + std::string_view expires_at, + bool expiration_required, + std::uint64_t effective_timestamp + ) { + if (!expiration_required) { + return true; + } + const auto expiration = parse_rfc3339_timestamp(expires_at); + return expiration && + effective_timestamp < license_calendar_timestamp(*expiration); + } + + bool unvalidated_gamepad_creation_allowed( + std::size_t active_licensed_gamepads + ) { + return active_licensed_gamepads < unvalidated_active_gamepad_limit; + } + + bool license_outage_retention_elapsed( + LicenseValidationClock::duration elapsed + ) { + return elapsed >= license_outage_device_retention; + } + + bool license_outage_gamepad_should_be_revoked( + bool github_actions_evaluation, + bool limit_licensed_devices, + std::size_t licensed_devices_kept + ) { + return limit_licensed_devices && + !github_actions_evaluation && + licensed_devices_kept >= unvalidated_active_gamepad_limit; + } + + bool broker_device_should_be_revoked( + bool github_actions_evaluation, + bool evaluation_expired, + bool revoke_licensed_devices + ) { + return (evaluation_expired && github_actions_evaluation) || + (revoke_licensed_devices && !github_actions_evaluation); + } + bool complete_pending_pipe_io( HANDLE pipe, OVERLAPPED &overlapped, @@ -473,6 +741,12 @@ namespace lvh::detail::windows_broker_service { std::string customer_email; std::string expires_at; std::uint32_t activation_limit = 0; + // Audit timestamp supplied by Polar, not the local machine clock. + std::uint64_t validated_at = 0; + // The boot-session marker and uptime at validation let the broker advance + // Polar time across service restarts without using the Windows wall clock. + std::string boot_marker; + std::uint64_t validated_uptime_ms = 0; }; std::string serialize_license_state(const PolarLicenseState &state) { @@ -487,6 +761,9 @@ namespace lvh::detail::windows_broker_service { serialized << "customer_email=" << state.customer_email << "\n"; serialized << "expires_at=" << state.expires_at << "\n"; serialized << "activation_limit=" << state.activation_limit << "\n"; + serialized << "validated_at=" << state.validated_at << "\n"; + serialized << "boot_marker=" << state.boot_marker << "\n"; + serialized << "validated_uptime_ms=" << state.validated_uptime_ms << "\n"; return serialized.str(); } @@ -625,6 +902,12 @@ namespace lvh::detail::windows_broker_service { state.expires_at = value; } else if (key == "activation_limit") { state.activation_limit = static_cast(parse_uint64(value).value_or(0)); + } else if (key == "validated_at") { + state.validated_at = parse_uint64(value).value_or(0); + } else if (key == "boot_marker") { + state.boot_marker = value; + } else if (key == "validated_uptime_ms") { + state.validated_uptime_ms = parse_uint64(value).value_or(0); } } if (line_end == std::string_view::npos) { @@ -736,6 +1019,7 @@ namespace lvh::detail::windows_broker_service { struct PolarApiResult { bool transport_ok = false; DWORD http_status = 0; + std::optional server_time; std::string body; std::string error; }; @@ -750,6 +1034,10 @@ namespace lvh::detail::windows_broker_service { result.error = "WinHttpOpen failed: " + windows_error_message(::GetLastError()); return result; } + if (::WinHttpSetTimeouts(session.get(), polar_resolve_timeout, polar_connect_timeout, polar_send_timeout, polar_receive_timeout) == FALSE) { + result.error = "WinHttpSetTimeouts failed: " + windows_error_message(::GetLastError()); + return result; + } const auto connection = make_unique_winhttp_handle(::WinHttpConnect(session.get(), L"api.polar.sh", INTERNET_DEFAULT_HTTPS_PORT, 0)); if (!connection) { @@ -784,6 +1072,11 @@ namespace lvh::detail::windows_broker_service { WINHTTP_NO_HEADER_INDEX )); + SYSTEMTIME server_time {}; + if (DWORD server_time_size = sizeof(server_time); ::WinHttpQueryHeaders(request.get(), WINHTTP_QUERY_DATE | WINHTTP_QUERY_FLAG_SYSTEMTIME, WINHTTP_HEADER_NAME_BY_INDEX, &server_time, &server_time_size, WINHTTP_NO_HEADER_INDEX) != FALSE) { + result.server_time = license_server_time(server_time); + } + for (;;) { DWORD available = 0; if (::WinHttpQueryDataAvailable(request.get(), &available) == FALSE) { @@ -873,7 +1166,9 @@ namespace lvh::detail::windows_broker_service { return state; } - std::string_view plan_name_for_benefit(std::string_view benefit_id) { + const lvh::windows::broker_config::PolarBenefit *polar_benefit( + std::string_view benefit_id + ) { const auto benefit = std::ranges::find_if( lvh::windows::broker_config::allowed_benefits, [benefit_id](const auto &candidate) { @@ -881,8 +1176,13 @@ namespace lvh::detail::windows_broker_service { } ); return benefit == lvh::windows::broker_config::allowed_benefits.end() ? - std::string_view {} : - benefit->plan_name; + nullptr : + std::to_address(benefit); + } + + std::string_view plan_name_for_benefit(std::string_view benefit_id) { + const auto *benefit = polar_benefit(benefit_id); + return benefit == nullptr ? std::string_view {} : benefit->plan_name; } bool session_token_matches( @@ -904,6 +1204,19 @@ namespace lvh::detail::windows_broker_service { return request; } + template + void destroy_cleanup_candidates( + std::span requests, + DestroyDevice &&destroy_device, + ForgetDevice &&forget_device + ) { + for (const auto &request : requests) { + if (destroy_device(request)) { + forget_device(request); + } + } + } + DWORD pipe_client_process_id(HANDLE pipe) { ULONG process_id = 0; if (::GetNamedPipeClientProcessId(pipe, &process_id) == FALSE) { @@ -1076,6 +1389,32 @@ namespace lvh::detail::windows_broker_service { return true; } + bool reset_devices( + LvhWindowsBrokerStatusCode &status, + std::string &message + ) { + using enum LvhWindowsBrokerStatusCode; + + if (!open()) { + status = backend_unavailable; + message = "Windows UMDF control device is unavailable: " + windows_error_message(last_error_); + return false; + } + + LvhWindowsResetDevicesRequest request {}; + request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + request.size = sizeof(request); + if (DWORD bytes_returned = 0; ::DeviceIoControl(handle_.get(), LVH_WINDOWS_IOCTL_RESET_DEVICES, &request, sizeof(request), nullptr, 0, &bytes_returned, nullptr) == FALSE) { + status = backend_failure; + message = "reset Windows virtual HID devices: " + windows_error_message(::GetLastError()); + return false; + } + + status = success; + message.clear(); + return true; + } + private: UniqueHandle handle_ {nullptr, &::CloseHandle}; std::string path_; @@ -1091,7 +1430,28 @@ namespace lvh::detail::windows_broker_service { bool github_actions_evaluation = false; }; - BrokerState() = default; + BrokerState() { + auto ignored_status = LvhWindowsBrokerStatusCode::success; + std::string ignored_message; + static_cast(reconcile_driver_state(ignored_status, ignored_message)); + if (license_state_) { + std::lock_guard lock {mutex_}; + schedule_license_validation_locked(LicenseValidationClock::now(), true); + } + license_validation_thread_ = std::jthread { + [this](std::stop_token stop_token) { + license_validation_loop(stop_token); + } + }; + } + + ~BrokerState() { + license_validation_thread_.request_stop(); + license_validation_wakeup_.notify_all(); + if (license_validation_thread_.joinable()) { + license_validation_thread_.join(); + } + } void fill_license_status(LvhWindowsBrokerLicenseStatus &license) const { std::lock_guard lock {mutex_}; @@ -1125,6 +1485,14 @@ namespace lvh::detail::windows_broker_service { return response; } + auto reconciliation_status = LvhWindowsBrokerStatusCode::success; + if (std::string reconciliation_message; !reconcile_driver_state(reconciliation_status, reconciliation_message)) { + response.status = std::to_underlying(reconciliation_status); + copy_c_string(response.message, reconciliation_message); + fill_license_status(response.license); + return response; + } + auto owner_process = open_client_process(client_process_id); if (!owner_process) { response.status = std::to_underlying(LvhWindowsBrokerStatusCode::backend_failure); @@ -1236,7 +1604,12 @@ namespace lvh::detail::windows_broker_service { } void cleanup_devices() { - std::vector stale_devices; + auto reconciliation_status = LvhWindowsBrokerStatusCode::success; + if (std::string reconciliation_message; !reconcile_driver_state(reconciliation_status, reconciliation_message)) { + return; + } + + std::vector cleanup_requests; { std::lock_guard lock {mutex_}; const auto now = lvh::windows::github_actions_evaluation::Clock::now(); @@ -1247,19 +1620,56 @@ namespace lvh::detail::windows_broker_service { github_actions_evaluation_state_->started_at, now ); - std::erase_if(devices_, [&stale_devices, evaluation_expired](const auto &entry) { - if (const auto wait_result = ::WaitForSingleObject(entry.second.owner_process.get(), 0); wait_result == WAIT_OBJECT_0 || wait_result == WAIT_FAILED || (evaluation_expired && entry.second.github_actions_evaluation)) { - stale_devices.push_back(make_destroy_device_request(entry.first, entry.second.session_token)); - return true; + const auto license_now = LicenseValidationClock::now(); + if (license_expiration_has_elapsed_locked()) { + revoke_licensed_devices_ = true; + } + const auto revoke_licensed_devices = revoke_licensed_devices_; + const auto limit_licensed_devices = + license_validation_unavailable_since_ && + license_outage_retention_elapsed( + license_now - *license_validation_unavailable_since_ + ); + std::size_t licensed_devices_kept = 0; + for (const auto &[driver_device_id, device] : devices_) { + const auto wait_result = ::WaitForSingleObject(device.owner_process.get(), 0); + const auto owner_gone = wait_result == WAIT_OBJECT_0 || wait_result == WAIT_FAILED; + const auto revoke_for_license = broker_device_should_be_revoked( + device.github_actions_evaluation, + evaluation_expired, + revoke_licensed_devices + ); + if (const auto over_outage_limit = license_outage_gamepad_should_be_revoked(device.github_actions_evaluation, limit_licensed_devices, licensed_devices_kept); owner_gone || revoke_for_license || over_outage_limit) { + cleanup_requests.push_back(make_destroy_device_request(driver_device_id, device.session_token)); + continue; + } + if (!device.github_actions_evaluation) { + ++licensed_devices_kept; } - return false; - }); + } } - for (const auto &request : stale_devices) { - auto status = LvhWindowsBrokerStatusCode::success; - std::string message; - static_cast(driver_.destroy_device(request, status, message)); + destroy_cleanup_candidates( + cleanup_requests, + [this](const auto &request) { + auto status = LvhWindowsBrokerStatusCode::success; + std::string message; + return driver_.destroy_device(request, status, message); + }, + [this](const auto &request) { + std::lock_guard lock {mutex_}; + const auto iter = devices_.find(request.driver_device_id); + if (iter != devices_.end() && session_token_matches(iter->second.session_token, request.session_token)) { + devices_.erase(iter); + } + } + ); + + { + std::lock_guard lock {mutex_}; + if (revoke_licensed_devices_ && active_licensed_gamepad_count_locked() == 0U) { + revoke_licensed_devices_ = false; + } } } @@ -1289,6 +1699,8 @@ namespace lvh::detail::windows_broker_service { return response; } + std::lock_guard operation_lock {license_operation_mutex_}; + auto api_result = post_polar_license_request( L"/v1/customer-portal/license-keys/activate", nlohmann::json { @@ -1346,6 +1758,18 @@ namespace lvh::detail::windows_broker_service { return response; } + std::string authorization_error; + if (const auto authorization_result = accept_trusted_license_time(new_state, api_result.server_time, authorization_error); authorization_result != TrustedLicenseTimeResult::success) { + response.status = std::to_underlying( + authorization_result == TrustedLicenseTimeResult::license_invalid ? + LvhWindowsBrokerStatusCode::license_invalid : + LvhWindowsBrokerStatusCode::backend_failure + ); + copy_c_string(response.message, authorization_error); + fill_license_status(response.license); + return response; + } + if (std::string save_error; !save_license_state(new_state, save_error)) { response.status = std::to_underlying(LvhWindowsBrokerStatusCode::backend_failure); copy_c_string(response.message, save_error); @@ -1356,6 +1780,9 @@ namespace lvh::detail::windows_broker_service { { std::lock_guard lock {mutex_}; license_state_ = std::move(new_state); + license_online_confirmed_ = true; + license_validation_unavailable_since_.reset(); + schedule_license_validation_locked(LicenseValidationClock::now(), false); fill_license_status_locked(response.license); } response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); @@ -1381,6 +1808,7 @@ namespace lvh::detail::windows_broker_service { } const auto [status, message] = validate_saved_license(); + schedule_after_license_validation(status); response.status = std::to_underlying(status); fill_license_status(response.license); copy_c_string(response.message, message); @@ -1404,6 +1832,8 @@ namespace lvh::detail::windows_broker_service { return response; } + std::lock_guard operation_lock {license_operation_mutex_}; + PolarLicenseState state; { std::lock_guard lock {mutex_}; @@ -1432,10 +1862,15 @@ namespace lvh::detail::windows_broker_service { } if (api_result.http_status != 204U) { + if (api_result.http_status == 404U) { + invalidate_license(); + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + fill_license_status(response.license); + copy_c_string(response.message, "The machine activation was already absent; local license state was removed."); + return response; + } response.status = std::to_underlying( - api_result.http_status == 404U ? - LvhWindowsBrokerStatusCode::license_invalid : - LvhWindowsBrokerStatusCode::backend_failure + LvhWindowsBrokerStatusCode::backend_failure ); copy_c_string( response.message, @@ -1449,6 +1884,11 @@ namespace lvh::detail::windows_broker_service { { std::lock_guard lock {mutex_}; license_state_.reset(); + next_license_validation_attempt_.reset(); + license_online_confirmed_ = false; + license_validation_unavailable_since_.reset(); + revoke_licensed_devices_ = true; + license_validation_wakeup_.notify_all(); fill_license_status_locked(response.license); } response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); @@ -1457,19 +1897,231 @@ namespace lvh::detail::windows_broker_service { } private: + enum class TrustedLicenseTimeResult { + success, + license_invalid, + backend_failure, + }; + + bool reconcile_driver_state( + LvhWindowsBrokerStatusCode &status, + std::string &message + ) { + { + std::lock_guard lock {mutex_}; + if (driver_state_reconciled_) { + status = LvhWindowsBrokerStatusCode::success; + message.clear(); + return true; + } + } + + if (!driver_.reset_devices(status, message)) { + return false; + } + + std::lock_guard lock {mutex_}; + devices_.clear(); + driver_state_reconciled_ = true; + return true; + } + + void schedule_license_validation_locked( + LicenseValidationClock::time_point now, + bool immediately + ) { + if (!license_state_) { + next_license_validation_attempt_.reset(); + } else if (immediately) { + next_license_validation_attempt_ = now; + } else { + next_license_validation_attempt_ = now + license_validation_interval; + } + license_validation_wakeup_.notify_all(); + } + + void schedule_after_license_validation( + LvhWindowsBrokerStatusCode status + ) { + std::lock_guard lock {mutex_}; + if (!license_state_) { + next_license_validation_attempt_.reset(); + } else { + next_license_validation_attempt_ = LicenseValidationClock::now() + + (status == LvhWindowsBrokerStatusCode::success ? + license_validation_interval : + license_validation_retry_interval); + } + license_validation_wakeup_.notify_all(); + } + + void license_validation_loop(std::stop_token stop_token) { + while (!stop_token.stop_requested()) { + bool validation_due = false; + { + std::unique_lock lock {mutex_}; + static_cast(license_validation_wakeup_.wait_for( + lock, + std::chrono::minutes {1}, + [this, stop_token] { + return stop_token.stop_requested() || + (license_state_ && next_license_validation_attempt_ && + LicenseValidationClock::now() >= *next_license_validation_attempt_); + } + )); + if (stop_token.stop_requested()) { + return; + } + const auto now = LicenseValidationClock::now(); + validation_due = license_state_ && + next_license_validation_attempt_ && + now >= *next_license_validation_attempt_; + if (validation_due) { + next_license_validation_attempt_ = now + license_validation_retry_interval; + } + } + + if (!validation_due) { + continue; + } + const auto [status, ignored_message] = validate_saved_license(); + static_cast(ignored_message); + schedule_after_license_validation(status); + } + } + bool license_allowed(const PolarLicenseState &state) const { return !lvh::windows::broker_config::polar_organization_id.empty() && state.organization_id == lvh::windows::broker_config::polar_organization_id && !plan_name_for_benefit(state.benefit_id).empty(); } + TrustedLicenseTimeResult accept_trusted_license_time( + PolarLicenseState &state, + const std::optional &server_time, + std::string &message + ) const { + using enum TrustedLicenseTimeResult; + + if (!server_time) { + message = "The license service response did not include trusted server time."; + return backend_failure; + } + const auto *benefit = polar_benefit(state.benefit_id); + if (benefit == nullptr) { + message = "License organization or benefit is not allowed for this driver."; + return license_invalid; + } + const auto server_timestamp = license_calendar_timestamp(*server_time); + if (benefit->expiration_required) { + const auto expiration = parse_rfc3339_timestamp(state.expires_at); + if (!expiration) { + message = "The yearly license has an invalid expiration."; + return backend_failure; + } + if (server_timestamp >= license_calendar_timestamp(*expiration)) { + message = "The yearly license has expired."; + return license_invalid; + } + } + state.validated_at = server_timestamp; + state.boot_marker = boot_session_marker_; + state.validated_uptime_ms = ::GetTickCount64(); + if (state.boot_marker.empty()) { + message = "Unable to establish a trusted Windows boot-session clock."; + return backend_failure; + } + message.clear(); + return success; + } + + std::optional license_effective_timestamp_locked() const { + if (!license_state_) { + return std::nullopt; + } + return license_monotonic_calendar_timestamp( + license_state_->validated_at, + license_state_->validated_uptime_ms, + ::GetTickCount64(), + !boot_session_marker_.empty() && + license_state_->boot_marker == boot_session_marker_ + ); + } + + bool license_time_is_current_locked() const { + if (!license_state_ || license_state_->license_status != "granted") { + return false; + } + const auto *benefit = polar_benefit(license_state_->benefit_id); + if (benefit == nullptr) { + return false; + } + if (!benefit->expiration_required) { + return true; + } + const auto effective_timestamp = license_effective_timestamp_locked(); + return effective_timestamp && license_expiration_is_current( + license_state_->expires_at, + true, + *effective_timestamp + ); + } + + bool license_expiration_has_elapsed_locked() const { + if (!license_state_) { + return false; + } + const auto *benefit = polar_benefit(license_state_->benefit_id); + const auto effective_timestamp = license_effective_timestamp_locked(); + return benefit != nullptr && benefit->expiration_required && + effective_timestamp && + !license_expiration_is_current( + license_state_->expires_at, + true, + *effective_timestamp + ); + } + + void mark_license_validation_unavailable() { + std::lock_guard lock {mutex_}; + if (license_state_) { + license_online_confirmed_ = false; + if (!license_validation_unavailable_since_) { + license_validation_unavailable_since_ = LicenseValidationClock::now(); + } + } + } + + std::size_t active_licensed_gamepad_count_locked() const { + return static_cast(std::ranges::count_if( + devices_, + [](const auto &entry) { + return !entry.second.github_actions_evaluation; + } + )); + } + bool license_is_active_locked() const { return license_state_ && - license_state_->license_status == "granted" && - license_allowed(*license_state_); + license_allowed(*license_state_) && + license_time_is_current_locked(); + } + + void invalidate_license() { + { + std::lock_guard lock {mutex_}; + license_state_.reset(); + next_license_validation_attempt_.reset(); + license_online_confirmed_ = false; + license_validation_unavailable_since_.reset(); + revoke_licensed_devices_ = true; + license_validation_wakeup_.notify_all(); + } + delete_license_state(); } std::pair validate_saved_license() { + std::lock_guard operation_lock {license_operation_mutex_}; PolarLicenseState state; { std::lock_guard lock {mutex_}; @@ -1491,6 +2143,7 @@ namespace lvh::detail::windows_broker_service { } ); if (!api_result.transport_ok) { + mark_license_validation_unavailable(); return { LvhWindowsBrokerStatusCode::network_unavailable, api_result.error, @@ -1499,9 +2152,9 @@ namespace lvh::detail::windows_broker_service { if (api_result.http_status != 200U) { if (api_result.http_status == 404U) { - std::lock_guard lock {mutex_}; - license_state_.reset(); - delete_license_state(); + invalidate_license(); + } else { + mark_license_validation_unavailable(); } return { api_result.http_status == 404U ? @@ -1513,6 +2166,7 @@ namespace lvh::detail::windows_broker_service { const auto parsed = parse_json(api_result.body); if (!parsed) { + mark_license_validation_unavailable(); return { LvhWindowsBrokerStatusCode::backend_failure, "The license validation response was not valid JSON.", @@ -1521,9 +2175,7 @@ namespace lvh::detail::windows_broker_service { auto new_state = license_state_from_json(*parsed, state.license_key, {}); if (new_state.activation_id != state.activation_id) { - std::lock_guard lock {mutex_}; - license_state_.reset(); - delete_license_state(); + invalidate_license(); return { LvhWindowsBrokerStatusCode::license_invalid, "The license service did not validate this machine activation.", @@ -1533,9 +2185,7 @@ namespace lvh::detail::windows_broker_service { const auto error = new_state.license_status == "disabled" ? "License disabled." : "License revoked."; - std::lock_guard lock {mutex_}; - license_state_.reset(); - delete_license_state(); + invalidate_license(); return { LvhWindowsBrokerStatusCode::license_invalid, error, @@ -1543,16 +2193,30 @@ namespace lvh::detail::windows_broker_service { } if (!license_allowed(new_state)) { - std::lock_guard lock {mutex_}; - license_state_.reset(); - delete_license_state(); + invalidate_license(); return { LvhWindowsBrokerStatusCode::license_invalid, "License organization or benefit is not allowed for this driver.", }; } + std::string authorization_error; + if (const auto authorization_result = accept_trusted_license_time(new_state, api_result.server_time, authorization_error); authorization_result != TrustedLicenseTimeResult::success) { + if (authorization_result == TrustedLicenseTimeResult::license_invalid) { + invalidate_license(); + } else { + mark_license_validation_unavailable(); + } + return { + authorization_result == TrustedLicenseTimeResult::license_invalid ? + LvhWindowsBrokerStatusCode::license_invalid : + LvhWindowsBrokerStatusCode::backend_failure, + authorization_error, + }; + } + if (std::string save_error; !save_license_state(new_state, save_error)) { + mark_license_validation_unavailable(); return { LvhWindowsBrokerStatusCode::backend_failure, save_error, @@ -1562,6 +2226,8 @@ namespace lvh::detail::windows_broker_service { { std::lock_guard lock {mutex_}; license_state_ = std::move(new_state); + license_online_confirmed_ = true; + license_validation_unavailable_since_.reset(); } return { LvhWindowsBrokerStatusCode::success, @@ -1612,12 +2278,29 @@ namespace lvh::detail::windows_broker_service { ); return {LvhWindowsBrokerStatusCode::success, true}; } - } - const auto [status, message_text] = validate_saved_license(); - fill_license_status(license); - copy_c_string(message, message_text); - return {status, false}; + if (!license_allowed(*license_state_) || !license_time_is_current_locked()) { + fill_license_status_locked(license); + copy_c_string(message, license.message); + return {LvhWindowsBrokerStatusCode::license_invalid, false}; + } + + if (license_online_confirmed_) { + fill_license_status_locked(license); + copy_c_string(message, "License validated online."); + return {LvhWindowsBrokerStatusCode::success, false}; + } + + if (unvalidated_gamepad_creation_allowed(active_licensed_gamepad_count_locked())) { + fill_license_status_locked(license); + copy_c_string(message, "Polar validation is unavailable; one active gamepad is permitted."); + return {LvhWindowsBrokerStatusCode::success, false}; + } + + fill_license_status_locked(license); + copy_c_string(message, "Polar validation is unavailable; the one-gamepad fallback is already in use."); + return {LvhWindowsBrokerStatusCode::network_unavailable, false}; + } } void fill_license_status_locked(LvhWindowsBrokerLicenseStatus &license) const { @@ -1671,15 +2354,27 @@ namespace lvh::detail::windows_broker_service { if (!license_allowed(*license_state_)) { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); copy_c_string(license.message, "License organization or benefit is not allowed for this driver."); - } else if (license_state_->license_status == "granted") { - license.state = std::to_underlying(LvhWindowsBrokerLicenseState::licensed); - copy_c_string(license.message, "Licensed."); } else if (license_state_->license_status == "disabled") { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::disabled); copy_c_string(license.message, "License disabled."); } else if (license_state_->license_status == "revoked") { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); copy_c_string(license.message, "License revoked."); + } else if (!license_time_is_current_locked()) { + license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); + if (!license_effective_timestamp_locked().has_value()) { + copy_c_string(license.message, "Reconnect to Polar after Windows restarts to verify the yearly expiration."); + } else { + copy_c_string(license.message, "The yearly license has expired."); + } + } else if (license_state_->license_status == "granted") { + license.state = std::to_underlying(LvhWindowsBrokerLicenseState::licensed); + copy_c_string( + license.message, + license_online_confirmed_ ? + "Licensed." : + "Polar validation unavailable; existing gamepads are retained for one hour and new creation is limited to one active gamepad." + ); } else { license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); copy_c_string(license.message, "License is not granted."); @@ -1687,13 +2382,22 @@ namespace lvh::detail::windows_broker_service { } mutable std::mutex mutex_; + std::mutex license_operation_mutex_; + std::condition_variable license_validation_wakeup_; const bool github_actions_ = lizardbyte::common::is_github_actions(); + const std::string boot_session_marker_ {load_or_create_boot_session_marker()}; std::map devices_; DriverChannel driver_; std::optional license_state_ {load_license_state()}; + std::optional next_license_validation_attempt_; + std::optional license_validation_unavailable_since_; std::optional github_actions_evaluation_state_ { github_actions_ ? load_github_actions_evaluation_state() : std::nullopt }; + bool license_online_confirmed_ = false; + bool revoke_licensed_devices_ = false; + bool driver_state_reconciled_ = false; + std::jthread license_validation_thread_; }; BrokerState &broker_state() { diff --git a/src/platform/windows/control_protocol.hpp b/src/platform/windows/control_protocol.hpp index 13978c9..78d628c 100644 --- a/src/platform/windows/control_protocol.hpp +++ b/src/platform/windows/control_protocol.hpp @@ -187,6 +187,13 @@ namespace lvh::detail::windows { return make_destroy_device_request(driver_device_id, empty_session_token); } + inline LvhWindowsResetDevicesRequest make_reset_devices_request() { + LvhWindowsResetDevicesRequest request {}; + request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + request.size = sizeof(request); + return request; + } + inline LvhWindowsSubmitInputReportRequest make_submit_input_report_request( std::uint64_t driver_device_id, const LvhWindowsSessionToken &session_token, diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp index 7406bf1..eaa1af2 100644 --- a/src/platform/windows/driver/libvirtualhid_umdf.cpp +++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp @@ -479,6 +479,10 @@ namespace { return valid_header(request.version, request.size, sizeof(request)); } + bool valid_reset_devices_request(const LvhWindowsResetDevicesRequest &request) { + return valid_header(request.version, request.size, sizeof(request)); + } + bool session_token_matches(const DeviceRecord &record, const LvhWindowsSessionToken &session_token) { return std::memcmp(record.session_token.bytes, session_token.bytes, sizeof(record.session_token.bytes)) == 0; } @@ -920,6 +924,28 @@ namespace { complete_request(request, STATUS_SUCCESS); } + void handle_reset_devices_request(WDFDEVICE device, WDFREQUEST request) { + if (!request_is_authorized_broker_service(request)) { + complete_request(request, STATUS_ACCESS_DENIED); + return; + } + + auto *reset_request = static_cast(nullptr); + const auto status = retrieve_input_buffer(request, reset_request); + if (!NT_SUCCESS(status)) { + complete_request(request, status); + return; + } + + if (!valid_reset_devices_request(*reset_request)) { + complete_request(request, STATUS_INVALID_PARAMETER); + return; + } + + delete_vhf_devices_for_device(device); + complete_request(request, STATUS_SUCCESS); + } + void handle_submit_input_report_request(WDFREQUEST request) { auto *submit_request = static_cast(nullptr); const auto status = retrieve_input_buffer(request, submit_request); @@ -1202,6 +1228,10 @@ void LvhEvtIoDeviceControl( handle_read_output_report_request(request); return; + case LVH_WINDOWS_IOCTL_RESET_DEVICES: + handle_reset_devices_request(WdfIoQueueGetDevice(queue), request); + return; + default: complete_request(request, STATUS_INVALID_DEVICE_REQUEST); return; diff --git a/src/platform/windows/shared/lvh_windows_broker_config.hpp b/src/platform/windows/shared/lvh_windows_broker_config.hpp index 53279c1..4b5b4a6 100644 --- a/src/platform/windows/shared/lvh_windows_broker_config.hpp +++ b/src/platform/windows/shared/lvh_windows_broker_config.hpp @@ -17,6 +17,7 @@ namespace lvh::windows::broker_config { struct PolarBenefit { std::string_view id; std::string_view plan_name; + bool expiration_required; }; // Polar's public license API identifies the organization and license-key benefit, @@ -28,10 +29,12 @@ namespace lvh::windows::broker_config { PolarBenefit { .id = "eb316dac-bf6a-4359-95a2-86c299d48ecc", .plan_name = "Yearly", + .expiration_required = true, }, PolarBenefit { .id = "157374cb-f526-4154-81ba-9f2c92a053ca", .plan_name = "Lifetime", + .expiration_required = false, }, }; diff --git a/src/platform/windows/shared/lvh_windows_protocol.h b/src/platform/windows/shared/lvh_windows_protocol.h index 7fc6513..0ebbdc1 100644 --- a/src/platform/windows/shared/lvh_windows_protocol.h +++ b/src/platform/windows/shared/lvh_windows_protocol.h @@ -59,6 +59,12 @@ inline constexpr uint32_t LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT = lvh_windows_ctl LVH_WINDOWS_METHOD_BUFFERED, LVH_WINDOWS_FILE_READ_ACCESS ); +inline constexpr uint32_t LVH_WINDOWS_IOCTL_RESET_DEVICES = lvh_windows_ctl_code( + LVH_WINDOWS_FILE_DEVICE_LIBVIRTUALHID, + 0x804u, + LVH_WINDOWS_METHOD_BUFFERED, + LVH_WINDOWS_FILE_READ_ACCESS | LVH_WINDOWS_FILE_WRITE_ACCESS +); inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_RUMBLE = 0x00000001u; inline constexpr uint32_t LVH_WINDOWS_GAMEPAD_FLAG_SUPPORTS_MOTION = 0x00000002u; @@ -170,6 +176,7 @@ static const uint32_t LVH_WINDOWS_IOCTL_CREATE_GAMEPAD = 0x8000E000u; static const uint32_t LVH_WINDOWS_IOCTL_DESTROY_DEVICE = 0x8000E004u; static const uint32_t LVH_WINDOWS_IOCTL_SUBMIT_INPUT_REPORT = 0x8000E008u; static const uint32_t LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT = 0x8000600Cu; +static const uint32_t LVH_WINDOWS_IOCTL_RESET_DEVICES = 0x8000E010u; enum LvhWindowsProtocolStatus { LVH_WINDOWS_STATUS_SUCCESS = 0, @@ -256,6 +263,11 @@ extern "C" { LvhWindowsSessionToken session_token; }; + struct LvhWindowsResetDevicesRequest { + uint32_t version; + uint32_t size; + }; + struct LvhWindowsSubmitInputReportRequest { uint32_t version; uint32_t size; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d465399..a9c2db8 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -122,6 +122,7 @@ elseif(WIN32) PRIVATE nlohmann_json::nlohmann_json advapi32 + bcrypt crypt32 dinput8 dxguid diff --git a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp index ccbfa62..aaf8d7c 100644 --- a/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/windows_broker_service_test_hooks.hpp @@ -29,6 +29,7 @@ namespace lvh::detail::test { enum class BrokerPolarScenario { open_failure, + timeout_configuration_failure, connect_failure, request_failure, send_failure, @@ -41,10 +42,49 @@ namespace lvh::detail::test { struct BrokerPolarResult { bool transport_ok = false; std::uint32_t http_status = 0; + bool server_time_available = false; + bool timeouts_configured = false; + int resolve_timeout = 0; + int connect_timeout = 0; + int send_timeout = 0; + int receive_timeout = 0; std::string body; std::string error; }; BrokerPolarResult broker_polar_scenario(BrokerPolarScenario scenario); + struct BrokerLicenseFallbackResult { + bool before_boundary_is_current = false; + bool boundary_is_current = false; + bool lifetime_is_current = false; + bool same_boot_anchor_is_accepted = false; + bool changed_boot_anchor_is_rejected = false; + bool uptime_rollback_is_rejected = false; + bool first_unvalidated_gamepad_is_allowed = false; + bool second_unvalidated_gamepad_is_rejected = false; + bool existing_gamepads_are_retained_before_one_hour = false; + bool outage_limit_applies_at_one_hour = false; + bool first_gamepad_is_retained_after_one_hour = false; + bool excess_gamepad_is_revoked_after_one_hour = false; + bool evaluation_gamepad_is_not_outage_limited = false; + bool licensed_device_is_revoked = false; + bool evaluation_device_is_preserved = false; + bool monotonic_clock = false; + bool persisted_boot_anchor_round_trips = false; + std::uint64_t retry_interval_seconds = 0; + std::uint64_t monotonic_timestamp = 0; + }; + + BrokerLicenseFallbackResult broker_license_fallback_policy(); + + struct BrokerCleanupRetryResult { + bool failed_destruction_is_retained = false; + bool failed_destruction_is_retried = false; + bool successful_destruction_is_forgotten = false; + std::uint32_t destruction_attempts = 0; + }; + + BrokerCleanupRetryResult broker_cleanup_retry_policy(); + } // namespace lvh::detail::test diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 2e6f95a..52fb1ee 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -6,6 +6,9 @@ // local includes #include "fixtures/windows_backend_test_hooks.hpp" +// lib includes +#include + #define create_platform_backend create_platform_backend_for_windows_backend_test_hooks #include "../../src/platform/windows/windows_backend.cpp" #undef create_platform_backend diff --git a/tests/fixtures/windows_broker_service_test_hooks.cpp b/tests/fixtures/windows_broker_service_test_hooks.cpp index b004f17..ffe6260 100644 --- a/tests/fixtures/windows_broker_service_test_hooks.cpp +++ b/tests/fixtures/windows_broker_service_test_hooks.cpp @@ -43,6 +43,11 @@ namespace { HANDLE persistence_file = INVALID_HANDLE_VALUE; BrokerPolarScenario polar_scenario = BrokerPolarScenario::success; std::size_t polar_body_offset = 0; + bool timeouts_configured = false; + int resolve_timeout = 0; + int connect_timeout = 0; + int send_timeout = 0; + int receive_timeout = 0; }; BrokerServiceTestState &broker_service_test_state() { @@ -241,6 +246,26 @@ namespace { return &connection; } + BOOL WINAPI broker_test_win_http_set_timeouts( + HINTERNET, + int resolve_timeout, + int connect_timeout, + int send_timeout, + int receive_timeout + ) { + auto &state = broker_service_test_state(); + state.timeouts_configured = true; + state.resolve_timeout = resolve_timeout; + state.connect_timeout = connect_timeout; + state.send_timeout = send_timeout; + state.receive_timeout = receive_timeout; + if (state.polar_scenario == BrokerPolarScenario::timeout_configuration_failure) { + ::SetLastError(ERROR_INVALID_PARAMETER); + return FALSE; + } + return TRUE; + } + HINTERNET WINAPI broker_test_win_http_open_request( HINTERNET, LPCWSTR, @@ -284,12 +309,26 @@ namespace { BOOL WINAPI broker_test_win_http_query_headers( HINTERNET, - DWORD, + DWORD info_level, LPCWSTR, std::byte *buffer, LPDWORD buffer_length, LPDWORD ) { + if ((info_level & 0xFFFFU) == WINHTTP_QUERY_DATE) { + if (buffer == nullptr || buffer_length == nullptr || *buffer_length < sizeof(SYSTEMTIME)) { + ::SetLastError(ERROR_INSUFFICIENT_BUFFER); + return FALSE; + } + const SYSTEMTIME server_time { + .wYear = 2026, + .wMonth = 1, + .wDay = 1, + }; + std::memcpy(buffer, &server_time, sizeof(server_time)); + *buffer_length = sizeof(server_time); + return TRUE; + } if (buffer == nullptr || buffer_length == nullptr || *buffer_length < sizeof(DWORD)) { ::SetLastError(ERROR_INSUFFICIENT_BUFFER); return FALSE; @@ -360,6 +399,7 @@ namespace { broker_test_win_http_receive_response(request, static_cast(static_cast(reserved))) #define WinHttpSendRequest(request, headers, headers_length, optional, optional_length, total_length, context) \ broker_test_win_http_send_request(request, headers, headers_length, static_cast(static_cast(optional)), optional_length, total_length, context) +#define WinHttpSetTimeouts broker_test_win_http_set_timeouts #define WriteFile(file, buffer, bytes_to_write, bytes_written, overlapped) \ broker_test_write_file(file, static_cast(static_cast(buffer)), bytes_to_write, bytes_written, overlapped) #define main libvirtualhid_broker_test_main @@ -380,6 +420,7 @@ namespace { #undef WinHttpReadData #undef WinHttpReceiveResponse #undef WinHttpSendRequest +#undef WinHttpSetTimeouts #undef WriteFile #undef main @@ -417,8 +458,14 @@ namespace lvh::detail::test { } BrokerPolarResult broker_polar_scenario(BrokerPolarScenario scenario) { - broker_service_test_state().polar_scenario = scenario; - broker_service_test_state().polar_body_offset = 0; + auto &state = broker_service_test_state(); + state.polar_scenario = scenario; + state.polar_body_offset = 0; + state.timeouts_configured = false; + state.resolve_timeout = 0; + state.connect_timeout = 0; + state.send_timeout = 0; + state.receive_timeout = 0; const auto result = lvh::detail::windows_broker_service::post_polar_license_request( L"/test", @@ -427,9 +474,138 @@ namespace lvh::detail::test { return { .transport_ok = result.transport_ok, .http_status = result.http_status, + .server_time_available = result.server_time.has_value(), + .timeouts_configured = state.timeouts_configured, + .resolve_timeout = state.resolve_timeout, + .connect_timeout = state.connect_timeout, + .send_timeout = state.send_timeout, + .receive_timeout = state.receive_timeout, .body = result.body, .error = result.error, }; } + BrokerLicenseFallbackResult broker_license_fallback_policy() { + using namespace lvh::detail::windows_broker_service; + const auto expiration = parse_rfc3339_timestamp("2026-01-03T00:00:00Z"); + const auto expiration_timestamp = license_calendar_timestamp(*expiration); + PolarLicenseState persisted_state { + .provider = "polar", + .validated_at = 12345U, + .boot_marker = "test-boot-marker", + .validated_uptime_ms = 6789U, + }; + const auto restored_state = deserialize_license_state( + serialize_license_state(persisted_state) + ); + const auto monotonic_timestamp = license_monotonic_calendar_timestamp( + 1000U, + 5000U, + 65000U, + true + ); + const auto changed_boot_timestamp = license_monotonic_calendar_timestamp( + 1000U, + 5000U, + 65000U, + false + ); + const auto uptime_rollback_timestamp = license_monotonic_calendar_timestamp( + 1000U, + 65000U, + 5000U, + true + ); + return { + .before_boundary_is_current = license_expiration_is_current( + "2026-01-03T00:00:00Z", + true, + expiration_timestamp - 1U + ), + .boundary_is_current = license_expiration_is_current( + "2026-01-03T00:00:00Z", + true, + expiration_timestamp + ), + .lifetime_is_current = license_expiration_is_current({}, false, 0U), + .same_boot_anchor_is_accepted = monotonic_timestamp.has_value(), + .changed_boot_anchor_is_rejected = !changed_boot_timestamp.has_value(), + .uptime_rollback_is_rejected = !uptime_rollback_timestamp.has_value(), + .first_unvalidated_gamepad_is_allowed = unvalidated_gamepad_creation_allowed(0U), + .second_unvalidated_gamepad_is_rejected = !unvalidated_gamepad_creation_allowed(1U), + .existing_gamepads_are_retained_before_one_hour = + !license_outage_retention_elapsed( + license_outage_device_retention - std::chrono::milliseconds {1} + ), + .outage_limit_applies_at_one_hour = license_outage_retention_elapsed( + license_outage_device_retention + ), + .first_gamepad_is_retained_after_one_hour = + !license_outage_gamepad_should_be_revoked(false, true, 0U), + .excess_gamepad_is_revoked_after_one_hour = + license_outage_gamepad_should_be_revoked(false, true, 1U), + .evaluation_gamepad_is_not_outage_limited = + !license_outage_gamepad_should_be_revoked(true, true, 1U), + .licensed_device_is_revoked = broker_device_should_be_revoked( + false, + false, + true + ), + .evaluation_device_is_preserved = !broker_device_should_be_revoked( + true, + false, + true + ), + .monotonic_clock = LicenseValidationClock::is_steady, + .persisted_boot_anchor_round_trips = + restored_state.validated_at == persisted_state.validated_at && + restored_state.boot_marker == persisted_state.boot_marker && + restored_state.validated_uptime_ms == persisted_state.validated_uptime_ms, + .retry_interval_seconds = static_cast( + license_validation_retry_interval.count() + ), + .monotonic_timestamp = monotonic_timestamp.value_or(0U), + }; + } + + BrokerCleanupRetryResult broker_cleanup_retry_policy() { + using namespace lvh::detail::windows_broker_service; + std::vector tracked_devices {1U, 2U}; + std::array requests { + make_destroy_device_request(1U, {}), + make_destroy_device_request(2U, {}), + }; + auto attempts = std::uint32_t {}; + + destroy_cleanup_candidates( + requests, + [&attempts](const auto &request) { + ++attempts; + return request.driver_device_id == 2U; + }, + [&tracked_devices](const auto &request) { + std::erase(tracked_devices, request.driver_device_id); + } + ); + + const auto failed_destruction_is_retained = std::ranges::contains(tracked_devices, 1U); + destroy_cleanup_candidates( + std::span {requests}.first(1U), + [&attempts](const auto &) { + ++attempts; + return true; + }, + [&tracked_devices](const auto &request) { + std::erase(tracked_devices, request.driver_device_id); + } + ); + + return { + .failed_destruction_is_retained = failed_destruction_is_retained, + .failed_destruction_is_retried = !std::ranges::contains(tracked_devices, 1U), + .successful_destruction_is_forgotten = !std::ranges::contains(tracked_devices, 2U), + .destruction_attempts = attempts, + }; + } + } // namespace lvh::detail::test diff --git a/tests/package-consumer/CMakeLists.txt b/tests/package-consumer/CMakeLists.txt new file mode 100644 index 0000000..9bb813c --- /dev/null +++ b/tests/package-consumer/CMakeLists.txt @@ -0,0 +1,9 @@ +cmake_minimum_required(VERSION 3.24) +project(libvirtualhid_package_consumer LANGUAGES CXX) + +find_package(libvirtualhid CONFIG REQUIRED) + +add_executable(libvirtualhid_package_consumer main.cpp) +target_link_libraries(libvirtualhid_package_consumer + PRIVATE + libvirtualhid::libvirtualhid) diff --git a/tests/package-consumer/main.cpp b/tests/package-consumer/main.cpp new file mode 100644 index 0000000..71d1f5e --- /dev/null +++ b/tests/package-consumer/main.cpp @@ -0,0 +1,5 @@ +#include + +int main() { + return lvh::profiles::generic_gamepad().report_descriptor.empty() ? 1 : 0; +} diff --git a/tests/unit/test_windows_broker_service.cpp b/tests/unit/test_windows_broker_service.cpp index 75fac5c..dac7140 100644 --- a/tests/unit/test_windows_broker_service.cpp +++ b/tests/unit/test_windows_broker_service.cpp @@ -362,6 +362,7 @@ TEST(WindowsBrokerImplementationTest, ReportsDpapiAndPolarFailures) { using enum lvh::detail::test::BrokerPolarScenario; for (const auto scenario : { open_failure, + timeout_configuration_failure, connect_failure, request_failure, send_failure, @@ -387,7 +388,44 @@ TEST(WindowsBrokerImplementationTest, ReportsDpapiAndPolarFailures) { const auto succeeded = lvh::detail::test::broker_polar_scenario(success); EXPECT_TRUE(succeeded.transport_ok); EXPECT_EQ(succeeded.http_status, 200U); + EXPECT_TRUE(succeeded.server_time_available); EXPECT_TRUE(succeeded.error.empty()); + EXPECT_TRUE(succeeded.timeouts_configured); + EXPECT_EQ(succeeded.resolve_timeout, 5000); + EXPECT_EQ(succeeded.connect_timeout, 5000); + EXPECT_EQ(succeeded.send_timeout, 5000); + EXPECT_EQ(succeeded.receive_timeout, 10000); +} + +TEST(WindowsBrokerImplementationTest, EnforcesLimitedUnvalidatedFallback) { + const auto policy = lvh::detail::test::broker_license_fallback_policy(); + EXPECT_TRUE(policy.before_boundary_is_current); + EXPECT_FALSE(policy.boundary_is_current); + EXPECT_TRUE(policy.lifetime_is_current); + EXPECT_TRUE(policy.same_boot_anchor_is_accepted); + EXPECT_TRUE(policy.changed_boot_anchor_is_rejected); + EXPECT_TRUE(policy.uptime_rollback_is_rejected); + EXPECT_TRUE(policy.first_unvalidated_gamepad_is_allowed); + EXPECT_TRUE(policy.second_unvalidated_gamepad_is_rejected); + EXPECT_TRUE(policy.existing_gamepads_are_retained_before_one_hour); + EXPECT_TRUE(policy.outage_limit_applies_at_one_hour); + EXPECT_TRUE(policy.first_gamepad_is_retained_after_one_hour); + EXPECT_TRUE(policy.excess_gamepad_is_revoked_after_one_hour); + EXPECT_TRUE(policy.evaluation_gamepad_is_not_outage_limited); + EXPECT_TRUE(policy.licensed_device_is_revoked); + EXPECT_TRUE(policy.evaluation_device_is_preserved); + EXPECT_TRUE(policy.monotonic_clock); + EXPECT_TRUE(policy.persisted_boot_anchor_round_trips); + EXPECT_EQ(policy.retry_interval_seconds, 60U); + EXPECT_EQ(policy.monotonic_timestamp, 1060U); +} + +TEST(WindowsBrokerImplementationTest, RetainsFailedCleanupForRetry) { + const auto policy = lvh::detail::test::broker_cleanup_retry_policy(); + EXPECT_TRUE(policy.failed_destruction_is_retained); + EXPECT_TRUE(policy.failed_destruction_is_retried); + EXPECT_TRUE(policy.successful_destruction_is_forgotten); + EXPECT_EQ(policy.destruction_attempts, 3U); } TEST(WindowsBrokerImplementationTest, ReportsFilePersistenceFailures) { diff --git a/tests/unit/test_windows_protocol.cpp b/tests/unit/test_windows_protocol.cpp index b1a950b..51349b0 100644 --- a/tests/unit/test_windows_protocol.cpp +++ b/tests/unit/test_windows_protocol.cpp @@ -65,6 +65,7 @@ TEST(WindowsProtocolTest, ExposesStableProtocolConstants) { EXPECT_EQ(LVH_WINDOWS_IOCTL_DESTROY_DEVICE, 0x8000E004U); EXPECT_EQ(LVH_WINDOWS_IOCTL_SUBMIT_INPUT_REPORT, 0x8000E008U); EXPECT_EQ(LVH_WINDOWS_IOCTL_READ_OUTPUT_REPORT, 0x8000600CU); + EXPECT_EQ(LVH_WINDOWS_IOCTL_RESET_DEVICES, 0x8000E010U); EXPECT_EQ(sizeof(LvhWindowsGamepadHardwareIds), 14U); EXPECT_EQ(sizeof(LvhWindowsGamepadReportSizes), 24U); @@ -72,6 +73,7 @@ TEST(WindowsProtocolTest, ExposesStableProtocolConstants) { EXPECT_EQ(sizeof(LvhWindowsSessionToken), 32U); EXPECT_EQ(sizeof(LvhWindowsCreateGamepadResponse), 316U); EXPECT_EQ(sizeof(LvhWindowsDestroyDeviceRequest), 48U); + EXPECT_EQ(sizeof(LvhWindowsResetDevicesRequest), 8U); EXPECT_EQ(sizeof(LvhWindowsSubmitInputReportRequest), 312U); EXPECT_EQ(sizeof(LvhWindowsOutputReportEvent), 280U); } @@ -574,6 +576,10 @@ TEST(WindowsProtocolTest, PacksSubmitAndDestroyRequests) { EXPECT_EQ(destroy.driver_device_id, 17U); EXPECT_EQ(destroy.session_token.bytes[0], session_token.bytes[0]); EXPECT_EQ(destroy.session_token.bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE - 1U], session_token.bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE - 1U]); + + const auto reset = lvh::detail::windows::make_reset_devices_request(); + EXPECT_EQ(reset.version, LVH_WINDOWS_CONTROL_PROTOCOL_VERSION); + EXPECT_EQ(reset.size, sizeof(reset)); } TEST(WindowsProtocolTest, CompatibilityRequestHelpersUseEmptySessionToken) {