From 8a442ad4cdaadd665128b346c0fddbe16b3a790f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:28:55 -0400 Subject: [PATCH] Add Windows broker service and license API Introduces a brokered architecture for Windows UMDF gamepad creation: - Adds `libvirtualhid_broker` Windows service that gates gamepad creation and destruction behind Polar license validation - Adds session tokens to the control protocol so stale or unrelated clients cannot control devices they did not create - Adds driver-side broker SID check: only `NT SERVICE\libvirtualhid_broker` may create/destroy gamepads - Adds provider-neutral `license.hpp` public API (`get_license_status`, `activate_license`, `validate_license`, `deactivate_license`) with a stub for non-Windows platforms - Adds Windows broker client helpers and `windows_license.cpp` implementing the public API via the broker named pipe - Updates install/uninstall scripts to register, start, and stop the broker service with a safe quoted `ImagePath` and service SID - Updates `virtualhid_control` UI to show broker license status and provide activate/refresh/deactivate controls - Updates docs and store-review notes to reflect the broker, Polar licensing flow, and DPAPI-protected license state --- .github/workflows/ci.yml | 23 +- CMakeLists.txt | 32 + LICENSES/LicenseRef-LizardByte-SAL-1.0.md | 2 +- README.md | 10 + cmake/libvirtualhid-config.cmake.in | 2 + cmake/packaging/windows.cmake | 6 + .../libvirtualhid-driver-installer-patch.xml | 4 +- docs/store-review-validation.md | 18 +- docs/usage.md | 15 + docs/windows-driver.md | 126 +- gh-pages-template/_config.yml | 1 + gh-pages-template/index.html | 30 + gh-pages-template/virtual-hid-driver.html | 14 + package-lock.cmake | 12 + scripts/windows/install-driver.ps1 | 161 ++ scripts/windows/uninstall-driver.ps1 | 23 + src/CMakeLists.txt | 15 +- src/include/libvirtualhid/libvirtualhid.hpp | 1 + src/include/libvirtualhid/license.hpp | 105 + src/include/libvirtualhid/types.hpp | 4 + src/platform/license_unavailable.cpp | 40 + src/platform/windows/broker/CMakeLists.txt | 51 + .../windows/broker/libvirtualhid_broker.cpp | 1729 +++++++++++++++++ src/platform/windows/control_protocol.hpp | 20 +- src/platform/windows/driver/CMakeLists.txt | 4 +- .../windows/driver/libvirtualhid.inf.in | 2 +- .../windows/driver/libvirtualhid_umdf.cpp | 254 ++- .../shared/lvh_windows_broker_config.hpp | 44 + .../shared/lvh_windows_broker_protocol.h | 178 ++ .../lvh_windows_github_actions_evaluation.hpp | 53 + .../windows/shared/lvh_windows_protocol.h | 9 + src/platform/windows/windows_backend.cpp | 147 +- .../windows/windows_broker_client.cpp | 152 ++ .../windows/windows_broker_client.hpp | 77 + src/platform/windows/windows_license.cpp | 134 ++ tests/CMakeLists.txt | 7 +- tests/fixtures/windows_backend_test_hooks.cpp | 60 +- tests/unit/test_license.cpp | 102 + tests/unit/test_windows_protocol.cpp | 35 +- tools/CMakeLists.txt | 1 + tools/virtualhid_control.cpp | 240 ++- 41 files changed, 3872 insertions(+), 71 deletions(-) create mode 100644 src/include/libvirtualhid/license.hpp create mode 100644 src/platform/license_unavailable.cpp create mode 100644 src/platform/windows/broker/CMakeLists.txt create mode 100644 src/platform/windows/broker/libvirtualhid_broker.cpp create mode 100644 src/platform/windows/shared/lvh_windows_broker_config.hpp create mode 100644 src/platform/windows/shared/lvh_windows_broker_protocol.h create mode 100644 src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp create mode 100644 src/platform/windows/windows_broker_client.cpp create mode 100644 src/platform/windows/windows_broker_client.hpp create mode 100644 src/platform/windows/windows_license.cpp create mode 100644 tests/unit/test_license.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1263f4..2d1ea48 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -300,6 +300,21 @@ jobs: throw "Windows driver installer exited with code $($process.ExitCode)." } + - name: Enable GitHub Actions evaluation window + if: runner.os == 'Windows' + shell: pwsh + run: | + $serviceName = "libvirtualhid_broker" + $serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName" + New-ItemProperty ` + -LiteralPath $serviceRegistryPath ` + -Name Environment ` + -PropertyType MultiString ` + -Value @("GITHUB_ACTIONS=true") ` + -Force | Out-Null + Restart-Service -Name $serviceName -Force + (Get-Service -Name $serviceName).WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) + - name: Verify Windows test driver package if: runner.os == 'Windows' shell: pwsh @@ -320,6 +335,9 @@ jobs: -Verbose } + - name: Run gamepad adapter example + run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --target run_gamepad_adapter_example + - name: Prepare report directory run: cmake -E make_directory cmake-build-ci/reports @@ -384,9 +402,6 @@ jobs: $coverage.Save($coveragePath) - - name: Run gamepad adapter example - run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --target run_gamepad_adapter_example - - name: Generate gcov report id: test_report if: >- @@ -506,7 +521,7 @@ jobs: run: >- cmake --build cmake-build-driver --config ${{ env.DRIVER_BUILD_CONFIG }} - --target libvirtualhid_windows_catalog gamepad_adapter virtualhid_control + --target libvirtualhid_windows_catalog libvirtualhid_broker gamepad_adapter virtualhid_control --parallel 2 - name: Validate Azure signing configuration diff --git a/CMakeLists.txt b/CMakeLists.txt index 81fe07b..7c4dc1e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,9 @@ option(LIBVIRTUALHID_TOOLS_FULLY_STATIC "Attempt to link libvirtualhid tools as option(LIBVIRTUALHID_TOOLS_STATIC_SDL3 "Prefer a static SDL3 diagnostic UI dependency when available" ON) option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on Linux" ON) option(LIBVIRTUALHID_BUILD_WINDOWS_DRIVER "Build the Windows UMDF2 driver package with the WDK/MSVC toolchain" OFF) +option(LIBVIRTUALHID_BUILD_WINDOWS_BROKER + "Build the Windows broker service used by the monetized UMDF driver package" + ${LIBVIRTUALHID_BUILD_WINDOWS_DRIVER}) option(LIBVIRTUALHID_INSTALL "Install libvirtualhid targets, headers, and CMake package files" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(LIBVIRTUALHID_ENABLE_PACKAGING "Enable CPack package metadata" ${LIBVIRTUALHID_INSTALL}) @@ -67,6 +70,31 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTS AND NOT CMAKE_CXX_CO set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") endif() +set(LIBVIRTUALHID_USES_LIZARDBYTE_COMMON OFF) +if(WIN32) + set(LIBVIRTUALHID_USES_LIZARDBYTE_COMMON ON) +endif() + +if(LIBVIRTUALHID_USES_LIZARDBYTE_COMMON OR BUILD_TESTS) + set(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT + ${BUILD_TESTS} + CACHE BOOL "Build lizardbyte-common GoogleTest support helpers" FORCE) + set(LIZARDBYTE_COMMON_INSTALL + ${LIBVIRTUALHID_INSTALL} + CACHE BOOL "Install lizardbyte-common targets and package configuration" FORCE) + if(NOT TARGET lizardbyte::common) + add_subdirectory(third-party/lizardbyte-common) + endif() + if(MSVC AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) + set_property(TARGET lizardbyte_common PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + if(TARGET lizardbyte_common_test_support) + set_property(TARGET lizardbyte_common_test_support PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") + endif() + endif() +endif() + # Copy MinGW runtime DLLs beside a target when using GNU toolchains on Windows. function(libvirtualhid_copy_mingw_runtime target_name) if(NOT WIN32 OR NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") @@ -103,6 +131,10 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) add_subdirectory(src/platform/windows/driver) endif() + if(WIN32 AND LIBVIRTUALHID_BUILD_WINDOWS_BROKER) + add_subdirectory(src/platform/windows/broker) + endif() + if(BUILD_DOCS) add_subdirectory(third-party/doxyconfig docs) endif() diff --git a/LICENSES/LicenseRef-LizardByte-SAL-1.0.md b/LICENSES/LicenseRef-LizardByte-SAL-1.0.md index 5b82033..077afbc 100644 --- a/LICENSES/LicenseRef-LizardByte-SAL-1.0.md +++ b/LICENSES/LicenseRef-LizardByte-SAL-1.0.md @@ -1,7 +1,7 @@ LIZARDBYTE SOURCE-AVAILABLE LICENSE Version 1.0, May 2026 -Copyright (C) 2026 David Lane. +Copyright (C) 2026 LIZARDBYTE LLC. The Licensor may modify this license document at any time and for any reason. Everyone else is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. diff --git a/README.md b/README.md index b1f66a4..220714c 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,16 @@ SonarCloud +
+

🎮 Windows Virtual HID Driver License

+

+ A license is required to create virtual gamepads with the Windows driver.
+ This requirement is Windows-only; non-Windows backends do not currently require a license.
+ Yearly and lifetime options are available. +

+ Buy a Windows license +
+ # Overview ## ℹ️ About diff --git a/cmake/libvirtualhid-config.cmake.in b/cmake/libvirtualhid-config.cmake.in index 55f7fe6..8b5855c 100644 --- a/cmake/libvirtualhid-config.cmake.in +++ b/cmake/libvirtualhid-config.cmake.in @@ -8,6 +8,8 @@ if(@LIBVIRTUALHID_USES_THREADS@) pkg_check_modules(LIBEVDEV REQUIRED IMPORTED_TARGET libevdev) endif() +find_dependency(lizardbyte-common) + include("${CMAKE_CURRENT_LIST_DIR}/libvirtualhid-targets.cmake") check_required_components(libvirtualhid) diff --git a/cmake/packaging/windows.cmake b/cmake/packaging/windows.cmake index ced7e13..ba84047 100644 --- a/cmake/packaging/windows.cmake +++ b/cmake/packaging/windows.cmake @@ -34,6 +34,12 @@ if(NOT TARGET virtualhid_control) "so the virtualhid_control UI tool can be packaged.") endif() +if(NOT TARGET libvirtualhid_broker) + message(FATAL_ERROR + "The Windows driver installer requires LIBVIRTUALHID_BUILD_WINDOWS_BROKER=ON " + "so the broker service can be packaged.") +endif() + install(TARGETS gamepad_adapter RUNTIME DESTINATION "tools/windows" COMPONENT driver) diff --git a/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml b/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml index 8387faf..ad8be0e 100644 --- a/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml +++ b/cmake/packaging/wix_resources/libvirtualhid-driver-installer-patch.xml @@ -2,13 +2,13 @@ diff --git a/docs/store-review-validation.md b/docs/store-review-validation.md index cccfdf9..eb5162b 100644 --- a/docs/store-review-validation.md +++ b/docs/store-review-validation.md @@ -13,7 +13,9 @@ Driver Store. It is not a kernel-mode `.sys` driver. Paste this into the Partner Center certification notes field: ```text -This package installs the libvirtualhid Windows user-mode UMDF/VHF virtual HID driver. Applications consume it through the libvirtualhid client API, and the MSI includes a native diagnostic UI for local validation. +This package installs the libvirtualhid Windows user-mode UMDF/VHF virtual HID driver and local broker service. Applications consume it through the libvirtualhid client API, and the MSI includes a native diagnostic UI for local validation. + +Every virtual gamepad creation requires an active license. A currently granted review license key with an available device activation is supplied separately in the Partner Center certification credentials or notes. The key is not embedded in the package or this document. Launch the validation tool below. @@ -23,15 +25,18 @@ C:\Program Files\libvirtualhid Installed validation files: C:\Program Files\libvirtualhid\tools\windows\virtualhid_control.exe C:\Program Files\libvirtualhid\tools\windows\gamepad_adapter.exe +C:\Program Files\libvirtualhid\services\windows\libvirtualhid_broker.exe Required validation: $installRoot = Join-Path $env:ProgramFiles "libvirtualhid" & "$installRoot\tools\windows\virtualhid_control.exe" -In the libvirtualhid control window, leave the default Xbox Series profile selected and click Create. Use the button and axis controls in the UI to submit input to the virtual controller. +In the libvirtualhid control window, paste the supplied review key into the License key field and click Activate license. Confirm the status changes to Licensed. Then leave the default Xbox Series profile selected and click Create. Use the button and axis controls in the UI to submit input to the virtual controller. Expected result: - The backend status reports windows-umdf with gamepad support available +- The libvirtualhid_broker service is running +- License validation succeeds and the license status reports Licensed - A virtual HID gamepad is created and appears in the device list - A virtual HID gamepad child device starts with the Xbox Series HID ID HID\VID_045E&PID_0B12&IG_00 @@ -59,7 +64,9 @@ Expected result: 2. Reboot only if Windows reports that a reboot is required. 3. Open PowerShell. 4. Run the required validation tool from the submission notes. -5. Optionally run the browser validation steps. +5. Activate the review key supplied through Partner Center. +6. Create the default gamepad and exercise its controls. +7. Optionally run the browser validation steps. If the default install location was changed during MSI installation, replace `$env:ProgramFiles\libvirtualhid` with the selected install directory. @@ -76,5 +83,6 @@ The `x360` profile is not used for Store review. The Windows UMDF/VHF backend is HID-only and intentionally does not emulate the Xbox 360 XUSB stack. The reviewer-visible success signal is the installed `ROOT\LIBVIRTUALHID` -control device, the `\\.\LibVirtualHid` control path, and a started HID gamepad -child device while `virtualhid_control.exe` has a gamepad created. +control device, the `\\.\LibVirtualHid` control path, the running +`libvirtualhid_broker` service, and a started HID gamepad child device while +`virtualhid_control.exe` has a gamepad created. diff --git a/docs/usage.md b/docs/usage.md index 75b9b60..bc9389e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -76,6 +76,9 @@ unless they explicitly enable additional options. fallback. - `LIBVIRTUALHID_BUILD_WINDOWS_DRIVER`: build the Windows UMDF2 driver package with the Microsoft WDK/MSVC toolchain. +- `LIBVIRTUALHID_BUILD_WINDOWS_BROKER`: build the Windows broker service used by + the driver package for gamepad creation, active-device limits, and license + state. - `LIBVIRTUALHID_ENABLE_PACKAGING`: enable CPack package metadata. - `LIBVIRTUALHID_WARNINGS_AS_ERRORS`: treat project warnings as errors. @@ -102,6 +105,14 @@ capabilities, list device nodes reported for UI-created devices, and display 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. Outside the explicitly marked GitHub +Actions test environment, every Windows UMDF gamepad creation requires a +current successful license validation response and there is no offline grace +period. The CI-only exception is a single five-minute window that begins with +the first gamepad creation attempt. Purchase and account-management buttons use +the compiled URLs in +`src/platform/windows/shared/lvh_windows_broker_config.hpp`. enable `Lock buttons` to keep the old click-to-toggle behavior for held inputs. The resizable window supports a compact width. Its device and control panels stack, and the button grid reflows, to keep controls usable when it is narrowed. @@ -119,6 +130,10 @@ The API centers on portable device concepts: - `Runtime`: owns backend discovery, initialization, device creation, and shutdown. +- `get_license_status`, `activate_license`, `validate_license`, and + `deactivate_license`: provider-neutral machine license operations for host + applications. On Windows these call the installed local broker; license keys + are not retained by the client library or returned to the application. - `VirtualDevice`: common lifecycle for created devices. - `Gamepad`: submits normalized gamepad state and receives output callbacks. - `Keyboard`: submits key press/release and UTF-8 text input. diff --git a/docs/windows-driver.md b/docs/windows-driver.md index 697eab3..b0c8b70 100644 --- a/docs/windows-driver.md +++ b/docs/windows-driver.md @@ -30,11 +30,37 @@ devices. ## Architecture -The backend opens the libvirtualhid control device and sends fixed-size C -protocol structures with `DeviceIoControl`. Create requests start a VHF child -device from the requested descriptor, VID/PID, version, and report layout. Input -reports are submitted through VHF, and HID output writes are normalized back to -the C++ output callback path. +Windows gamepad creation is brokered by `libvirtualhid_broker`. The normal C++ +backend asks the broker service to create and destroy gamepads through a local +named pipe, while input reports stay on the direct driver path after creation. +This keeps license and active-device checks outside the input hot path. + +The broker pipe explicitly grants local authenticated users generic read access +plus the individual data-write and attribute-write rights needed to exchange +request and response messages in message mode. It does not grant clients the +right to create pipe instances, and it rejects remote clients. This allows a +normal desktop application to use the broker without running as administrator +while keeping broker ownership and privileged device operations in the Windows +service. + +The backend sends fixed-size C protocol structures to the broker. A create +request identifies the backend's existing control handle; the broker duplicates +that handle from the named-pipe client process and issues `DeviceIoControl` on +the same file object. This starts a VHF child device from the requested +descriptor, VID/PID, version, and report layout while preserving handle-scoped +output delivery. The driver returns a per-device session token, and +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 library and installed driver must use the same control-protocol version. Protocol version 2 expands the report-descriptor capacity to 2048 bytes for the @@ -42,18 +68,23 @@ complete DirectInput PID descriptor; a version mismatch is rejected rather than interpreting a differently sized request. Each backend runtime uses one control-file handle for commands and its pending -output read. The driver associates output events with that handle, so feedback -from a virtual gamepad is delivered only to the runtime that created it instead -of being consumed by another libvirtualhid client. +output read. Broker protocol version 2 preserves that association by duplicating +the handle only for the authorized create IOCTL. The driver associates output +events with that file object, so feedback from a virtual gamepad is delivered +only to the runtime that created it instead of being consumed by another +libvirtualhid client. The driver opens a separate VHF source target for each virtual gamepad and 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. +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. The backend reports `requires_installed_driver = true` and only advertises -gamepad/output-report support when the control device can be opened. Keyboard -and mouse support do not require the driver package. +gamepad/output-report support when the broker is reachable and the control +device can be opened. Keyboard and mouse support do not require the driver +package. ## Build @@ -64,7 +95,7 @@ cmake -S . -B cmake-build-windows-driver -G "Visual Studio 17 2022" -A x64 ` -DLIBVIRTUALHID_BUILD_WINDOWS_DRIVER=ON -DLIBVIRTUALHID_ENABLE_PACKAGING=ON ` -DBUILD_TESTS=OFF -DBUILD_EXAMPLES=ON -DLIBVIRTUALHID_BUILD_TOOLS=ON cmake --build cmake-build-windows-driver --config Release ` - --target libvirtualhid_windows_catalog gamepad_adapter virtualhid_control + --target libvirtualhid_windows_catalog libvirtualhid_broker gamepad_adapter virtualhid_control cpack -G WIX -C Release --config .\cmake-build-windows-driver\CPackConfig.cmake ``` @@ -80,6 +111,7 @@ Developer helpers live under `scripts/windows`: ```powershell powershell -ExecutionPolicy Bypass -File .\scripts\windows\install-driver.ps1 ` -InfPath .\cmake-build-windows-driver\src\platform\windows\driver\package\Release\libvirtualhid.inf ` + -BrokerPath .\cmake-build-windows-driver\src\platform\windows\broker\Release\libvirtualhid_broker.exe ` -LogPath .\cmake-build-windows-driver\install-driver.log powershell -ExecutionPolicy Bypass -File .\scripts\windows\test-installed-driver.ps1 ` -GamepadAdapterPath .\cmake-build-windows-driver\examples\Release\gamepad_adapter.exe ` @@ -96,6 +128,7 @@ The WiX installer also places validation files under the default install root, - `tools\windows\gamepad_adapter.exe` - `tools\windows\virtualhid_control.exe` +- `services\windows\libvirtualhid_broker.exe` The source-tree validation scripts remain developer and CI helpers. They are not packaged as reviewer-facing MSI validation scripts because the native @@ -105,7 +138,14 @@ gamepads interactively. The install helper stages the INF with `pnputil`, updates an existing `ROOT\LIBVIRTUALHID` device when present, and creates that root-enumerated device when it is missing. It uses SetupAPI/NewDev directly, so MSI installs do -not require WDK tools on the target machine. +not require WDK tools on the target machine. When a broker executable is present, +the helper also installs and starts the `libvirtualhid_broker` Windows service +with a service SID. The service `ImagePath` is stored as a literal quoted path, +and installation fails if the registry value is not safely quoted. This avoids +CWE-428 unquoted-service-path escalation when the install root contains spaces. +The install helper also clears any legacy broker service `Environment` value so +licensing configuration cannot be overridden on the user's machine. The uninstall +helper stops and deletes that service before removing the driver package. The installed-driver test fails if the root device is not started, if `\\.\LibVirtualHid` cannot be opened, or if a held `gamepad_adapter` instance @@ -138,6 +178,10 @@ raw output events. Devices created by another process are not listed yet; that requires a future Windows control-protocol extension for cross-process diagnostics. +On Windows, the UI also shows broker license status. It can activate a license +key, refresh validation, deactivate the current machine, and open +compiled purchase or account-management URLs. + ## Installation Notes The driver binary is a user-mode UMDF DLL installed through the Windows Driver @@ -146,7 +190,18 @@ Windows still uses its built-in `WUDFRd.sys` and VHF components under `System32\drivers`. The libvirtualhid-specific sign that installation completed is the -`ROOT\LIBVIRTUALHID` root device and the `\\.\LibVirtualHid` control device. +`ROOT\LIBVIRTUALHID` root device, the `\\.\LibVirtualHid` control device, and +the running `libvirtualhid_broker` service. + +Host applications can present the same license workflow through the installed +public C++ API. Include `libvirtualhid/license.hpp` (or the aggregate +`libvirtualhid/libvirtualhid.hpp`) and call `get_license_status`, +`activate_license`, `validate_license`, or `deactivate_license`. The API uses +provider-neutral types, sends activation keys directly to the local broker, +and returns purchase and account-management URLs with the status. Applications +must treat activation keys as transient secrets and must not persist or log +them. + Development driver builds write a lightweight UMDF trace to: ```text @@ -157,6 +212,49 @@ During rapid development reinstalls, the fixed global control symbolic link can briefly outlive the previous root device. The driver treats that collision as non-fatal, and normal clients discover the PnP control device interface first. +The broker stores machine-scoped license state in: + +```text +C:\ProgramData\libvirtualhid\license.dat +``` + +The file is protected with Windows DPAPI local-machine scope. GitHub Actions +evaluation timing is stored separately with the same protection in +`C:\ProgramData\libvirtualhid\github-actions-evaluation.dat`. Broker entitlement +configuration is compiled into the Windows broker and diagnostic UI. Update +`src/platform/windows/shared/lvh_windows_broker_config.hpp` when the Polar +organization ID, allowed license-key benefit IDs, Checkout Links, customer +portal URL changes, then rebuild the Windows package. No Polar access token or +webhook secret is compiled into the client: +activation, validation, and deactivation use Polar's +[public customer license-key API](https://polar.sh/docs/features/benefits/license-keys). + +The production configuration accepts organization +`3db9f05a-44d7-42f1-ba7c-a0f198235fb7` with yearly license-key benefit +`eb316dac-bf6a-4359-95a2-86c299d48ecc` or lifetime license-key benefit +`157374cb-f526-4154-81ba-9f2c92a053ca`. Polar's public response identifies the +benefit rather than the purchased product, so the broker fails closed unless the +returned organization and benefit are both allow-listed. The purchase button +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 +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 +start survives broker restarts, clock rollback expires the window, and the +broker destroys evaluation-created devices when the deadline is reached. +Setting `GITHUB_ACTIONS` only in a consuming application does not affect the +separately running service. + +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. + ## Profile Compatibility The Windows backend publishes HID gamepads through VHF. DirectInput, SDL/HIDAPI, diff --git a/gh-pages-template/_config.yml b/gh-pages-template/_config.yml index 4969f38..06307ff 100644 --- a/gh-pages-template/_config.yml +++ b/gh-pages-template/_config.yml @@ -2,3 +2,4 @@ # See https://github.com/LizardByte/beautiful-jekyll-next/blob/master/_config.yml for documented options avatar: "/assets/img/navbar-avatar.png" +windows_license_purchase_url: "https://buy.polar.sh/polar_cl_zj6Io5NVukXfZSl97ULtFvImfI5L1jbL2cSnc0Y72Pt" diff --git a/gh-pages-template/index.html b/gh-pages-template/index.html index dd4c3aa..e8d2b42 100644 --- a/gh-pages-template/index.html +++ b/gh-pages-template/index.html @@ -21,6 +21,36 @@ + +
+
+
+
+
+
+ Windows only +

Create virtual gamepads on Windows

+

+ A license is required for virtual gamepads created through the Windows Virtual HID + Driver. Non-Windows backends do not currently require a license. Choose a yearly or + lifetime license at checkout. +

+
+ +
+
+
+
+
+
diff --git a/gh-pages-template/virtual-hid-driver.html b/gh-pages-template/virtual-hid-driver.html index 24ba677..91d1e65 100644 --- a/gh-pages-template/virtual-hid-driver.html +++ b/gh-pages-template/virtual-hid-driver.html @@ -18,6 +18,20 @@ component that compatible applications use to create virtual HID gamepads discoverable by Windows apps. The driver package is separate from the portable C++ library and is currently packaged for AMD64 systems.

+
+
+ Windows only +

Get your Virtual HID Driver license

+

+ A license is required to create virtual gamepads with this Windows driver. Non-Windows backends + do not currently require a license. Choose a yearly or lifetime license at checkout. +

+ + Windows + Buy a Windows license + +
+
GitHub diff --git a/package-lock.cmake b/package-lock.cmake index 0c59553..badbeb9 100644 --- a/package-lock.cmake +++ b/package-lock.cmake @@ -63,3 +63,15 @@ CPMDeclarePackage(imgui DOWNLOAD_ONLY YES FORCE YES ) + +# nlohmann JSON +# renovate: datasource=github-tags depName=nlohmann/json +# versioning=regex:^v(?\d+)\.(?\d+)\.(?\d+)$ +# extractVersion=^v(?.*)$ +set(NLOHMANN_JSON_VERSION 3.12.0) +CPMDeclarePackage(nlohmann_json + NAME nlohmann_json + VERSION ${NLOHMANN_JSON_VERSION} + GITHUB_REPOSITORY nlohmann/json + GIT_TAG v${NLOHMANN_JSON_VERSION} +) diff --git a/scripts/windows/install-driver.ps1 b/scripts/windows/install-driver.ps1 index 1444501..26515f1 100644 --- a/scripts/windows/install-driver.ps1 +++ b/scripts/windows/install-driver.ps1 @@ -11,6 +11,8 @@ param( [string] $HardwareId = "ROOT\LIBVIRTUALHID", + [string] $BrokerPath, + [string] $LogPath, [switch] $StageOnly @@ -18,6 +20,8 @@ param( $ErrorActionPreference = "Stop" $script:LibVirtualHidTranscriptStarted = $false +$script:LibVirtualHidBrokerServiceName = "libvirtualhid_broker" +$script:LibVirtualHidBrokerServiceDisplayName = "libvirtualhid Broker" . (Join-Path $PSScriptRoot "libvirtualhid-driver-common.ps1") function Start-LibVirtualHidTranscript { @@ -76,6 +80,161 @@ function Invoke-CheckedCommand { } } +function Resolve-LibVirtualHidBrokerPath { + param([string] $Path) + + if ($Path) { + if (-not (Test-Path -LiteralPath $Path)) { + throw "The broker executable was not found at $Path" + } + return (Resolve-Path -LiteralPath $Path).Path + } + + $packagedPath = Join-Path $PSScriptRoot "..\..\services\windows\libvirtualhid_broker.exe" + if (Test-Path -LiteralPath $packagedPath) { + return (Resolve-Path -LiteralPath $packagedPath).Path + } + + return $null +} + +function Get-LibVirtualHidQuotedServiceBinaryPath { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + if ($Path.Contains('"')) { + throw "The broker executable path must not contain quotation marks: $Path" + } + + return "`"$Path`"" +} + +function Get-LibVirtualHidScBinaryPathArgument { + param( + [Parameter(Mandatory = $true)] + [string] $Path + ) + + # Windows PowerShell 5.1 needs triple quotes in a native-command argument to pass one literal quote + # through to sc.exe while keeping a path containing spaces in a single argv element. + return (Get-LibVirtualHidQuotedServiceBinaryPath -Path $Path).Replace('"', '"""') +} + +function Assert-LibVirtualHidBrokerServiceImagePath { + param( + [Parameter(Mandatory = $true)] + [string] $Name, + + [Parameter(Mandatory = $true)] + [string] $Path + ) + + $registryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$Name" + $imagePath = (Get-ItemProperty -LiteralPath $registryPath -Name "ImagePath").ImagePath + $expectedPath = Get-LibVirtualHidQuotedServiceBinaryPath -Path $Path + if ($imagePath -cne $expectedPath) { + throw "The $Name service ImagePath is not safely quoted. Expected $expectedPath but found $imagePath." + } +} + +function Clear-LibVirtualHidBrokerServiceEnvironment { + [CmdletBinding(SupportsShouldProcess)] + param([string] $Name) + + $registryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$Name" + if (-not (Test-Path -LiteralPath $registryPath)) { + return + } + + if ($PSCmdlet.ShouldProcess($Name, "Clear legacy libvirtualhid broker service environment")) { + Remove-ItemProperty -LiteralPath $registryPath -Name "Environment" -ErrorAction SilentlyContinue + } +} + +function Stop-LibVirtualHidBrokerService { + [CmdletBinding(SupportsShouldProcess)] + param([string] $Name) + + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if (-not $service -or $service.Status -eq "Stopped") { + return + } + + if ($PSCmdlet.ShouldProcess($Name, "Stop libvirtualhid broker service")) { + Stop-Service -Name $Name -Force -ErrorAction Stop + $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) + } +} + +function Install-LibVirtualHidBrokerService { + [CmdletBinding(SupportsShouldProcess)] + param([string] $Path) + + $resolvedBroker = Resolve-LibVirtualHidBrokerPath -Path $Path + if (-not $resolvedBroker) { + Write-Verbose "No libvirtualhid broker executable was found; skipping broker service registration." + return + } + + $serviceBinaryPath = Get-LibVirtualHidScBinaryPathArgument -Path $resolvedBroker + $serviceRegistered = $false + $service = Get-Service -Name $script:LibVirtualHidBrokerServiceName -ErrorAction SilentlyContinue + if ($service) { + Stop-LibVirtualHidBrokerService -Name $script:LibVirtualHidBrokerServiceName + if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Update libvirtualhid broker service")) { + Invoke-CheckedCommand -FilePath "sc.exe" -Arguments @( + "config", + $script:LibVirtualHidBrokerServiceName, + "binPath=", + $serviceBinaryPath, + "start=", + "auto", + "DisplayName=", + $script:LibVirtualHidBrokerServiceDisplayName + ) + $serviceRegistered = $true + } + } else { + if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Install libvirtualhid broker service")) { + $quotedServicePath = Get-LibVirtualHidQuotedServiceBinaryPath -Path $resolvedBroker + New-Service ` + -Name $script:LibVirtualHidBrokerServiceName ` + -BinaryPathName $quotedServicePath ` + -DisplayName $script:LibVirtualHidBrokerServiceDisplayName ` + -StartupType Automatic | Out-Null + $serviceRegistered = $true + } + } + + if ($serviceRegistered) { + Assert-LibVirtualHidBrokerServiceImagePath -Name $script:LibVirtualHidBrokerServiceName -Path $resolvedBroker + } + + Clear-LibVirtualHidBrokerServiceEnvironment -Name $script:LibVirtualHidBrokerServiceName + + if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Enable libvirtualhid broker service SID")) { + Invoke-CheckedCommand -FilePath "sc.exe" -Arguments @( + "sidtype", + $script:LibVirtualHidBrokerServiceName, + "unrestricted" + ) + } + + if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Set libvirtualhid broker service description")) { + Invoke-CheckedCommand -FilePath "sc.exe" -Arguments @( + "description", + $script:LibVirtualHidBrokerServiceName, + "Authorizes libvirtualhid virtual gamepad creation and license state." + ) + } + + if ($PSCmdlet.ShouldProcess($script:LibVirtualHidBrokerServiceName, "Start libvirtualhid broker service")) { + Start-Service -Name $script:LibVirtualHidBrokerServiceName + } +} + function Import-DriverCertificate { [CmdletBinding(SupportsShouldProcess)] param([string] $Path) @@ -330,6 +489,7 @@ try { foreach ($rootDevice in $rootDevices) { Restart-RootDevice -InstanceId $rootDevice } + Install-LibVirtualHidBrokerService -Path $BrokerPath return } @@ -345,6 +505,7 @@ try { foreach ($rootDevice in $rootDevices) { Restart-RootDevice -InstanceId $rootDevice } + Install-LibVirtualHidBrokerService -Path $BrokerPath } finally { Stop-LibVirtualHidTranscript } diff --git a/scripts/windows/uninstall-driver.ps1 b/scripts/windows/uninstall-driver.ps1 index b89cc3b..e7d7b6c 100644 --- a/scripts/windows/uninstall-driver.ps1 +++ b/scripts/windows/uninstall-driver.ps1 @@ -10,6 +10,8 @@ param( [string] $HardwareId = "ROOT\LIBVIRTUALHID", + [string] $BrokerServiceName = "libvirtualhid_broker", + [string] $RemoveCertificateSubject, [switch] $Force @@ -35,6 +37,25 @@ function Invoke-CheckedCommand { } } +function Remove-LibVirtualHidBrokerService { + [CmdletBinding(SupportsShouldProcess)] + param([string] $Name) + + $service = Get-Service -Name $Name -ErrorAction SilentlyContinue + if (-not $service) { + return + } + + if ($service.Status -ne "Stopped" -and $PSCmdlet.ShouldProcess($Name, "Stop libvirtualhid broker service")) { + Stop-Service -Name $Name -Force -ErrorAction Stop + $service.WaitForStatus("Stopped", [TimeSpan]::FromSeconds(15)) + } + + if ($PSCmdlet.ShouldProcess($Name, "Delete libvirtualhid broker service")) { + Invoke-CheckedCommand -FilePath "sc.exe" -Arguments @("delete", $Name) -IgnoreFailure + } +} + function Find-Devcon { if ($env:DEVCON_EXE -and (Test-Path -LiteralPath $env:DEVCON_EXE)) { return $env:DEVCON_EXE @@ -104,6 +125,8 @@ function Remove-DriverCertificate { } } +Remove-LibVirtualHidBrokerService -Name $BrokerServiceName + $devcon = Find-Devcon if ($devcon -and $PSCmdlet.ShouldProcess($HardwareId, "Remove libvirtualhid development device")) { Invoke-CheckedCommand -FilePath $devcon -Arguments @("remove", $HardwareId) -IgnoreFailure diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 29af052..c767414 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -49,7 +49,9 @@ if(LIBVIRTUALHID_USES_THREADS) elseif(WIN32) target_sources(${PROJECT_NAME} PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/platform/windows/windows_backend.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 "${CMAKE_CURRENT_SOURCE_DIR}/platform/windows/shared") @@ -60,10 +62,12 @@ elseif(WIN32) _WIN32_WINNT=0x0600) target_link_libraries(${PROJECT_NAME} PRIVATE - setupapi) + setupapi + lizardbyte::common) elseif(APPLE) target_sources(${PROJECT_NAME} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/platform/license_unavailable.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/platform/macos/macos_backend.cpp") target_link_libraries(${PROJECT_NAME} PRIVATE @@ -74,9 +78,16 @@ elseif(APPLE) else() target_sources(${PROJECT_NAME} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/platform/license_unavailable.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/platform/unsupported_backend.cpp") endif() +if(LIBVIRTUALHID_USES_THREADS) + target_sources(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/platform/license_unavailable.cpp") +endif() + target_include_directories(${PROJECT_NAME} PUBLIC $ diff --git a/src/include/libvirtualhid/libvirtualhid.hpp b/src/include/libvirtualhid/libvirtualhid.hpp index 43e642e..395a63f 100644 --- a/src/include/libvirtualhid/libvirtualhid.hpp +++ b/src/include/libvirtualhid/libvirtualhid.hpp @@ -6,6 +6,7 @@ // local includes #include +#include #include #include #include diff --git a/src/include/libvirtualhid/license.hpp b/src/include/libvirtualhid/license.hpp new file mode 100644 index 0000000..993a54b --- /dev/null +++ b/src/include/libvirtualhid/license.hpp @@ -0,0 +1,105 @@ +/** + * @file src/include/libvirtualhid/license.hpp + * @brief Provider-neutral license management API. + */ +#pragma once + +// standard includes +#include +#include +#include + +// local includes +#include + +namespace lvh { + + /** + * @brief Machine license states reported by the platform license service. + */ + enum class LicenseState { + unavailable, ///< The platform license service is not available. + unlicensed, ///< No active machine license is stored. + licensed, ///< The stored machine license is active. + expired, ///< The stored machine license has expired. + disabled, ///< The stored machine license was disabled by the provider. + invalid, ///< The stored machine license is invalid. + }; + + /** + * @brief Current machine license details. + */ + struct LicenseStatus { + bool service_available = false; ///< Whether the platform license service answered the request. + LicenseState state = LicenseState::unavailable; ///< Current machine license state. + std::uint32_t active_devices = 0; ///< Virtual devices currently tracked by the license service. + std::uint32_t activation_limit = 0; ///< Maximum machine activations allowed by the license. + std::uint32_t activation_usage = 0; ///< Machine activations currently used by the license. + std::string plan_name; ///< Human-readable plan name, when available. + std::string customer_email; ///< Customer email associated with the license, when available. + std::string expires_at; ///< Provider-formatted expiration timestamp, when applicable. + std::string message; ///< Human-readable license service status. + std::string purchase_url; ///< Hosted page where a license can be purchased. + std::string manage_account_url; ///< Hosted page where the customer can manage activations. + + /** + * @brief Check whether the machine has an active license. + * + * @return `true` when the current state is licensed. + */ + bool licensed() const { + return state == LicenseState::licensed; + } + }; + + /** + * @brief Result returned by license service operations. + */ + struct LicenseResult { + OperationStatus status; ///< Operation status, including transport and provider failures. + LicenseStatus license; ///< Latest license details returned by the service. + + /** + * @brief Check whether the license operation succeeded. + * + * @return `true` when the operation completed successfully. + */ + explicit operator bool() const { + return status.ok(); + } + }; + + /** + * @brief Read the locally stored machine license status without forcing remote validation. + * + * @return Current license result. + */ + LicenseResult get_license_status(); + + /** + * @brief Activate a license on this machine. + * + * The license key is sent directly to the platform license service. The library does not + * persist a copy or expose it in the returned status. + * + * @param license_key License key supplied by the customer. + * @param instance_name Optional customer-visible name for this machine activation. + * @return Activation result and latest license details. + */ + LicenseResult activate_license(std::string_view license_key, std::string_view instance_name = {}); + + /** + * @brief Revalidate the stored machine license with the configured provider. + * + * @return Validation result and latest license details. + */ + LicenseResult validate_license(); + + /** + * @brief Deactivate the stored license from this machine. + * + * @return Deactivation result and latest license details. + */ + LicenseResult deactivate_license(); + +} // namespace lvh diff --git a/src/include/libvirtualhid/types.hpp b/src/include/libvirtualhid/types.hpp index 00874a4..260fca5 100644 --- a/src/include/libvirtualhid/types.hpp +++ b/src/include/libvirtualhid/types.hpp @@ -32,6 +32,10 @@ namespace lvh { backend_unavailable, ///< Requested backend is not available on this host. device_closed, ///< Device operation was requested after the device closed. unsupported_profile, ///< Backend cannot create the requested device profile. + license_required, ///< Operation requires an active machine license. + license_invalid, ///< Supplied or stored license is invalid. + activation_limit_reached, ///< License has no remaining machine activations. + network_unavailable, ///< License provider could not be reached. backend_failure, ///< Backend-specific operation failed. }; diff --git a/src/platform/license_unavailable.cpp b/src/platform/license_unavailable.cpp new file mode 100644 index 0000000..cbfdda4 --- /dev/null +++ b/src/platform/license_unavailable.cpp @@ -0,0 +1,40 @@ +/** + * @file src/platform/license_unavailable.cpp + * @brief Unsupported-platform public license API definitions. + */ + +// local includes +#include + +// standard includes +#include + +namespace lvh { + namespace { + + LicenseResult unavailable_result() { + auto status = OperationStatus::failure(ErrorCode::backend_unavailable, "License management is not available on this platform"); + LicenseStatus license; + license.message = status.message(); + return {std::move(status), std::move(license)}; + } + + } // namespace + + LicenseResult get_license_status() { + return unavailable_result(); + } + + LicenseResult activate_license(std::string_view /*license_key*/, std::string_view /*instance_name*/) { + return unavailable_result(); + } + + LicenseResult validate_license() { + return unavailable_result(); + } + + LicenseResult deactivate_license() { + return unavailable_result(); + } + +} // namespace lvh diff --git a/src/platform/windows/broker/CMakeLists.txt b/src/platform/windows/broker/CMakeLists.txt new file mode 100644 index 0000000..c53b67c --- /dev/null +++ b/src/platform/windows/broker/CMakeLists.txt @@ -0,0 +1,51 @@ +if(NOT WIN32) + message(FATAL_ERROR "The libvirtualhid Windows broker can only be built on Windows.") +endif() + +include("${PROJECT_SOURCE_DIR}/cmake/cpm/CPM.cmake") +CPMUsePackageLock("${PROJECT_SOURCE_DIR}/package-lock.cmake") +CPMGetPackage(nlohmann_json) + +add_executable(libvirtualhid_broker + "${CMAKE_CURRENT_SOURCE_DIR}/libvirtualhid_broker.cpp") + +target_compile_features(libvirtualhid_broker PRIVATE cxx_std_23) +target_include_directories(libvirtualhid_broker + PRIVATE + "${PROJECT_SOURCE_DIR}/src/platform/windows/shared") +target_compile_definitions(libvirtualhid_broker + PRIVATE + NOMINMAX + WIN32_LEAN_AND_MEAN + _WIN32_WINNT=0x0A00) +target_link_libraries(libvirtualhid_broker + PRIVATE + lizardbyte::common + nlohmann_json::nlohmann_json + advapi32 + crypt32 + winhttp) + +if(MSVC AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) + set_property(TARGET libvirtualhid_broker PROPERTY + MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") +endif() + +if(MSVC) + # nlohmann/json uses C++ exceptions while serializing Polar request bodies. + target_compile_options(libvirtualhid_broker PRIVATE /EHsc /W4) + if(LIBVIRTUALHID_WARNINGS_AS_ERRORS) + target_compile_options(libvirtualhid_broker PRIVATE /WX) + endif() +else() + target_compile_options(libvirtualhid_broker PRIVATE -Wall -Wextra -Wpedantic) + if(LIBVIRTUALHID_WARNINGS_AS_ERRORS) + target_compile_options(libvirtualhid_broker PRIVATE -Werror) + endif() +endif() + +if(LIBVIRTUALHID_INSTALL) + install(TARGETS libvirtualhid_broker + RUNTIME DESTINATION "services/windows" + COMPONENT driver) +endif() diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp new file mode 100644 index 0000000..21d9ad0 --- /dev/null +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -0,0 +1,1729 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/windows/broker/libvirtualhid_broker.cpp + * @brief Windows service boundary for licensed libvirtualhid driver access. + */ + +#ifndef NOMINMAX + #define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN +#endif + +// platform includes +// clang-format off +#include +#include +#include +#include +// clang-format on + +// local includes +#include "lvh_windows_broker_config.hpp" +#include "lvh_windows_broker_protocol.h" +#include "lvh_windows_github_actions_evaluation.hpp" + +// lib includes +#include +#include + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + + using UniqueHandle = std::unique_ptr; + using UniqueWinHttpHandle = std::unique_ptr; + + constexpr auto service_name = L"libvirtualhid_broker"; + constexpr auto broker_instance_name = "libvirtualhid Windows broker"; + constexpr auto pipe_buffer_size = 8192U; + // 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)"; + + static_assert(pipe_client_granted_access == 0x0012018BU); + static_assert((pipe_client_granted_access & FILE_CREATE_PIPE_INSTANCE) == 0U); + + static_assert( + !lvh::windows::broker_config::polar_organization_id.empty() && + !lvh::windows::broker_config::allowed_benefits.empty(), + "The Windows broker requires a Polar organization and at least one allowed benefit." + ); + + struct ServiceRuntime { + SERVICE_STATUS_HANDLE status_handle = nullptr; + SERVICE_STATUS status { + .dwServiceType = SERVICE_WIN32_OWN_PROCESS, + .dwCurrentState = SERVICE_STOPPED, + .dwControlsAccepted = 0, + .dwWin32ExitCode = NO_ERROR, + .dwServiceSpecificExitCode = 0, + .dwCheckPoint = 0, + .dwWaitHint = 0, + }; + HANDLE stop_event = nullptr; + }; + + ServiceRuntime &service_runtime() { + static ServiceRuntime runtime; + return runtime; + } + + UniqueHandle make_unique_handle(HANDLE handle) { + if (handle == INVALID_HANDLE_VALUE) { + handle = nullptr; + } + return {handle, &::CloseHandle}; + } + + UniqueWinHttpHandle make_unique_winhttp_handle(HINTERNET handle) { + return {handle, &::WinHttpCloseHandle}; + } + + template + void copy_c_string(char (&target)[Size], std::string_view value) { + std::ranges::fill(target, '\0'); + const auto count = std::min(value.size(), Size - 1U); + std::memcpy(target, value.data(), count); + } + + std::string windows_error_message(DWORD error_code) { + std::array message_buffer {}; + const auto message_size = ::FormatMessageA( + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + error_code, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + message_buffer.data(), + static_cast(message_buffer.size()), + nullptr + ); + + if (message_size == 0U) { + std::ostringstream fallback; + fallback << "Windows error " << error_code; + return fallback.str(); + } + + std::string message {message_buffer.data(), message_size}; + while (!message.empty() && (message.back() == '\r' || message.back() == '\n')) { + message.pop_back(); + } + return message; + } + + std::optional parse_json(std::string_view body) { + auto parsed = nlohmann::json::parse(body.begin(), body.end(), nullptr, false); + if (parsed.is_discarded()) { + return std::nullopt; + } + return parsed; + } + + std::string json_string_or_empty(const nlohmann::json &object, std::string_view key) { + if (!object.is_object()) { + return {}; + } + const auto iter = object.find(std::string {key}); + if (iter == object.end() || !iter->is_string()) { + return {}; + } + return iter->get(); + } + + std::string polar_detail_message(const nlohmann::json &detail) { + if (detail.is_string()) { + return detail.get(); + } + if (!detail.is_array()) { + return {}; + } + + const auto entry = std::ranges::find_if(detail, [](const auto &candidate) { + return !json_string_or_empty(candidate, "msg").empty(); + }); + return entry == detail.end() ? std::string {} : json_string_or_empty(*entry, "msg"); + } + + std::string polar_error_message(const nlohmann::json &body, std::string_view fallback) { + if (const auto detail = body.find("detail"); detail != body.end()) { + if (auto message = polar_detail_message(*detail); !message.empty()) { + return message; + } + } + + const auto error = json_string_or_empty(body, "error"); + return error.empty() ? std::string {fallback} : error; + } + + std::filesystem::path broker_state_path(std::string_view filename) { + std::string program_data; + if (!lizardbyte::common::get_env("ProgramData", program_data) || program_data.empty()) { + program_data = R"(C:\ProgramData)"; + } + + auto root = std::filesystem::path {program_data}; + return root / "libvirtualhid" / filename; + } + + std::filesystem::path license_state_path() { + return broker_state_path("license.dat"); + } + + std::filesystem::path github_actions_evaluation_state_path() { + return broker_state_path("github-actions-evaluation.dat"); + } + + struct PolarLicenseState { + std::string provider; + std::string license_key; + std::string activation_id; + std::string license_key_id; + std::string license_status; + std::string organization_id; + std::string benefit_id; + std::string customer_email; + std::string expires_at; + std::uint32_t activation_limit = 0; + }; + + std::string serialize_license_state(const PolarLicenseState &state) { + std::ostringstream serialized; + serialized << "provider=" << state.provider << "\n"; + serialized << "license_key=" << state.license_key << "\n"; + serialized << "activation_id=" << state.activation_id << "\n"; + serialized << "license_key_id=" << state.license_key_id << "\n"; + serialized << "license_status=" << state.license_status << "\n"; + serialized << "organization_id=" << state.organization_id << "\n"; + serialized << "benefit_id=" << state.benefit_id << "\n"; + serialized << "customer_email=" << state.customer_email << "\n"; + serialized << "expires_at=" << state.expires_at << "\n"; + serialized << "activation_limit=" << state.activation_limit << "\n"; + return serialized.str(); + } + + std::optional parse_uint64(std::string_view value) { + std::uint64_t parsed = 0; + if (const auto result = std::from_chars(value.data(), value.data() + value.size(), parsed); result.ec != std::errc {} || result.ptr != value.data() + value.size()) { + return std::nullopt; + } + return parsed; + } + + std::optional load_protected_state(const std::filesystem::path &path) { + std::ifstream input {path, std::ios::binary}; + if (!input) { + return std::nullopt; + } + + std::vector encrypted { + std::istreambuf_iterator {input}, + std::istreambuf_iterator {} + }; + if (encrypted.empty()) { + return std::nullopt; + } + + DATA_BLOB encrypted_blob { + .cbData = static_cast(encrypted.size()), + .pbData = encrypted.data(), + }; + DATA_BLOB plain_blob {}; + if (::CryptUnprotectData(&encrypted_blob, nullptr, nullptr, nullptr, nullptr, 0, &plain_blob) == FALSE) { + return std::nullopt; + } + + std::string serialized { + reinterpret_cast(plain_blob.pbData), + plain_blob.cbData + }; + static_cast(::LocalFree(plain_blob.pbData)); + return serialized; + } + + bool save_protected_state( + const std::filesystem::path &path, + std::string serialized, + const wchar_t *description, + std::string_view state_name, + std::string &message + ) { + DATA_BLOB plain_blob { + .cbData = static_cast(serialized.size()), + .pbData = static_cast(static_cast(serialized.data())), + }; + DATA_BLOB encrypted_blob {}; + if (::CryptProtectData(&plain_blob, description, nullptr, nullptr, nullptr, CRYPTPROTECT_LOCAL_MACHINE, &encrypted_blob) == FALSE) { + message = "Unable to protect " + std::string {state_name} + " state: " + windows_error_message(::GetLastError()); + return false; + } + + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + if (ec) { + static_cast(::LocalFree(encrypted_blob.pbData)); + message = "Unable to create " + std::string {state_name} + " state directory: " + ec.message(); + return false; + } + + std::ofstream output {path, std::ios::binary | std::ios::trunc}; + if (!output) { + static_cast(::LocalFree(encrypted_blob.pbData)); + message = "Unable to write " + std::string {state_name} + " state."; + return false; + } + + output.write( + reinterpret_cast(encrypted_blob.pbData), + encrypted_blob.cbData + ); + static_cast(::LocalFree(encrypted_blob.pbData)); + return true; + } + + PolarLicenseState deserialize_license_state(std::string_view serialized) { + PolarLicenseState state; + std::size_t offset = 0; + while (offset < serialized.size()) { + const auto line_end = serialized.find('\n', offset); + const auto end = line_end == std::string_view::npos ? serialized.size() : line_end; + const auto line = serialized.substr(offset, end - offset); + if (const auto separator = line.find('='); separator != std::string_view::npos) { + const auto key = line.substr(0, separator); + const auto value = line.substr(separator + 1U); + if (key == "provider") { + state.provider = value; + } else if (key == "license_key") { + state.license_key = value; + } else if (key == "activation_id") { + state.activation_id = value; + } else if (key == "license_key_id") { + state.license_key_id = value; + } else if (key == "license_status") { + state.license_status = value; + } else if (key == "organization_id") { + state.organization_id = value; + } else if (key == "benefit_id") { + state.benefit_id = value; + } else if (key == "customer_email") { + state.customer_email = value; + } else if (key == "expires_at") { + state.expires_at = value; + } else if (key == "activation_limit") { + state.activation_limit = static_cast(parse_uint64(value).value_or(0)); + } + } + if (line_end == std::string_view::npos) { + break; + } + offset = line_end + 1U; + } + return state; + } + + std::optional load_license_state() { + const auto serialized = load_protected_state(license_state_path()); + if (!serialized) { + return std::nullopt; + } + + auto state = deserialize_license_state(*serialized); + if (state.provider != "polar" || state.license_key.empty() || state.activation_id.empty()) { + return std::nullopt; + } + return state; + } + + bool save_license_state(const PolarLicenseState &state, std::string &message) { + return save_protected_state( + license_state_path(), + serialize_license_state(state), + L"libvirtualhid broker license", + "license", + message + ); + } + + struct GitHubActionsEvaluationState { + lvh::windows::github_actions_evaluation::Clock::time_point started_at; + }; + + std::string serialize_github_actions_evaluation_state( + const GitHubActionsEvaluationState &state + ) { + const auto started_at = std::chrono::duration_cast( + state.started_at.time_since_epoch() + ); + return std::format("started_at={}\n", started_at.count()); + } + + std::optional deserialize_github_actions_evaluation_state( + std::string_view serialized + ) { + constexpr std::string_view prefix = "started_at="; + if (!serialized.starts_with(prefix)) { + return std::nullopt; + } + + const auto line_end = serialized.find('\n'); + const auto value = serialized.substr( + prefix.size(), + line_end == std::string_view::npos ? std::string_view::npos : line_end - prefix.size() + ); + const auto started_at = parse_uint64(value); + if (!started_at || *started_at > static_cast(std::numeric_limits::max())) { + return std::nullopt; + } + + return GitHubActionsEvaluationState { + .started_at = lvh::windows::github_actions_evaluation::Clock::time_point { + std::chrono::seconds {static_cast(*started_at)} + }, + }; + } + + std::optional load_github_actions_evaluation_state() { + const auto serialized = load_protected_state(github_actions_evaluation_state_path()); + if (!serialized) { + return std::nullopt; + } + return deserialize_github_actions_evaluation_state(*serialized); + } + + bool save_github_actions_evaluation_state( + const GitHubActionsEvaluationState &state, + std::string &message + ) { + return save_protected_state( + github_actions_evaluation_state_path(), + serialize_github_actions_evaluation_state(state), + L"libvirtualhid GitHub Actions evaluation", + "GitHub Actions evaluation", + message + ); + } + + void delete_license_state() { + std::error_code ignored; + std::filesystem::remove(license_state_path(), ignored); + } + + std::string default_instance_name() { + std::array computer_name {}; + if (auto size = static_cast(computer_name.size()); ::GetComputerNameA(computer_name.data(), &size) != FALSE && size > 0U) { + return std::string {computer_name.data(), size}; + } + return "Windows PC"; + } + + struct PolarApiResult { + bool transport_ok = false; + DWORD http_status = 0; + std::string body; + std::string error; + }; + + PolarApiResult post_polar_license_request( + std::wstring_view endpoint, + const nlohmann::json &request_body + ) { + PolarApiResult result; + const auto session = make_unique_winhttp_handle(::WinHttpOpen(L"libvirtualhid-broker/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)); + if (!session) { + result.error = "WinHttpOpen 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) { + result.error = "WinHttpConnect failed: " + windows_error_message(::GetLastError()); + return result; + } + + const auto request = make_unique_winhttp_handle(::WinHttpOpenRequest(connection.get(), L"POST", std::wstring {endpoint}.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)); + if (!request) { + result.error = "WinHttpOpenRequest failed: " + windows_error_message(::GetLastError()); + return result; + } + + auto body = request_body.dump(-1, ' ', false, nlohmann::json::error_handler_t::replace); + if (constexpr auto headers = L"Accept: application/json\r\nContent-Type: application/json\r\n"; ::WinHttpSendRequest(request.get(), headers, static_cast(-1), body.data(), static_cast(body.size()), static_cast(body.size()), 0) == FALSE) { + result.error = "WinHttpSendRequest failed: " + windows_error_message(::GetLastError()); + return result; + } + + if (::WinHttpReceiveResponse(request.get(), nullptr) == FALSE) { + result.error = "WinHttpReceiveResponse failed: " + windows_error_message(::GetLastError()); + return result; + } + + DWORD status_size = sizeof(result.http_status); + static_cast(::WinHttpQueryHeaders( + request.get(), + WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, + &result.http_status, + &status_size, + WINHTTP_NO_HEADER_INDEX + )); + + for (;;) { + DWORD available = 0; + if (::WinHttpQueryDataAvailable(request.get(), &available) == FALSE) { + result.error = "WinHttpQueryDataAvailable failed: " + windows_error_message(::GetLastError()); + return result; + } + if (available == 0U) { + break; + } + + const auto old_size = result.body.size(); + result.body.resize(old_size + available); + DWORD read = 0; + if (::WinHttpReadData(request.get(), result.body.data() + old_size, available, &read) == FALSE) { + result.error = "WinHttpReadData failed: " + windows_error_message(::GetLastError()); + return result; + } + result.body.resize(old_size + read); + } + + result.transport_ok = true; + if (result.http_status >= 400U) { + if (const auto parsed = parse_json(result.body)) { + result.error = polar_error_message(*parsed, "The license service returned an error."); + } else { + result.error = "The license service returned an error."; + } + } + return result; + } + + std::uint32_t json_uint32_or_zero(const nlohmann::json &object, std::string_view key) { + if (!object.is_object()) { + return 0; + } + const auto iter = object.find(std::string {key}); + if (iter == object.end() || iter->is_null()) { + return 0; + } + if (iter->is_number_unsigned()) { + return static_cast(std::min( + iter->get(), + static_cast(std::numeric_limits::max()) + )); + } + if (iter->is_number_integer()) { + const auto value = iter->get(); + if (value > 0) { + return static_cast(std::min( + static_cast(value), + static_cast(std::numeric_limits::max()) + )); + } + } + return 0; + } + + PolarLicenseState license_state_from_json( + const nlohmann::json &body, + std::string_view fallback_license_key, + std::string_view fallback_activation_id + ) { + PolarLicenseState state; + state.provider = "polar"; + state.license_key = json_string_or_empty(body, "key"); + state.activation_id = fallback_activation_id; + state.license_key_id = json_string_or_empty(body, "id"); + state.license_status = json_string_or_empty(body, "status"); + state.organization_id = json_string_or_empty(body, "organization_id"); + state.benefit_id = json_string_or_empty(body, "benefit_id"); + state.expires_at = json_string_or_empty(body, "expires_at"); + state.activation_limit = json_uint32_or_zero(body, "limit_activations"); + + if (const auto customer = body.find("customer"); customer != body.end() && customer->is_object()) { + state.customer_email = json_string_or_empty(*customer, "email"); + } + + if (const auto activation = body.find("activation"); activation != body.end() && activation->is_object()) { + const auto activation_id = json_string_or_empty(*activation, "id"); + if (!activation_id.empty()) { + state.activation_id = activation_id; + } + } + if (state.license_key.empty()) { + state.license_key = fallback_license_key; + } + return state; + } + + std::string_view plan_name_for_benefit(std::string_view benefit_id) { + const auto benefit = std::ranges::find_if( + lvh::windows::broker_config::allowed_benefits, + [benefit_id](const auto &candidate) { + return candidate.id == benefit_id; + } + ); + return benefit == lvh::windows::broker_config::allowed_benefits.end() ? + std::string_view {} : + benefit->plan_name; + } + + bool valid_broker_header( + const LvhWindowsBrokerRequestHeader &header, + LvhWindowsBrokerRequestType expected_type, + std::uint32_t expected_size + ) { + return header.version == LVH_WINDOWS_BROKER_PROTOCOL_VERSION && + header.size == expected_size && + header.type == std::to_underlying(expected_type); + } + + bool session_token_matches( + const LvhWindowsSessionToken &lhs, + const LvhWindowsSessionToken &rhs + ) { + return std::memcmp(lhs.bytes, rhs.bytes, sizeof(lhs.bytes)) == 0; + } + + LvhWindowsDestroyDeviceRequest make_destroy_device_request( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) { + auto request = LvhWindowsDestroyDeviceRequest {}; + request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + request.size = sizeof(request); + request.driver_device_id = driver_device_id; + request.session_token = session_token; + return request; + } + + DWORD pipe_client_process_id(HANDLE pipe) { + ULONG process_id = 0; + if (::GetNamedPipeClientProcessId(pipe, &process_id) == FALSE) { + return 0; + } + return process_id; + } + + UniqueHandle open_client_process(DWORD process_id) { + if (process_id == 0U) { + return make_unique_handle(nullptr); + } + return make_unique_handle(::OpenProcess(SYNCHRONIZE | PROCESS_DUP_HANDLE, FALSE, process_id)); + } + + UniqueHandle duplicate_client_handle( + HANDLE client_process, + std::uint64_t client_handle_value + ) { + const auto native_handle_value = static_cast(client_handle_value); + if (client_process == nullptr || static_cast(native_handle_value) != client_handle_value || native_handle_value == 0U) { + return make_unique_handle(nullptr); + } + + auto duplicated_handle = HANDLE {}; + if (::DuplicateHandle(client_process, std::bit_cast(native_handle_value), ::GetCurrentProcess(), &duplicated_handle, 0, FALSE, DUPLICATE_SAME_ACCESS) == FALSE) { + return make_unique_handle(nullptr); + } + return make_unique_handle(duplicated_handle); + } + + LvhWindowsBrokerStatusCode broker_status_from_protocol(std::uint32_t status) { + using enum LvhWindowsBrokerStatusCode; + + switch (status) { + case LVH_WINDOWS_STATUS_SUCCESS: + return success; + case LVH_WINDOWS_STATUS_INVALID_ARGUMENT: + return invalid_argument; + case LVH_WINDOWS_STATUS_UNSUPPORTED_PROFILE: + return unsupported_profile; + case LVH_WINDOWS_STATUS_DEVICE_NOT_FOUND: + return device_not_found; + case LVH_WINDOWS_STATUS_BACKEND_FAILURE: + default: + return backend_failure; + } + } + + LvhWindowsBrokerStatusCode activation_failure_status(DWORD http_status) { + using enum LvhWindowsBrokerStatusCode; + + if (http_status == 403U) { + return activation_limit_reached; + } + if (http_status == 404U || http_status == 422U) { + return license_invalid; + } + return backend_failure; + } + + class DriverChannel { + public: + DriverChannel() = default; + + DriverChannel(const DriverChannel &) = delete; + DriverChannel &operator=(const DriverChannel &) = delete; + + bool open() { + if (handle_) { + return true; + } + + for (const auto *path : {LVH_WINDOWS_CONTROL_DEVICE_PATH, LVH_WINDOWS_GLOBAL_CONTROL_DEVICE_PATH}) { + const auto handle = ::CreateFileA( + path, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + nullptr, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + nullptr + ); + if (handle != INVALID_HANDLE_VALUE) { + path_ = path; + handle_ = make_unique_handle(handle); + return true; + } + } + + last_error_ = ::GetLastError(); + return false; + } + + bool create_gamepad( + HANDLE control_handle, + LvhWindowsCreateGamepadRequest request, + LvhWindowsCreateGamepadResponse &response, + LvhWindowsBrokerStatusCode &status, + std::string &message + ) const { + if (control_handle == nullptr || control_handle == INVALID_HANDLE_VALUE) { + status = LvhWindowsBrokerStatusCode::backend_unavailable; + message = "The requesting client control handle is unavailable."; + return false; + } + + auto operation_event = make_unique_handle(::CreateEventA(nullptr, TRUE, FALSE, nullptr)); + if (!operation_event) { + status = LvhWindowsBrokerStatusCode::backend_failure; + message = "Unable to create a Windows driver request event: " + windows_error_message(::GetLastError()); + return false; + } + + OVERLAPPED overlapped {}; + overlapped.hEvent = operation_event.get(); + DWORD bytes_returned = 0; + if (::DeviceIoControl(control_handle, LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, &request, sizeof(request), &response, sizeof(response), nullptr, &overlapped) == FALSE && ::GetLastError() != ERROR_IO_PENDING) { + status = LvhWindowsBrokerStatusCode::backend_failure; + message = "create Windows gamepad: " + windows_error_message(::GetLastError()); + return false; + } + if (::GetOverlappedResult(control_handle, &overlapped, &bytes_returned, TRUE) == FALSE) { + status = LvhWindowsBrokerStatusCode::backend_failure; + message = "create Windows gamepad: " + windows_error_message(::GetLastError()); + return false; + } + + if (bytes_returned < sizeof(response)) { + status = LvhWindowsBrokerStatusCode::backend_failure; + message = "Windows driver returned a truncated gamepad response"; + return false; + } + + status = broker_status_from_protocol(response.status); + if (status != LvhWindowsBrokerStatusCode::success) { + message = "Windows driver rejected gamepad creation"; + return false; + } + + if (response.device_path[0] == '\0') { + copy_c_string(response.device_path, path_); + } + + message.clear(); + return true; + } + + bool destroy_device( + LvhWindowsDestroyDeviceRequest request, + 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; + } + + if (DWORD bytes_returned = 0; ::DeviceIoControl(handle_.get(), LVH_WINDOWS_IOCTL_DESTROY_DEVICE, &request, sizeof(request), nullptr, 0, &bytes_returned, nullptr) == FALSE) { + status = backend_failure; + message = "destroy Windows virtual HID device: " + windows_error_message(::GetLastError()); + return false; + } + + status = success; + message.clear(); + return true; + } + + private: + UniqueHandle handle_ {nullptr, &::CloseHandle}; + std::string path_; + DWORD last_error_ = ERROR_FILE_NOT_FOUND; + }; + + class BrokerState { + public: + struct DeviceRecord { + LvhWindowsSessionToken session_token {}; + DWORD owner_process_id {}; + UniqueHandle owner_process {nullptr, &::CloseHandle}; + bool github_actions_evaluation = false; + }; + + BrokerState() = default; + + void fill_license_status(LvhWindowsBrokerLicenseStatus &license) const { + std::lock_guard lock {mutex_}; + fill_license_status_locked(license); + } + + LvhWindowsBrokerStatusResponse handle_status() const { + LvhWindowsBrokerStatusResponse response {}; + response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + response.size = sizeof(response); + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + fill_license_status(response.license); + copy_c_string(response.message, "Broker is running."); + return response; + } + + LvhWindowsBrokerCreateGamepadResponse handle_create( + const LvhWindowsBrokerCreateGamepadRequest &request, + DWORD client_process_id + ) { + LvhWindowsBrokerCreateGamepadResponse response {}; + response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + response.size = sizeof(response); + response.gamepad.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; + response.gamepad.size = sizeof(response.gamepad); + + if (!valid_broker_header( + request.header, + LvhWindowsBrokerRequestType::create_gamepad, + sizeof(request) + )) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Invalid broker create request."); + 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); + copy_c_string(response.message, "Unable to open the requesting client process."); + fill_license_status(response.license); + return response; + } + + auto client_control_handle = duplicate_client_handle( + owner_process.get(), + request.client_control_handle + ); + if (!client_control_handle) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Unable to duplicate the requesting client control handle."); + fill_license_status(response.license); + return response; + } + + const auto [authorization_status, github_actions_evaluation] = authorize_gamepad_create( + response.license, + response.message + ); + if (authorization_status != LvhWindowsBrokerStatusCode::success) { + response.status = std::to_underlying(authorization_status); + return response; + } + + auto status = LvhWindowsBrokerStatusCode::success; + if (std::string message; !driver_.create_gamepad(client_control_handle.get(), request.gamepad, response.gamepad, status, message)) { + response.status = std::to_underlying(status); + fill_license_status(response.license); + copy_c_string(response.message, message); + return response; + } + + { + std::lock_guard lock {mutex_}; + devices_.try_emplace( + response.gamepad.driver_device_id, + DeviceRecord { + .session_token = response.gamepad.session_token, + .owner_process_id = client_process_id, + .owner_process = std::move(owner_process), + .github_actions_evaluation = github_actions_evaluation, + } + ); + fill_license_status_locked(response.license); + } + + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + copy_c_string( + response.message, + github_actions_evaluation ? + "Created virtual gamepad using the GitHub Actions evaluation window." : + "Created virtual gamepad." + ); + return response; + } + + LvhWindowsBrokerDestroyDeviceResponse handle_destroy( + const LvhWindowsBrokerDestroyDeviceRequest &request + ) { + LvhWindowsBrokerDestroyDeviceResponse response {}; + response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + response.size = sizeof(response); + + if (!valid_broker_header( + request.header, + LvhWindowsBrokerRequestType::destroy_device, + sizeof(request) + )) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Invalid broker destroy request."); + fill_license_status(response.license); + return response; + } + + { + std::lock_guard lock {mutex_}; + const auto iter = devices_.find(request.device.driver_device_id); + if (iter == devices_.end()) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::device_not_found); + fill_license_status_locked(response.license); + copy_c_string(response.message, "Broker does not own this virtual device."); + return response; + } + if (!session_token_matches(iter->second.session_token, request.device.session_token)) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + fill_license_status_locked(response.license); + copy_c_string(response.message, "Invalid virtual device session token."); + return response; + } + } + + auto status = LvhWindowsBrokerStatusCode::success; + if (std::string message; !driver_.destroy_device(request.device, status, message)) { + response.status = std::to_underlying(status); + fill_license_status(response.license); + copy_c_string(response.message, message); + return response; + } + + { + std::lock_guard lock {mutex_}; + devices_.erase(request.device.driver_device_id); + fill_license_status_locked(response.license); + } + + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + copy_c_string(response.message, "Destroyed virtual device."); + return response; + } + + void cleanup_devices() { + std::vector stale_devices; + { + std::lock_guard lock {mutex_}; + const auto now = lvh::windows::github_actions_evaluation::Clock::now(); + const auto evaluation_expired = github_actions_ && + github_actions_evaluation_state_ && + !license_is_active_locked() && + !lvh::windows::github_actions_evaluation::active( + 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; + } + return false; + }); + } + + for (const auto &request : stale_devices) { + auto status = LvhWindowsBrokerStatusCode::success; + std::string message; + static_cast(driver_.destroy_device(request, status, message)); + } + } + + LvhWindowsBrokerLicenseResponse handle_activate_license( + const LvhWindowsBrokerLicenseRequest &request + ) { + LvhWindowsBrokerLicenseResponse response {}; + response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + response.size = sizeof(response); + + if (!valid_broker_header( + request.header, + LvhWindowsBrokerRequestType::activate_license, + sizeof(request) + )) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Invalid broker activate request."); + fill_license_status(response.license); + return response; + } + + const std::string license_key {request.license_key}; + const auto instance_name = request.instance_name[0] == '\0' ? default_instance_name() : std::string {request.instance_name}; + if (license_key.empty()) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "License key is required."); + fill_license_status(response.license); + return response; + } + + auto api_result = post_polar_license_request( + L"/v1/customer-portal/license-keys/activate", + nlohmann::json { + {"key", license_key}, + {"organization_id", std::string {lvh::windows::broker_config::polar_organization_id}}, + {"label", instance_name}, + } + ); + if (!api_result.transport_ok) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::network_unavailable); + copy_c_string(response.message, api_result.error); + fill_license_status(response.license); + return response; + } + + if (api_result.http_status != 200U) { + response.status = std::to_underlying(activation_failure_status(api_result.http_status)); + copy_c_string( + response.message, + api_result.error.empty() ? "License activation failed." : api_result.error + ); + fill_license_status(response.license); + return response; + } + + const auto parsed = parse_json(api_result.body); + if (!parsed) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::backend_failure); + copy_c_string(response.message, "The license activation response was not valid JSON."); + fill_license_status(response.license); + return response; + } + + const auto activation_id = json_string_or_empty(*parsed, "id"); + const auto license_key_body = parsed->find("license_key"); + if (activation_id.empty() || license_key_body == parsed->end() || !license_key_body->is_object()) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::backend_failure); + copy_c_string(response.message, "The license activation response was missing license state."); + fill_license_status(response.license); + return response; + } + + auto new_state = license_state_from_json(*license_key_body, license_key, activation_id); + if (new_state.license_status != "granted") { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::license_invalid); + copy_c_string(response.message, "The license service did not grant this license key."); + fill_license_status(response.license); + return response; + } + + if (!license_allowed(new_state)) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::license_invalid); + copy_c_string(response.message, "License organization or benefit is not allowed for this driver."); + 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); + fill_license_status(response.license); + return response; + } + + { + std::lock_guard lock {mutex_}; + license_state_ = std::move(new_state); + fill_license_status_locked(response.license); + } + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + copy_c_string(response.message, "License activated on this machine."); + return response; + } + + LvhWindowsBrokerLicenseResponse handle_validate_license( + const LvhWindowsBrokerLicenseRequest &request + ) { + LvhWindowsBrokerLicenseResponse response {}; + response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + response.size = sizeof(response); + + if (!valid_broker_header( + request.header, + LvhWindowsBrokerRequestType::validate_license, + sizeof(request) + )) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Invalid broker validate request."); + fill_license_status(response.license); + return response; + } + + const auto [status, message] = validate_saved_license(); + response.status = std::to_underlying(status); + fill_license_status(response.license); + copy_c_string(response.message, message); + return response; + } + + LvhWindowsBrokerLicenseResponse handle_deactivate_license( + const LvhWindowsBrokerLicenseRequest &request + ) { + LvhWindowsBrokerLicenseResponse response {}; + response.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + response.size = sizeof(response); + + if (!valid_broker_header( + request.header, + LvhWindowsBrokerRequestType::deactivate_license, + sizeof(request) + )) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Invalid broker deactivate request."); + fill_license_status(response.license); + return response; + } + + PolarLicenseState state; + { + std::lock_guard lock {mutex_}; + if (!license_state_) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + fill_license_status_locked(response.license); + copy_c_string(response.message, "No machine license is active."); + return response; + } + state = *license_state_; + } + + auto api_result = post_polar_license_request( + L"/v1/customer-portal/license-keys/deactivate", + nlohmann::json { + {"key", state.license_key}, + {"organization_id", std::string {lvh::windows::broker_config::polar_organization_id}}, + {"activation_id", state.activation_id}, + } + ); + if (!api_result.transport_ok) { + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::network_unavailable); + copy_c_string(response.message, api_result.error); + fill_license_status(response.license); + return response; + } + + if (api_result.http_status != 204U) { + response.status = std::to_underlying( + api_result.http_status == 404U ? + LvhWindowsBrokerStatusCode::license_invalid : + LvhWindowsBrokerStatusCode::backend_failure + ); + copy_c_string( + response.message, + api_result.error.empty() ? "License deactivation failed." : api_result.error + ); + fill_license_status(response.license); + return response; + } + + delete_license_state(); + { + std::lock_guard lock {mutex_}; + license_state_.reset(); + fill_license_status_locked(response.license); + } + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::success); + copy_c_string(response.message, "License deactivated on this machine."); + return response; + } + + private: + 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(); + } + + bool license_is_active_locked() const { + return license_state_ && + license_state_->license_status == "granted" && + license_allowed(*license_state_); + } + + std::pair validate_saved_license() { + PolarLicenseState state; + { + std::lock_guard lock {mutex_}; + if (!license_state_) { + return { + LvhWindowsBrokerStatusCode::license_invalid, + "No license is activated on this machine.", + }; + } + state = *license_state_; + } + + auto api_result = post_polar_license_request( + L"/v1/customer-portal/license-keys/validate", + nlohmann::json { + {"key", state.license_key}, + {"organization_id", std::string {lvh::windows::broker_config::polar_organization_id}}, + {"activation_id", state.activation_id}, + } + ); + if (!api_result.transport_ok) { + return { + LvhWindowsBrokerStatusCode::network_unavailable, + api_result.error, + }; + } + + if (api_result.http_status != 200U) { + if (api_result.http_status == 404U) { + std::lock_guard lock {mutex_}; + license_state_.reset(); + delete_license_state(); + } + return { + api_result.http_status == 404U ? + LvhWindowsBrokerStatusCode::license_invalid : + LvhWindowsBrokerStatusCode::backend_failure, + api_result.error.empty() ? "License validation failed." : api_result.error, + }; + } + + const auto parsed = parse_json(api_result.body); + if (!parsed) { + return { + LvhWindowsBrokerStatusCode::backend_failure, + "The license validation response was not valid JSON.", + }; + } + + 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(); + return { + LvhWindowsBrokerStatusCode::license_invalid, + "The license service did not validate this machine activation.", + }; + } + if (new_state.license_status != "granted") { + const auto error = new_state.license_status == "disabled" ? + "License disabled." : + "License revoked."; + std::lock_guard lock {mutex_}; + license_state_.reset(); + delete_license_state(); + return { + LvhWindowsBrokerStatusCode::license_invalid, + error, + }; + } + + if (!license_allowed(new_state)) { + std::lock_guard lock {mutex_}; + license_state_.reset(); + delete_license_state(); + return { + LvhWindowsBrokerStatusCode::license_invalid, + "License organization or benefit is not allowed for this driver.", + }; + } + + if (std::string save_error; !save_license_state(new_state, save_error)) { + return { + LvhWindowsBrokerStatusCode::backend_failure, + save_error, + }; + } + + { + std::lock_guard lock {mutex_}; + license_state_ = std::move(new_state); + } + return { + LvhWindowsBrokerStatusCode::success, + "License validated.", + }; + } + + std::pair authorize_gamepad_create( + LvhWindowsBrokerLicenseStatus &license, + char (&message)[LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE] + ) { + { + std::lock_guard lock {mutex_}; + if (!license_state_) { + if (!github_actions_) { + fill_license_status_locked(license); + copy_c_string(message, "An active license is required to create virtual gamepads."); + return {LvhWindowsBrokerStatusCode::license_required, false}; + } + + const auto now = lvh::windows::github_actions_evaluation::Clock::now(); + if (!github_actions_evaluation_state_) { + GitHubActionsEvaluationState evaluation_state {.started_at = now}; + if (std::string save_error; !save_github_actions_evaluation_state(evaluation_state, save_error)) { + fill_license_status_locked(license); + copy_c_string(message, save_error); + return {LvhWindowsBrokerStatusCode::backend_failure, false}; + } + github_actions_evaluation_state_ = evaluation_state; + } + + fill_license_status_locked(license); + if (!lvh::windows::github_actions_evaluation::active( + github_actions_evaluation_state_->started_at, + now + )) { + copy_c_string(message, "The five-minute GitHub Actions evaluation window has expired."); + return {LvhWindowsBrokerStatusCode::license_required, false}; + } + + const auto remaining = lvh::windows::github_actions_evaluation::remaining( + github_actions_evaluation_state_->started_at, + now + ); + copy_c_string( + message, + std::format("GitHub Actions evaluation active for {} more seconds.", remaining.count()) + ); + 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}; + } + + void fill_license_status_locked(LvhWindowsBrokerLicenseStatus &license) const { + license.version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION; + license.size = sizeof(license); + license.active_devices = static_cast(devices_.size()); + license.free_active_device_limit = 0; + + if (!license_state_) { + license.state = std::to_underlying(LvhWindowsBrokerLicenseState::free); + license.activation_limit = 0; + license.activation_usage = 0; + copy_c_string(license.customer_email, ""); + copy_c_string(license.expires_at, ""); + if (!github_actions_) { + copy_c_string(license.plan_name, "Unlicensed"); + copy_c_string(license.message, "An active license is required to create gamepads."); + return; + } + + copy_c_string(license.plan_name, "GitHub Actions Evaluation"); + if (!github_actions_evaluation_state_) { + copy_c_string( + license.message, + "Five-minute GitHub Actions evaluation starts with the first gamepad creation." + ); + return; + } + + if (const auto remaining = lvh::windows::github_actions_evaluation::remaining(github_actions_evaluation_state_->started_at, lvh::windows::github_actions_evaluation::Clock::now()); remaining > std::chrono::seconds::zero()) { + copy_c_string( + license.message, + std::format("GitHub Actions evaluation active for {} more seconds.", remaining.count()) + ); + } else { + copy_c_string( + license.message, + "GitHub Actions evaluation expired; an active license is required." + ); + } + return; + } + + license.activation_limit = license_state_->activation_limit; + license.activation_usage = license_state_->activation_id.empty() ? 0U : 1U; + const auto plan_name = plan_name_for_benefit(license_state_->benefit_id); + copy_c_string(license.plan_name, plan_name.empty() ? "Licensed" : plan_name); + copy_c_string(license.customer_email, license_state_->customer_email); + copy_c_string(license.expires_at, license_state_->expires_at); + + 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 { + license.state = std::to_underlying(LvhWindowsBrokerLicenseState::invalid); + copy_c_string(license.message, "License is not granted."); + } + } + + mutable std::mutex mutex_; + const bool github_actions_ = lizardbyte::common::is_github_actions(); + std::map devices_; + DriverChannel driver_; + std::optional license_state_ {load_license_state()}; + std::optional github_actions_evaluation_state_ { + github_actions_ ? load_github_actions_evaluation_state() : std::nullopt + }; + }; + + BrokerState &broker_state() { + static BrokerState state; + return state; + } + + template + void write_response(HANDLE pipe, const Response &response) { + DWORD bytes_written = 0; + static_cast(::WriteFile( + pipe, + &response, + sizeof(response), + &bytes_written, + nullptr + )); + } + + template + Request request_from_buffer(const std::array &buffer) { + Request request {}; + std::memcpy(&request, buffer.data(), sizeof(request)); + return request; + } + + void handle_pipe_client(HANDLE pipe) { + broker_state().cleanup_devices(); + + std::array request_buffer {}; + DWORD bytes_read = 0; + if (::ReadFile(pipe, request_buffer.data(), static_cast(request_buffer.size()), &bytes_read, nullptr) == FALSE || bytes_read < sizeof(LvhWindowsBrokerRequestHeader)) { + auto response = broker_state().handle_status(); + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Broker request was empty or truncated."); + write_response(pipe, response); + return; + } + + const auto header = request_from_buffer(request_buffer); + if (header.version != LVH_WINDOWS_BROKER_PROTOCOL_VERSION || header.size > bytes_read) { + auto response = broker_state().handle_status(); + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Broker request header is invalid."); + write_response(pipe, response); + return; + } + + switch (static_cast(header.type)) { + case LvhWindowsBrokerRequestType::status: + write_response(pipe, broker_state().handle_status()); + return; + + case LvhWindowsBrokerRequestType::create_gamepad: + if (header.size == sizeof(LvhWindowsBrokerCreateGamepadRequest)) { + const auto request = request_from_buffer(request_buffer); + write_response(pipe, broker_state().handle_create(request, pipe_client_process_id(pipe))); + return; + } + break; + + case LvhWindowsBrokerRequestType::destroy_device: + if (header.size == sizeof(LvhWindowsBrokerDestroyDeviceRequest)) { + const auto request = request_from_buffer(request_buffer); + write_response(pipe, broker_state().handle_destroy(request)); + return; + } + break; + + case LvhWindowsBrokerRequestType::activate_license: + if (header.size == sizeof(LvhWindowsBrokerLicenseRequest)) { + const auto request = request_from_buffer(request_buffer); + write_response(pipe, broker_state().handle_activate_license(request)); + return; + } + break; + + case LvhWindowsBrokerRequestType::validate_license: + if (header.size == sizeof(LvhWindowsBrokerLicenseRequest)) { + const auto request = request_from_buffer(request_buffer); + write_response(pipe, broker_state().handle_validate_license(request)); + return; + } + break; + + case LvhWindowsBrokerRequestType::deactivate_license: + if (header.size == sizeof(LvhWindowsBrokerLicenseRequest)) { + const auto request = request_from_buffer(request_buffer); + write_response(pipe, broker_state().handle_deactivate_license(request)); + return; + } + break; + } + + auto response = broker_state().handle_status(); + response.status = std::to_underlying(LvhWindowsBrokerStatusCode::invalid_argument); + copy_c_string(response.message, "Broker request type or size is unsupported."); + write_response(pipe, response); + } + + std::optional wait_for_pipe_client(HANDLE requested_stop_event) { + PSECURITY_DESCRIPTOR raw_security_descriptor = nullptr; + if (::ConvertStringSecurityDescriptorToSecurityDescriptorW(pipe_security_descriptor, SDDL_REVISION_1, &raw_security_descriptor, nullptr) == FALSE) { + return std::nullopt; + } + auto security_descriptor = std::unique_ptr { + raw_security_descriptor, + &::LocalFree, + }; + SECURITY_ATTRIBUTES security_attributes { + .nLength = sizeof(SECURITY_ATTRIBUTES), + .lpSecurityDescriptor = security_descriptor.get(), + .bInheritHandle = FALSE, + }; + + auto pipe = make_unique_handle(::CreateNamedPipeA(LVH_WINDOWS_BROKER_PIPE_PATH, PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, PIPE_UNLIMITED_INSTANCES, pipe_buffer_size, pipe_buffer_size, 1000, &security_attributes)); + if (!pipe) { + return std::nullopt; + } + + auto connected_event = make_unique_handle(::CreateEventA(nullptr, TRUE, FALSE, nullptr)); + if (!connected_event) { + return std::nullopt; + } + + OVERLAPPED overlapped {}; + overlapped.hEvent = connected_event.get(); + if (::ConnectNamedPipe(pipe.get(), &overlapped) == FALSE) { + const auto error = ::GetLastError(); + if (error == ERROR_PIPE_CONNECTED) { + static_cast(::SetEvent(connected_event.get())); + } else if (error != ERROR_IO_PENDING) { + return std::nullopt; + } + } + + std::array wait_handles { + connected_event.get(), + requested_stop_event, + }; + const auto wait_result = ::WaitForMultipleObjects( + static_cast(wait_handles.size()), + wait_handles.data(), + FALSE, + 1000 + ); + if (wait_result == WAIT_TIMEOUT) { + static_cast(::CancelIoEx(pipe.get(), &overlapped)); + return std::nullopt; + } + if (wait_result == WAIT_OBJECT_0 + 1U) { + static_cast(::CancelIoEx(pipe.get(), &overlapped)); + return std::nullopt; + } + if (wait_result != WAIT_OBJECT_0) { + static_cast(::CancelIoEx(pipe.get(), &overlapped)); + return std::nullopt; + } + + if (DWORD ignored = 0; ::GetOverlappedResult(pipe.get(), &overlapped, &ignored, FALSE) == FALSE && ::GetLastError() != ERROR_PIPE_CONNECTED) { + return std::nullopt; + } + + return pipe; + } + + void report_service_status(DWORD current_state, DWORD win32_exit_code = NO_ERROR, DWORD wait_hint = 0) { + auto &runtime = service_runtime(); + if (runtime.status_handle == nullptr) { + return; + } + + runtime.status.dwCurrentState = current_state; + runtime.status.dwWin32ExitCode = win32_exit_code; + runtime.status.dwWaitHint = wait_hint; + runtime.status.dwControlsAccepted = current_state == SERVICE_RUNNING ? SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN : 0; + if (current_state == SERVICE_RUNNING || current_state == SERVICE_STOPPED) { + runtime.status.dwCheckPoint = 0; + } else { + ++runtime.status.dwCheckPoint; + } + + static_cast(SetServiceStatus(runtime.status_handle, &runtime.status)); + } + + DWORD run_broker_loop(HANDLE requested_stop_event) { + if (requested_stop_event == nullptr) { + return ERROR_INVALID_HANDLE; + } + + while (WaitForSingleObject(requested_stop_event, 0) == WAIT_TIMEOUT) { + broker_state().cleanup_devices(); + + auto pipe = wait_for_pipe_client(requested_stop_event); + if (!pipe) { + continue; + } + + handle_pipe_client(pipe->get()); + static_cast(::FlushFileBuffers(pipe->get())); + static_cast(::DisconnectNamedPipe(pipe->get())); + } + + return ERROR_SUCCESS; + } + + BOOL WINAPI console_control_handler(DWORD control_type) { + switch (control_type) { + case CTRL_C_EVENT: + case CTRL_BREAK_EVENT: + case CTRL_CLOSE_EVENT: + if (const auto event = service_runtime().stop_event; event != nullptr) { + static_cast(::SetEvent(event)); + return TRUE; + } + return FALSE; + + default: + return FALSE; + } + } + + void WINAPI service_control_handler(DWORD control_code) { + switch (control_code) { + case SERVICE_CONTROL_STOP: + case SERVICE_CONTROL_SHUTDOWN: + report_service_status(SERVICE_STOP_PENDING, NO_ERROR, 1000); + if (const auto event = service_runtime().stop_event; event != nullptr) { + static_cast(SetEvent(event)); + } + return; + + default: + return; + } + } + + void WINAPI service_main(DWORD argc, wchar_t **argv) { + static_cast(argc); + static_cast(argv); + + auto &runtime = service_runtime(); + runtime.status_handle = RegisterServiceCtrlHandlerW(service_name, service_control_handler); + if (runtime.status_handle == nullptr) { + return; + } + + report_service_status(SERVICE_START_PENDING, NO_ERROR, 1000); + runtime.stop_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (runtime.stop_event == nullptr) { + report_service_status(SERVICE_STOPPED, GetLastError()); + return; + } + + report_service_status(SERVICE_RUNNING); + const auto result = run_broker_loop(runtime.stop_event); + static_cast(CloseHandle(runtime.stop_event)); + runtime.stop_event = nullptr; + report_service_status(SERVICE_STOPPED, result); + } + + int run_console() { + auto &runtime = service_runtime(); + runtime.stop_event = CreateEventW(nullptr, TRUE, FALSE, nullptr); + if (runtime.stop_event == nullptr) { + return static_cast(GetLastError()); + } + + static_cast(SetConsoleCtrlHandler(console_control_handler, TRUE)); + const auto result = run_broker_loop(runtime.stop_event); + static_cast(SetConsoleCtrlHandler(console_control_handler, FALSE)); + static_cast(CloseHandle(runtime.stop_event)); + runtime.stop_event = nullptr; + return static_cast(result); + } + +} // namespace + +int main(int argc, char **argv) { + if (argc > 1 && std::string_view {argv[1]} == "--console") { + return run_console(); + } + if (argc > 1 && std::string_view {argv[1]} == "--service-name") { + (void) broker_instance_name; + return 0; + } + + std::wstring mutable_service_name {service_name}; + if (std::array dispatch_table {{ + {mutable_service_name.data(), service_main}, + {nullptr, nullptr}, + }}; + StartServiceCtrlDispatcherW(dispatch_table.data()) != FALSE) { + return 0; + } + + const auto error = GetLastError(); + if (error == ERROR_FAILED_SERVICE_CONTROLLER_CONNECT) { + return run_console(); + } + + return static_cast(error); +} diff --git a/src/platform/windows/control_protocol.hpp b/src/platform/windows/control_protocol.hpp index 73aad43..13978c9 100644 --- a/src/platform/windows/control_protocol.hpp +++ b/src/platform/windows/control_protocol.hpp @@ -29,6 +29,7 @@ namespace lvh::detail::windows { inline constexpr std::uint16_t xbox_series_windows_device_version = 0x0509; inline constexpr std::size_t xbox_series_windows_input_report_size = 17; inline constexpr std::size_t xbox_series_windows_output_report_size = 8; + inline constexpr LvhWindowsSessionToken empty_session_token {}; inline std::uint32_t gamepad_flags(const GamepadProfileCapabilities &capabilities) { std::uint32_t flags = 0; @@ -169,26 +170,43 @@ namespace lvh::detail::windows { return request; } - inline LvhWindowsDestroyDeviceRequest make_destroy_device_request(std::uint64_t driver_device_id) { + inline LvhWindowsDestroyDeviceRequest make_destroy_device_request( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) { LvhWindowsDestroyDeviceRequest request {}; request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; request.size = sizeof(request); request.driver_device_id = driver_device_id; + request.session_token = session_token; return request; } + inline LvhWindowsDestroyDeviceRequest make_destroy_device_request(std::uint64_t driver_device_id) { + return make_destroy_device_request(driver_device_id, empty_session_token); + } + inline LvhWindowsSubmitInputReportRequest make_submit_input_report_request( std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token, const std::vector &report ) { LvhWindowsSubmitInputReportRequest request {}; request.version = LVH_WINDOWS_CONTROL_PROTOCOL_VERSION; request.size = sizeof(request); request.driver_device_id = driver_device_id; + request.session_token = session_token; request.report_size = copy_bytes(request.report, report); return request; } + inline LvhWindowsSubmitInputReportRequest make_submit_input_report_request( + std::uint64_t driver_device_id, + const std::vector &report + ) { + return make_submit_input_report_request(driver_device_id, empty_session_token, report); + } + } // namespace lvh::detail::windows diff --git a/src/platform/windows/driver/CMakeLists.txt b/src/platform/windows/driver/CMakeLists.txt index a6a1cec..d4ffd1f 100644 --- a/src/platform/windows/driver/CMakeLists.txt +++ b/src/platform/windows/driver/CMakeLists.txt @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: 2026 David Lane +# SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC # SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 if(NOT WIN32) @@ -218,6 +218,8 @@ if(LIBVIRTUALHID_WARNINGS_AS_ERRORS) endif() target_link_libraries(libvirtualhid_umdf PRIVATE + advapi32 + bcrypt "${LIBVIRTUALHID_WDF_DRIVER_STUB_UM_LIBRARY}" "${LIBVIRTUALHID_VHF_UM_LIBRARY}" "${LIBVIRTUALHID_NTDLL_LIBRARY}") diff --git a/src/platform/windows/driver/libvirtualhid.inf.in b/src/platform/windows/driver/libvirtualhid.inf.in index 2cbe8af..005e1ae 100644 --- a/src/platform/windows/driver/libvirtualhid.inf.in +++ b/src/platform/windows/driver/libvirtualhid.inf.in @@ -1,6 +1,6 @@ ; ; -; SPDX-FileCopyrightText: 2026 David Lane +; SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC ; SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 ; ; libvirtualhid UMDF2 control driver package. diff --git a/src/platform/windows/driver/libvirtualhid_umdf.cpp b/src/platform/windows/driver/libvirtualhid_umdf.cpp index 07475da..10b38e8 100644 --- a/src/platform/windows/driver/libvirtualhid_umdf.cpp +++ b/src/platform/windows/driver/libvirtualhid_umdf.cpp @@ -1,4 +1,4 @@ -// SPDX-FileCopyrightText: 2026 David Lane +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 /** @@ -28,11 +28,14 @@ #if defined(_MSC_VER) #pragma warning(pop) #endif +#include // standard includes #include #include +#include #include +#include #include #include #include @@ -41,8 +44,11 @@ #include #include #include +#include #include #include +#include +#include #include // local includes @@ -70,8 +76,55 @@ namespace { constexpr auto symbolic_link_name = L"\\DosDevices\\LibVirtualHid"; constexpr auto global_symbolic_link_name = L"\\DosDevices\\Global\\LibVirtualHid"; + constexpr auto broker_service_name = L"libvirtualhid_broker"; + constexpr auto broker_service_account_name = L"NT SERVICE\\libvirtualhid_broker"; constexpr auto trace_file_name = std::wstring_view {L"libvirtualhid-umdf-driver.log"}; + using UniqueServiceHandle = std::unique_ptr< + std::remove_pointer_t, + decltype(&::CloseServiceHandle)>; + + class UniqueHandle { + public: + explicit UniqueHandle(HANDLE handle = nullptr): + handle_ {handle} {} + + UniqueHandle(const UniqueHandle &) = delete; + UniqueHandle &operator=(const UniqueHandle &) = delete; + + UniqueHandle(UniqueHandle &&other) noexcept: + handle_ {std::exchange(other.handle_, nullptr)} {} + + UniqueHandle &operator=(UniqueHandle &&other) noexcept { + if (this != &other) { + reset(std::exchange(other.handle_, nullptr)); + } + return *this; + } + + ~UniqueHandle() { + reset(); + } + + HANDLE get() const { + return handle_; + } + + explicit operator bool() const { + return handle_ != nullptr && handle_ != INVALID_HANDLE_VALUE; + } + + void reset(HANDLE handle = nullptr) { + if (handle_ != nullptr && handle_ != INVALID_HANDLE_VALUE) { + static_cast(CloseHandle(handle_)); + } + handle_ = handle; + } + + private: + HANDLE handle_ {}; + }; + struct DeviceRecord { std::mutex mutex; std::uint64_t driver_device_id {}; @@ -79,6 +132,7 @@ namespace { WDFFILEOBJECT owner_file {}; WDFIOTARGET vhf_io_target {}; LvhWindowsCreateGamepadRequest request {}; + LvhWindowsSessionToken session_token {}; VHFHANDLE vhf_handle {}; std::vector report_descriptor; std::wstring hardware_ids; @@ -480,6 +534,171 @@ namespace { request.report_size <= LVH_WINDOWS_MAX_INPUT_REPORT_SIZE; } + bool valid_destroy_device_request(const LvhWindowsDestroyDeviceRequest &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; + } + + NTSTATUS generate_session_token(LvhWindowsSessionToken &session_token) { + const auto status = BCryptGenRandom( + nullptr, + session_token.bytes, + static_cast(sizeof(session_token.bytes)), + BCRYPT_USE_SYSTEM_PREFERRED_RNG + ); + if (!NT_SUCCESS(status)) { + return status; + } + + const auto all_zero = std::ranges::all_of(session_token.bytes, [](const auto value) { + return value == 0U; + }); + return all_zero ? STATUS_UNSUCCESSFUL : STATUS_SUCCESS; + } + + std::optional> lookup_account_sid(const wchar_t *account_name) { + auto sid_size = DWORD {}; + auto domain_size = DWORD {}; + auto sid_name_use = SID_NAME_USE {}; + static_cast(LookupAccountNameW( + nullptr, + account_name, + nullptr, + &sid_size, + nullptr, + &domain_size, + &sid_name_use + )); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || sid_size == 0U) { + trace_status("lookup broker service sid size failed"); + return std::nullopt; + } + + auto sid = std::vector(sid_size); + auto domain = std::wstring(domain_size, L'\0'); + if (LookupAccountNameW(nullptr, account_name, sid.data(), &sid_size, domain.data(), &domain_size, &sid_name_use) == FALSE) { + trace_status("lookup broker service sid failed"); + return std::nullopt; + } + + sid.resize(sid_size); + return sid; + } + + std::optional> broker_service_sid() { + static std::mutex mutex; + static auto sid = std::optional> {}; + + std::lock_guard lock {mutex}; + if (!sid) { + sid = lookup_account_sid(broker_service_account_name); + } + return sid; + } + + bool token_has_sid(HANDLE token, const std::vector &sid) { + if (sid.empty()) { + return false; + } + + auto sid_to_check = sid; + if (IsValidSid(sid_to_check.data()) == FALSE) { + return false; + } + + auto token_groups_size = DWORD {}; + static_cast(GetTokenInformation(token, TokenGroups, nullptr, 0, &token_groups_size)); + if (GetLastError() != ERROR_INSUFFICIENT_BUFFER || token_groups_size == 0U) { + trace_status("broker service sid membership failed: token group size"); + return false; + } + + auto token_groups_buffer = std::make_unique_for_overwrite(token_groups_size); + auto *token_groups = static_cast(static_cast(token_groups_buffer.get())); + if (GetTokenInformation(token, TokenGroups, token_groups, token_groups_size, &token_groups_size) == FALSE) { + trace_status("broker service sid membership failed: token groups"); + return false; + } + + for (auto index = DWORD {0}; index < token_groups->GroupCount; ++index) { + const auto &group = token_groups->Groups[index]; + if ((group.Attributes & SE_GROUP_ENABLED) != 0U && EqualSid(group.Sid, sid_to_check.data()) != FALSE) { + return true; + } + } + return false; + } + + bool requestor_is_running_broker_service(DWORD requestor_process_id) { + auto service_manager = UniqueServiceHandle { + ::OpenSCManagerW(nullptr, nullptr, SC_MANAGER_CONNECT), + &::CloseServiceHandle + }; + if (!service_manager) { + trace_status("broker service identity failed: open service manager"); + return false; + } + + auto service = UniqueServiceHandle { + ::OpenServiceW(service_manager.get(), broker_service_name, SERVICE_QUERY_STATUS), + &::CloseServiceHandle + }; + if (!service) { + trace_status("broker service identity failed: open service"); + return false; + } + + SERVICE_STATUS_PROCESS status {}; + const auto status_bytes = std::as_writable_bytes(std::span {&status, 1}); + auto bytes_needed = DWORD {}; + if (::QueryServiceStatusEx(service.get(), SC_STATUS_PROCESS_INFO, std::bit_cast(status_bytes.data()), static_cast(status_bytes.size()), &bytes_needed) == FALSE) { + trace_status("broker service identity failed: query status"); + return false; + } + + return status.dwCurrentState == SERVICE_RUNNING && + status.dwProcessId == requestor_process_id; + } + + bool request_is_authorized_broker_service(WDFREQUEST request) { + const auto requestor_process_id = WdfRequestGetRequestorProcessId(request); + if (requestor_process_id == 0U) { + trace_status("broker service access denied: missing requestor pid"); + return false; + } + + auto process = UniqueHandle { + OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, requestor_process_id) + }; + if (!process) { + trace_status("broker service identity: open requestor process failed"); + return requestor_is_running_broker_service(requestor_process_id); + } + + auto token_handle = HANDLE {}; + if (OpenProcessToken(process.get(), TOKEN_QUERY, &token_handle) == FALSE) { + trace_status("broker service identity: open requestor token failed"); + return requestor_is_running_broker_service(requestor_process_id); + } + + const auto token = UniqueHandle {token_handle}; + const auto service_sid = broker_service_sid(); + if (service_sid && token_has_sid(token.get(), *service_sid)) { + return true; + } + + if (requestor_is_running_broker_service(requestor_process_id)) { + trace_status("broker service authorized by running service identity"); + return true; + } + + trace_status("broker service access denied: service identity not present"); + return false; + } + bool symbolic_link_already_exists(NTSTATUS status) { const auto value = static_cast(status); return value == 0xC0000035U || value == 0x800700B7U || value == 0x900700B7U; @@ -650,6 +869,11 @@ namespace { } void handle_create_gamepad_request(WDFDEVICE device, WDFREQUEST request) { + if (!request_is_authorized_broker_service(request)) { + complete_request(request, STATUS_ACCESS_DENIED); + return; + } + auto *create_request = static_cast(nullptr); auto status = retrieve_input_buffer(request, create_request); if (!NT_SUCCESS(status)) { @@ -681,6 +905,14 @@ namespace { record->owner_device = device; record->owner_file = WdfRequestGetFileObject(request); record->request = *create_request; + status = generate_session_token(record->session_token); + if (!NT_SUCCESS(status)) { + trace_status("create_gamepad token failed", status); + create_response->status = LVH_WINDOWS_STATUS_BACKEND_FAILURE; + complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); + return; + } + trace_status("create_gamepad begin"); status = create_vhf_device(device, record); @@ -697,12 +929,18 @@ namespace { } create_response->status = LVH_WINDOWS_STATUS_SUCCESS; create_response->driver_device_id = driver_device_id; + create_response->session_token = record->session_token; set_device_path(driver_device_id, create_response->device_path); trace_status("create_gamepad success"); complete_request(request, STATUS_SUCCESS, sizeof(*create_response)); } void handle_destroy_device_request(WDFREQUEST request) { + if (!request_is_authorized_broker_service(request)) { + complete_request(request, STATUS_ACCESS_DENIED); + return; + } + auto *destroy_request = static_cast(nullptr); const auto status = retrieve_input_buffer(request, destroy_request); if (!NT_SUCCESS(status)) { @@ -710,7 +948,7 @@ namespace { return; } - if (!valid_header(destroy_request->version, destroy_request->size, sizeof(*destroy_request))) { + if (!valid_destroy_device_request(*destroy_request)) { complete_request(request, STATUS_INVALID_PARAMETER); return; } @@ -721,6 +959,12 @@ namespace { std::lock_guard lock {state.devices_mutex}; const auto iter = state.devices.find(destroy_request->driver_device_id); if (iter != state.devices.end()) { + if (!session_token_matches(*iter->second, destroy_request->session_token)) { + trace_status("destroy_device access denied"); + complete_request(request, STATUS_ACCESS_DENIED); + return; + } + record = iter->second; state.devices.erase(iter); trace_status("destroy_device found"); @@ -754,6 +998,12 @@ namespace { return; } + if (!session_token_matches(*record, submit_request->session_token)) { + trace_status("submit_input_report access denied"); + complete_request(request, STATUS_ACCESS_DENIED); + return; + } + std::lock_guard lock {record->mutex}; if (record->vhf_handle == nullptr) { trace_status("submit_input_report missing vhf"); diff --git a/src/platform/windows/shared/lvh_windows_broker_config.hpp b/src/platform/windows/shared/lvh_windows_broker_config.hpp new file mode 100644 index 0000000..53279c1 --- /dev/null +++ b/src/platform/windows/shared/lvh_windows_broker_config.hpp @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/windows/shared/lvh_windows_broker_config.hpp + * @brief Compiled Windows broker licensing constants. + * + * Update this file when Polar organization, benefit, or purchase URL changes. + */ +#pragma once + +#include +#include + +namespace lvh::windows::broker_config { + + struct PolarBenefit { + std::string_view id; + std::string_view plan_name; + }; + + // Polar's public license API identifies the organization and license-key benefit, + // not the product. Restrict production licenses to this organization's yearly and + // lifetime license-key benefits. + inline constexpr auto polar_organization_id = + std::string_view {"3db9f05a-44d7-42f1-ba7c-a0f198235fb7"}; + inline constexpr auto allowed_benefits = std::array { + PolarBenefit { + .id = "eb316dac-bf6a-4359-95a2-86c299d48ecc", + .plan_name = "Yearly", + }, + PolarBenefit { + .id = "157374cb-f526-4154-81ba-9f2c92a053ca", + .plan_name = "Lifetime", + }, + }; + + // Use persistent Polar Checkout Links and the organization's hosted customer portal. + inline constexpr auto buy_url = + std::string_view {"https://buy.polar.sh/polar_cl_zj6Io5NVukXfZSl97ULtFvImfI5L1jbL2cSnc0Y72Pt"}; + inline constexpr auto manage_account_url = + std::string_view {"https://polar.sh/lizardbyte-llc/portal"}; + +} // namespace lvh::windows::broker_config diff --git a/src/platform/windows/shared/lvh_windows_broker_protocol.h b/src/platform/windows/shared/lvh_windows_broker_protocol.h new file mode 100644 index 0000000..93a0495 --- /dev/null +++ b/src/platform/windows/shared/lvh_windows_broker_protocol.h @@ -0,0 +1,178 @@ +/** + * @file src/platform/windows/shared/lvh_windows_broker_protocol.h + * @brief Stable named-pipe protocol shared by the Windows backend, broker, and control UI. + */ +#pragma once + +#include "lvh_windows_protocol.h" + +#include + +#ifdef __cplusplus + +inline constexpr uint32_t LVH_WINDOWS_BROKER_PROTOCOL_VERSION = 2u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE = 512u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_LICENSE_KEY_SIZE = 128u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_INSTANCE_NAME_SIZE = 128u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_PLAN_NAME_SIZE = 128u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_CUSTOMER_EMAIL_SIZE = 128u; +inline constexpr uint32_t LVH_WINDOWS_BROKER_MAX_TIMESTAMP_SIZE = 64u; +inline constexpr char LVH_WINDOWS_BROKER_PIPE_PATH[] = R"(\\.\pipe\libvirtualhid-broker)"; + +enum class LvhWindowsBrokerRequestType : uint32_t { + status = 1, + create_gamepad = 2, + destroy_device = 3, + activate_license = 4, + validate_license = 5, + deactivate_license = 6, +}; + +enum class LvhWindowsBrokerStatusCode : uint32_t { + success = 0, + invalid_argument = 1, + unsupported_profile = 2, + device_not_found = 3, + backend_unavailable = 4, + backend_failure = 5, + license_required = 6, + license_invalid = 7, + activation_limit_reached = 8, + network_unavailable = 9, +}; + +enum class LvhWindowsBrokerLicenseState : uint32_t { + free = 0, + licensed = 1, + expired = 2, + disabled = 3, + invalid = 4, +}; + +#else + +enum { + LVH_WINDOWS_BROKER_PROTOCOL_VERSION = 2u, + LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE = 512u, + LVH_WINDOWS_BROKER_MAX_LICENSE_KEY_SIZE = 128u, + LVH_WINDOWS_BROKER_MAX_INSTANCE_NAME_SIZE = 128u, + LVH_WINDOWS_BROKER_MAX_PLAN_NAME_SIZE = 128u, + LVH_WINDOWS_BROKER_MAX_CUSTOMER_EMAIL_SIZE = 128u, + LVH_WINDOWS_BROKER_MAX_TIMESTAMP_SIZE = 64u, +}; + +static const char LVH_WINDOWS_BROKER_PIPE_PATH[] = "\\\\.\\pipe\\libvirtualhid-broker"; + +enum LvhWindowsBrokerRequestType { + LVH_WINDOWS_BROKER_REQUEST_STATUS = 1, + LVH_WINDOWS_BROKER_REQUEST_CREATE_GAMEPAD = 2, + LVH_WINDOWS_BROKER_REQUEST_DESTROY_DEVICE = 3, + LVH_WINDOWS_BROKER_REQUEST_ACTIVATE_LICENSE = 4, + LVH_WINDOWS_BROKER_REQUEST_VALIDATE_LICENSE = 5, + LVH_WINDOWS_BROKER_REQUEST_DEACTIVATE_LICENSE = 6, +}; + +enum LvhWindowsBrokerStatusCode { + LVH_WINDOWS_BROKER_STATUS_SUCCESS = 0, + LVH_WINDOWS_BROKER_STATUS_INVALID_ARGUMENT = 1, + LVH_WINDOWS_BROKER_STATUS_UNSUPPORTED_PROFILE = 2, + LVH_WINDOWS_BROKER_STATUS_DEVICE_NOT_FOUND = 3, + LVH_WINDOWS_BROKER_STATUS_BACKEND_UNAVAILABLE = 4, + LVH_WINDOWS_BROKER_STATUS_BACKEND_FAILURE = 5, + LVH_WINDOWS_BROKER_STATUS_LICENSE_REQUIRED = 6, + LVH_WINDOWS_BROKER_STATUS_LICENSE_INVALID = 7, + LVH_WINDOWS_BROKER_STATUS_ACTIVATION_LIMIT_REACHED = 8, + LVH_WINDOWS_BROKER_STATUS_NETWORK_UNAVAILABLE = 9, +}; + +enum LvhWindowsBrokerLicenseState { + LVH_WINDOWS_BROKER_LICENSE_FREE = 0, + LVH_WINDOWS_BROKER_LICENSE_LICENSED = 1, + LVH_WINDOWS_BROKER_LICENSE_EXPIRED = 2, + LVH_WINDOWS_BROKER_LICENSE_DISABLED = 3, + LVH_WINDOWS_BROKER_LICENSE_INVALID = 4, +}; + +#endif + +extern "C" { + + struct LvhWindowsBrokerRequestHeader { + uint32_t version; + uint32_t size; + uint32_t type; + uint32_t reserved0; + }; + + struct LvhWindowsBrokerLicenseStatus { + uint32_t version; + uint32_t size; + uint32_t state; + uint32_t active_devices; + uint32_t free_active_device_limit; + uint32_t activation_limit; + uint32_t activation_usage; + char plan_name[LVH_WINDOWS_BROKER_MAX_PLAN_NAME_SIZE]; + char customer_email[LVH_WINDOWS_BROKER_MAX_CUSTOMER_EMAIL_SIZE]; + char expires_at[LVH_WINDOWS_BROKER_MAX_TIMESTAMP_SIZE]; + char message[LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE]; + }; + + struct LvhWindowsBrokerStatusRequest { + LvhWindowsBrokerRequestHeader header; + }; + + struct LvhWindowsBrokerStatusResponse { + uint32_t version; + uint32_t size; + uint32_t status; + uint32_t reserved0; + LvhWindowsBrokerLicenseStatus license; + char message[LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE]; + }; + + struct LvhWindowsBrokerCreateGamepadRequest { + LvhWindowsBrokerRequestHeader header; + uint64_t client_control_handle; + LvhWindowsCreateGamepadRequest gamepad; + }; + + struct LvhWindowsBrokerCreateGamepadResponse { + uint32_t version; + uint32_t size; + uint32_t status; + uint32_t reserved0; + LvhWindowsCreateGamepadResponse gamepad; + LvhWindowsBrokerLicenseStatus license; + char message[LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE]; + }; + + struct LvhWindowsBrokerDestroyDeviceRequest { + LvhWindowsBrokerRequestHeader header; + LvhWindowsDestroyDeviceRequest device; + }; + + struct LvhWindowsBrokerDestroyDeviceResponse { + uint32_t version; + uint32_t size; + uint32_t status; + uint32_t reserved0; + LvhWindowsBrokerLicenseStatus license; + char message[LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE]; + }; + + struct LvhWindowsBrokerLicenseRequest { + LvhWindowsBrokerRequestHeader header; + char license_key[LVH_WINDOWS_BROKER_MAX_LICENSE_KEY_SIZE]; + char instance_name[LVH_WINDOWS_BROKER_MAX_INSTANCE_NAME_SIZE]; + }; + + struct LvhWindowsBrokerLicenseResponse { + uint32_t version; + uint32_t size; + uint32_t status; + uint32_t reserved0; + LvhWindowsBrokerLicenseStatus license; + char message[LVH_WINDOWS_BROKER_MAX_MESSAGE_SIZE]; + }; +} diff --git a/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp b/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp new file mode 100644 index 0000000..76c5c5e --- /dev/null +++ b/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp + * @brief Time-window helpers for the GitHub Actions gamepad evaluation exception. + */ +#pragma once + +// standard includes +#include + +namespace lvh::windows::github_actions_evaluation { + + using Clock = std::chrono::system_clock; + + /** + * @brief Maximum unlicensed gamepad evaluation window on GitHub-hosted CI. + */ + inline constexpr auto duration = std::chrono::minutes {5}; + + /** + * @brief Check whether an evaluation window is active. + * + * A clock earlier than the persisted start is treated as expired so rolling + * the system clock backward cannot extend the window. + * + * @param started_at Persisted start of the evaluation window. + * @param now Current wall-clock time. + * @return `true` from the start instant until, but not including, its deadline. + */ + constexpr bool active(Clock::time_point started_at, Clock::time_point now) noexcept { + return now >= started_at && now < started_at + duration; + } + + /** + * @brief Calculate display seconds remaining in an evaluation window. + * + * @param started_at Persisted start of the evaluation window. + * @param now Current wall-clock time. + * @return Remaining seconds rounded up, or zero when the window is inactive. + */ + constexpr std::chrono::seconds remaining( + Clock::time_point started_at, + Clock::time_point now + ) noexcept { + if (!active(started_at, now)) { + return std::chrono::seconds::zero(); + } + return std::chrono::ceil(started_at + duration - now); + } + +} // namespace lvh::windows::github_actions_evaluation diff --git a/src/platform/windows/shared/lvh_windows_protocol.h b/src/platform/windows/shared/lvh_windows_protocol.h index 12c4d30..7fc6513 100644 --- a/src/platform/windows/shared/lvh_windows_protocol.h +++ b/src/platform/windows/shared/lvh_windows_protocol.h @@ -19,6 +19,7 @@ inline constexpr uint32_t LVH_WINDOWS_MAX_DEVICE_PATH_SIZE = 260u; inline constexpr uint32_t LVH_WINDOWS_MAX_DEVICE_NAME_SIZE = 128u; inline constexpr uint32_t LVH_WINDOWS_MAX_MANUFACTURER_SIZE = 128u; inline constexpr uint32_t LVH_WINDOWS_MAX_STABLE_ID_SIZE = 128u; +inline constexpr uint32_t LVH_WINDOWS_SESSION_TOKEN_SIZE = 32u; inline constexpr uint32_t LVH_WINDOWS_FILE_DEVICE_LIBVIRTUALHID = 0x8000u; inline constexpr uint32_t LVH_WINDOWS_METHOD_BUFFERED = 0u; @@ -149,6 +150,7 @@ enum { LVH_WINDOWS_MAX_DEVICE_NAME_SIZE = 128u, LVH_WINDOWS_MAX_MANUFACTURER_SIZE = 128u, LVH_WINDOWS_MAX_STABLE_ID_SIZE = 128u, + LVH_WINDOWS_SESSION_TOKEN_SIZE = 32u, LVH_WINDOWS_FILE_DEVICE_LIBVIRTUALHID = 0x8000u, LVH_WINDOWS_METHOD_BUFFERED = 0u, LVH_WINDOWS_FILE_READ_ACCESS = 1u, @@ -218,6 +220,10 @@ extern "C" { uint32_t stable_id_size; }; + struct LvhWindowsSessionToken { + uint8_t bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE]; + }; + struct LvhWindowsCreateGamepadRequest { uint32_t version; uint32_t size; @@ -239,6 +245,7 @@ extern "C" { uint32_t status; uint32_t reserved0; uint64_t driver_device_id; + LvhWindowsSessionToken session_token; char device_path[LVH_WINDOWS_MAX_DEVICE_PATH_SIZE]; }; @@ -246,12 +253,14 @@ extern "C" { uint32_t version; uint32_t size; uint64_t driver_device_id; + LvhWindowsSessionToken session_token; }; struct LvhWindowsSubmitInputReportRequest { uint32_t version; uint32_t size; uint64_t driver_device_id; + LvhWindowsSessionToken session_token; uint32_t report_size; uint32_t reserved0; uint8_t report[LVH_WINDOWS_MAX_INPUT_REPORT_SIZE]; diff --git a/src/platform/windows/windows_backend.cpp b/src/platform/windows/windows_backend.cpp index 425d448..89c776f 100644 --- a/src/platform/windows/windows_backend.cpp +++ b/src/platform/windows/windows_backend.cpp @@ -26,13 +26,18 @@ // local includes #include "core/backend.hpp" +#include "lvh_windows_broker_protocol.h" #include "platform/windows/control_protocol.hpp" #include "platform/windows/keylayout.hpp" #include "platform/windows/shared/generic_pid_rumble.hpp" +#include "platform/windows/windows_broker_client.hpp" #include #include +// lib includes +#include + // standard includes #include #include @@ -418,12 +423,8 @@ namespace lvh::detail { std::vector resolve_control_device_paths() { constexpr auto environment_name = "LIBVIRTUALHID_WINDOWS_CONTROL_DEVICE"; - if (const auto required_size = ::GetEnvironmentVariableA(environment_name, nullptr, 0); required_size > 1U) { - std::string path(required_size - 1U, '\0'); - const auto copied_size = ::GetEnvironmentVariableA(environment_name, path.data(), required_size); - if (copied_size > 0U && copied_size < required_size) { - return {path}; - } + if (std::string override_path; lizardbyte::common::get_env(environment_name, override_path) && !override_path.empty()) { + return {override_path}; } auto paths = enumerate_control_device_interface_paths(); @@ -486,15 +487,23 @@ namespace lvh::detail { virtual const std::string &path() const = 0; + virtual HANDLE native_handle() const { + return nullptr; + } + virtual OperationStatus create_gamepad( const LvhWindowsCreateGamepadRequest &request, LvhWindowsCreateGamepadResponse &response ) const = 0; - virtual OperationStatus destroy_device(std::uint64_t driver_device_id) const = 0; + virtual OperationStatus destroy_device( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) const = 0; virtual OperationStatus submit_input_report( std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token, const std::vector &report ) const = 0; @@ -541,6 +550,10 @@ namespace lvh::detail { return path_; } + HANDLE native_handle() const override { + return handle_->value.get(); + } + OperationStatus create_gamepad( const LvhWindowsCreateGamepadRequest &request, LvhWindowsCreateGamepadResponse &response @@ -560,8 +573,11 @@ namespace lvh::detail { return protocol_status(response.status, "Windows driver rejected gamepad creation"); } - OperationStatus destroy_device(std::uint64_t driver_device_id) const override { - auto request = windows::make_destroy_device_request(driver_device_id); + OperationStatus destroy_device( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) const override { + auto request = windows::make_destroy_device_request(driver_device_id, session_token); DWORD bytes_returned = 0; return device_io_control( LVH_WINDOWS_IOCTL_DESTROY_DEVICE, @@ -573,6 +589,7 @@ namespace lvh::detail { OperationStatus submit_input_report( std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token, const std::vector &report ) const override { using enum ErrorCode; @@ -581,7 +598,7 @@ namespace lvh::detail { return OperationStatus::failure(invalid_argument, "input report exceeds Windows control protocol limit"); } - auto request = windows::make_submit_input_report_request(driver_device_id, report); + auto request = windows::make_submit_input_report_request(driver_device_id, session_token, report); DWORD bytes_returned = 0; return device_io_control( LVH_WINDOWS_IOCTL_SUBMIT_INPUT_REPORT, @@ -700,16 +717,115 @@ namespace lvh::detail { return {}; } + class BrokeredWindowsControlChannel final: public WindowsControlChannel { + public: + explicit BrokeredWindowsControlChannel(std::unique_ptr direct_channel): + direct_channel_ {std::move(direct_channel)} {} + + static std::unique_ptr open(std::unique_ptr direct_channel) { + if (!direct_channel) { + return nullptr; + } + + auto brokered_channel = std::make_unique(std::move(direct_channel)); + if (!brokered_channel->broker_available()) { + return nullptr; + } + + return brokered_channel; + } + + const std::string &path() const override { + return direct_channel_->path(); + } + + HANDLE native_handle() const override { + return direct_channel_->native_handle(); + } + + OperationStatus create_gamepad( + const LvhWindowsCreateGamepadRequest &request, + LvhWindowsCreateGamepadResponse &response + ) const override { + LvhWindowsBrokerCreateGamepadRequest broker_request {}; + broker_request.header = windows_broker::make_request_header( + LvhWindowsBrokerRequestType::create_gamepad, + sizeof(broker_request) + ); + broker_request.client_control_handle = static_cast( + reinterpret_cast(direct_channel_->native_handle()) + ); + broker_request.gamepad = request; + + LvhWindowsBrokerCreateGamepadResponse broker_response {}; + if (const auto status = windows_broker::call(broker_request, broker_response, "create Windows gamepad through broker"); !status.ok()) { + return status; + } + + response = broker_response.gamepad; + return protocol_status(response.status, "Windows driver rejected gamepad creation"); + } + + OperationStatus destroy_device( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) const override { + LvhWindowsBrokerDestroyDeviceRequest broker_request {}; + broker_request.header = windows_broker::make_request_header( + LvhWindowsBrokerRequestType::destroy_device, + sizeof(broker_request) + ); + broker_request.device = windows::make_destroy_device_request(driver_device_id, session_token); + + LvhWindowsBrokerDestroyDeviceResponse broker_response {}; + return windows_broker::call(broker_request, broker_response, "destroy Windows virtual HID device through broker"); + } + + OperationStatus submit_input_report( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token, + const std::vector &report + ) const override { + return direct_channel_->submit_input_report(driver_device_id, session_token, report); + } + + std::optional read_output_report(HANDLE stop_event) const override { + return direct_channel_->read_output_report(stop_event); + } + + private: + bool broker_available() const { + LvhWindowsBrokerStatusRequest request {}; + request.header = windows_broker::make_request_header( + LvhWindowsBrokerRequestType::status, + sizeof(request) + ); + + LvhWindowsBrokerStatusResponse response {}; + return windows_broker::call(request, response, "query Windows broker status").ok(); + } + + std::unique_ptr direct_channel_; + }; + + WindowsControlChannels open_brokered_control_channels() { + auto channels = open_control_channels(); + channels.command = BrokeredWindowsControlChannel::open(std::move(channels.command)); + return channels; + } + class WindowsGamepadState { public: WindowsGamepadState( DeviceId client_device_id, std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token, DeviceProfile device_profile, std::string device_path ): client_id {client_device_id}, driver_id {driver_device_id}, + token {session_token}, profile {std::move(device_profile)}, path {std::move(device_path)} { if (profile.gamepad_kind == GamepadProfileKind::generic && profile.capabilities.supports_rumble) { @@ -725,6 +841,7 @@ namespace lvh::detail { mutable std::mutex mutex_; DeviceId client_id; std::uint64_t driver_id; + LvhWindowsSessionToken token {}; DeviceProfile profile; std::string path; bool open = true; @@ -819,6 +936,7 @@ namespace lvh::detail { auto state = std::make_shared( id, response.driver_device_id, + response.session_token, options.profile, response.device_path[0] == '\0' ? command_channel_->path() : std::string {response.device_path} ); @@ -838,11 +956,13 @@ namespace lvh::detail { const std::vector &report ) const { const auto driver_id = state->driver_id; - return command_channel_->submit_input_report(driver_id, report); + const auto token = state->token; + return command_channel_->submit_input_report(driver_id, token, report); } OperationStatus close_gamepad(const std::shared_ptr &state) { std::uint64_t driver_id = 0; + LvhWindowsSessionToken token {}; { std::lock_guard lock {state->mutex_}; if (!state->open) { @@ -851,6 +971,7 @@ namespace lvh::detail { state->open = false; driver_id = state->driver_id; + token = state->token; } { @@ -859,7 +980,7 @@ namespace lvh::detail { } notify_pid_timer(); - return command_channel_->destroy_device(driver_id); + return command_channel_->destroy_device(driver_id, token); } private: @@ -1736,7 +1857,7 @@ namespace lvh::detail { class WindowsBackend final: public Backend { public: WindowsBackend(): - WindowsBackend(open_control_channels()) {} + WindowsBackend(open_brokered_control_channels()) {} explicit WindowsBackend(WindowsControlChannels channels): WindowsBackend(std::move(channels.command), std::move(channels.event)) {} diff --git a/src/platform/windows/windows_broker_client.cpp b/src/platform/windows/windows_broker_client.cpp new file mode 100644 index 0000000..ad86db6 --- /dev/null +++ b/src/platform/windows/windows_broker_client.cpp @@ -0,0 +1,152 @@ +/** + * @file src/platform/windows/windows_broker_client.cpp + * @brief Internal Windows broker client helper definitions. + */ + +// local includes +#include "platform/windows/windows_broker_client.hpp" + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef NOMINMAX + #define NOMINMAX +#endif +#ifndef WIN32_LEAN_AND_MEAN + #define WIN32_LEAN_AND_MEAN +#endif + +// platform includes +#include + +namespace lvh::detail::windows_broker { + namespace { + + using UniqueHandle = std::unique_ptr; + + // GENERIC_READ is required when switching the client end to message-read mode. + constexpr auto pipe_client_access = GENERIC_READ | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES; + constexpr auto pipe_client_granted_access = FILE_GENERIC_READ | FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES; + constexpr auto pipe_wait_timeout = 5000U; + + static_assert(pipe_client_access == 0x80000102U); + static_assert(pipe_client_granted_access == 0x0012018BU); + static_assert((pipe_client_granted_access & FILE_CREATE_PIPE_INSTANCE) == 0U); + + UniqueHandle make_unique_handle(HANDLE handle) { + if (handle == INVALID_HANDLE_VALUE) { + handle = nullptr; + } + return {handle, &::CloseHandle}; + } + + std::string windows_error_message(DWORD error_code) { + std::array message_buffer {}; + const auto message_size = ::FormatMessageA( + FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + nullptr, + error_code, + MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), + message_buffer.data(), + static_cast(message_buffer.size()), + nullptr + ); + + if (message_size == 0U) { + return std::format("Windows error {}", error_code); + } + + std::string message {message_buffer.data(), message_size}; + while (!message.empty() && (message.back() == '\r' || message.back() == '\n')) { + message.pop_back(); + } + return message; + } + + } // namespace + + LvhWindowsBrokerRequestHeader make_request_header(LvhWindowsBrokerRequestType type, std::uint32_t size) { + return { + .version = LVH_WINDOWS_BROKER_PROTOCOL_VERSION, + .size = size, + .type = std::to_underlying(type), + .reserved0 = 0, + }; + } + + OperationStatus response_status(std::uint32_t status, std::string_view message) { + using enum ErrorCode; + + const auto text = message.empty() ? "Windows broker request failed" : std::string {message}; + switch (static_cast(status)) { + case LvhWindowsBrokerStatusCode::success: + return OperationStatus::success(); + case LvhWindowsBrokerStatusCode::invalid_argument: + return OperationStatus::failure(invalid_argument, text); + case LvhWindowsBrokerStatusCode::unsupported_profile: + return OperationStatus::failure(unsupported_profile, text); + case LvhWindowsBrokerStatusCode::device_not_found: + return OperationStatus::failure(device_closed, text); + case LvhWindowsBrokerStatusCode::backend_unavailable: + return OperationStatus::failure(backend_unavailable, text); + case LvhWindowsBrokerStatusCode::license_required: + return OperationStatus::failure(license_required, text); + case LvhWindowsBrokerStatusCode::license_invalid: + return OperationStatus::failure(license_invalid, text); + case LvhWindowsBrokerStatusCode::activation_limit_reached: + return OperationStatus::failure(activation_limit_reached, text); + case LvhWindowsBrokerStatusCode::network_unavailable: + return OperationStatus::failure(network_unavailable, text); + case LvhWindowsBrokerStatusCode::backend_failure: + default: + return OperationStatus::failure(backend_failure, text); + } + } + + OperationStatus call_bytes( + std::span request, + std::span response, + std::string_view operation + ) { + auto pipe = make_unique_handle(::CreateFileA(LVH_WINDOWS_BROKER_PIPE_PATH, pipe_client_access, 0, nullptr, OPEN_EXISTING, 0, nullptr)); + if (!pipe && ::GetLastError() == ERROR_PIPE_BUSY && ::WaitNamedPipeA(LVH_WINDOWS_BROKER_PIPE_PATH, pipe_wait_timeout) != FALSE) { + pipe = make_unique_handle(::CreateFileA(LVH_WINDOWS_BROKER_PIPE_PATH, pipe_client_access, 0, nullptr, OPEN_EXISTING, 0, nullptr)); + } + if (!pipe) { + return OperationStatus::failure( + ErrorCode::backend_unavailable, + std::format("{}: {}", operation, windows_error_message(::GetLastError())) + ); + } + + if (DWORD read_mode = PIPE_READMODE_MESSAGE; ::SetNamedPipeHandleState(pipe.get(), &read_mode, nullptr, nullptr) == FALSE) { + return OperationStatus::failure( + ErrorCode::backend_unavailable, + std::format("{}: {}", operation, windows_error_message(::GetLastError())) + ); + } + + auto request_copy = std::vector {request.begin(), request.end()}; + DWORD bytes_read = 0; + if (::TransactNamedPipe(pipe.get(), request_copy.data(), static_cast(request_copy.size()), response.data(), static_cast(response.size()), &bytes_read, nullptr) == FALSE) { + return OperationStatus::failure( + ErrorCode::backend_unavailable, + std::format("{}: {}", operation, windows_error_message(::GetLastError())) + ); + } + + if (bytes_read != response.size()) { + return OperationStatus::failure(ErrorCode::backend_failure, "Windows broker returned a truncated or invalid response"); + } + + return OperationStatus::success(); + } + +} // namespace lvh::detail::windows_broker diff --git a/src/platform/windows/windows_broker_client.hpp b/src/platform/windows/windows_broker_client.hpp new file mode 100644 index 0000000..991b488 --- /dev/null +++ b/src/platform/windows/windows_broker_client.hpp @@ -0,0 +1,77 @@ +/** + * @file src/platform/windows/windows_broker_client.hpp + * @brief Internal Windows broker client helpers. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +// local includes +#include "lvh_windows_broker_protocol.h" + +#include + +namespace lvh::detail::windows_broker { + + /** + * @brief Create a versioned Windows broker request header. + * + * @param type Broker request type. + * @param size Full request structure size. + * @return Initialized request header. + */ + LvhWindowsBrokerRequestHeader make_request_header(LvhWindowsBrokerRequestType type, std::uint32_t size); + + /** + * @brief Map a Windows broker response status into the public status type. + * + * @param status Broker status code. + * @param message Broker-supplied status message. + * @return Public operation status. + */ + OperationStatus response_status(std::uint32_t status, std::string_view message); + + /** + * @brief Send fixed-size request bytes to the local Windows broker. + * + * @param request Request bytes. + * @param response Writable response bytes. + * @param operation Human-readable operation description. + * @return Named-pipe transport status. + */ + OperationStatus call_bytes( + std::span request, + std::span response, + std::string_view operation + ); + + /** + * @brief Send a typed request to the local Windows broker. + * + * @tparam Request Fixed-size broker request structure. + * @tparam Response Fixed-size broker response structure. + * @param request Request structure. + * @param response Response structure populated by the broker. + * @param operation Human-readable operation description. + * @return Broker operation status. + */ + template + OperationStatus call(const Request &request, Response &response, std::string_view operation) { + const auto request_bytes = std::as_bytes(std::span {&request, 1U}); + const auto response_bytes = std::as_writable_bytes(std::span {&response, 1U}); + if (auto status = call_bytes(request_bytes, response_bytes, operation); !status.ok()) { + return status; + } + + if (response.version != LVH_WINDOWS_BROKER_PROTOCOL_VERSION || response.size != sizeof(response)) { + return OperationStatus::failure(ErrorCode::backend_failure, "Windows broker returned a truncated or invalid response"); + } + + return response_status(response.status, response.message); + } + +} // namespace lvh::detail::windows_broker diff --git a/src/platform/windows/windows_license.cpp b/src/platform/windows/windows_license.cpp new file mode 100644 index 0000000..e29084f --- /dev/null +++ b/src/platform/windows/windows_license.cpp @@ -0,0 +1,134 @@ +/** + * @file src/platform/windows/windows_license.cpp + * @brief Windows broker-backed public license API definitions. + */ + +// local includes +#include "lvh_windows_broker_config.hpp" +#include "lvh_windows_broker_protocol.h" +#include "platform/windows/windows_broker_client.hpp" + +#include + +// standard includes +#include +#include +#include +#include +#include + +namespace lvh { + namespace { + + LicenseState license_state_from(std::uint32_t state) { + switch (static_cast(state)) { + case LvhWindowsBrokerLicenseState::free: + return LicenseState::unlicensed; + case LvhWindowsBrokerLicenseState::licensed: + return LicenseState::licensed; + case LvhWindowsBrokerLicenseState::expired: + return LicenseState::expired; + case LvhWindowsBrokerLicenseState::disabled: + return LicenseState::disabled; + case LvhWindowsBrokerLicenseState::invalid: + default: + return LicenseState::invalid; + } + } + + LicenseStatus license_status_from(const LvhWindowsBrokerLicenseStatus &status) { + return { + .service_available = true, + .state = license_state_from(status.state), + .active_devices = status.active_devices, + .activation_limit = status.activation_limit, + .activation_usage = status.activation_usage, + .plan_name = status.plan_name, + .customer_email = status.customer_email, + .expires_at = status.expires_at, + .message = status.message, + .purchase_url = std::string {windows::broker_config::buy_url}, + .manage_account_url = std::string {windows::broker_config::manage_account_url}, + }; + } + + LicenseStatus unavailable_license_status(std::string_view message, bool service_available = false) { + LicenseStatus status; + status.service_available = service_available; + status.message = message; + status.purchase_url = windows::broker_config::buy_url; + status.manage_account_url = windows::broker_config::manage_account_url; + return status; + } + + template + void copy_c_string(char (&target)[Size], std::string_view value) { + std::ranges::fill(target, '\0'); + std::memcpy(target, value.data(), value.size()); + } + + template + LicenseResult submit_license_request(std::string_view license_key, std::string_view instance_name) { + if (license_key.size() >= LVH_WINDOWS_BROKER_MAX_LICENSE_KEY_SIZE) { + auto status = OperationStatus::failure(ErrorCode::invalid_argument, "License key exceeds the platform service limit"); + return {status, unavailable_license_status(status.message(), true)}; + } + if (instance_name.size() >= LVH_WINDOWS_BROKER_MAX_INSTANCE_NAME_SIZE) { + auto status = OperationStatus::failure(ErrorCode::invalid_argument, "License instance name exceeds the platform service limit"); + return {status, unavailable_license_status(status.message(), true)}; + } + + LvhWindowsBrokerLicenseRequest request {}; + request.header = detail::windows_broker::make_request_header(RequestType, sizeof(request)); + copy_c_string(request.license_key, license_key); + copy_c_string(request.instance_name, instance_name); + + LvhWindowsBrokerLicenseResponse response {}; + const auto status = detail::windows_broker::call(request, response, "Call the Windows license service"); + if (response.version != LVH_WINDOWS_BROKER_PROTOCOL_VERSION || response.size != sizeof(response)) { + return {status, unavailable_license_status(status.message())}; + } + + auto license = license_status_from(response.license); + if (!std::string_view {response.message}.empty()) { + license.message = response.message; + } + return {status, std::move(license)}; + } + + } // namespace + + LicenseResult get_license_status() { + LvhWindowsBrokerStatusRequest request {}; + request.header = detail::windows_broker::make_request_header(LvhWindowsBrokerRequestType::status, sizeof(request)); + + LvhWindowsBrokerStatusResponse response {}; + const auto status = detail::windows_broker::call(request, response, "Query the Windows license service"); + if (response.version != LVH_WINDOWS_BROKER_PROTOCOL_VERSION || response.size != sizeof(response)) { + return {status, unavailable_license_status(status.message())}; + } + + auto license = license_status_from(response.license); + if (!std::string_view {response.message}.empty()) { + license.message = response.message; + } + return {status, std::move(license)}; + } + + LicenseResult activate_license(std::string_view license_key, std::string_view instance_name) { + if (license_key.empty()) { + auto status = OperationStatus::failure(ErrorCode::invalid_argument, "License key is required"); + return {status, unavailable_license_status(status.message(), true)}; + } + return submit_license_request(license_key, instance_name); + } + + LicenseResult validate_license() { + return submit_license_request({}, {}); + } + + LicenseResult deactivate_license() { + return submit_license_request({}, {}); + } + +} // namespace lvh diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b9d31ea..5abeb16 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -9,8 +9,10 @@ if(WIN32) endif() include(GoogleTest) -add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/third-party/googletest" - "third-party/googletest") +if(NOT TARGET gtest) + add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/lizardbyte-common/third-party/googletest" + "third-party/googletest") +endif() set(LIZARDBYTE_COMMON_BUILD_TEST_SUPPORT ON CACHE BOOL "Build lizardbyte-common GoogleTest support helpers" FORCE) if(NOT TARGET lizardbyte::common) @@ -24,6 +26,7 @@ set(TEST_BINARY test_libvirtualhid) set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_adapter.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_license.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" diff --git a/tests/fixtures/windows_backend_test_hooks.cpp b/tests/fixtures/windows_backend_test_hooks.cpp index 8f7bb42..2e6f95a 100644 --- a/tests/fixtures/windows_backend_test_hooks.cpp +++ b/tests/fixtures/windows_backend_test_hooks.cpp @@ -52,17 +52,27 @@ namespace lvh::detail { response.size = sizeof(response); response.status = create_protocol_status_; response.driver_device_id = next_driver_id_++; + response.session_token = session_token_; windows::copy_string(response.device_path, response_device_path_); return protocol_status(response.status, "Windows driver rejected gamepad creation"); } - OperationStatus destroy_device(std::uint64_t driver_device_id) { + OperationStatus destroy_device( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) { std::lock_guard lock {mutex_}; + if (!session_token_matches(session_token)) { + return OperationStatus::failure(ErrorCode::backend_failure, "unexpected destroy session token"); + } destroyed_ids_.push_back(driver_device_id); return destroy_status_; } - OperationStatus submit_input_report(const std::vector &report) { + OperationStatus submit_input_report( + const LvhWindowsSessionToken &session_token, + const std::vector &report + ) { using enum ErrorCode; if (report.size() > LVH_WINDOWS_MAX_INPUT_REPORT_SIZE) { @@ -70,6 +80,9 @@ namespace lvh::detail { } std::lock_guard lock {mutex_}; + if (!session_token_matches(session_token)) { + return OperationStatus::failure(backend_failure, "unexpected submit session token"); + } submit_reports_.push_back(report); return submit_status_; } @@ -116,10 +129,24 @@ namespace lvh::detail { } private: + bool session_token_matches(const LvhWindowsSessionToken &session_token) const { + return std::ranges::equal(session_token.bytes, session_token_.bytes); + } + + static LvhWindowsSessionToken make_session_token() { + LvhWindowsSessionToken session_token {}; + for (std::uint8_t index = 0; index < LVH_WINDOWS_SESSION_TOKEN_SIZE; ++index) { + session_token.bytes[index] = static_cast(0x10U + index); + } + + return session_token; + } + mutable std::mutex mutex_; std::string path_ = R"(\\.\LibVirtualHid)"; std::string response_device_path_ = R"(\\.\LibVirtualHid#100)"; std::uint64_t next_driver_id_ = 100; + LvhWindowsSessionToken session_token_ = make_session_token(); OperationStatus create_transport_status_ = OperationStatus::success(); std::uint32_t create_protocol_status_ = LVH_WINDOWS_STATUS_SUCCESS; OperationStatus submit_status_ = OperationStatus::success(); @@ -146,15 +173,19 @@ namespace lvh::detail { return state_->create_gamepad(request, response); } - OperationStatus destroy_device(std::uint64_t driver_device_id) const override { - return state_->destroy_device(driver_device_id); + OperationStatus destroy_device( + std::uint64_t driver_device_id, + const LvhWindowsSessionToken &session_token + ) const override { + return state_->destroy_device(driver_device_id, session_token); } OperationStatus submit_input_report( std::uint64_t /*driver_device_id*/, + const LvhWindowsSessionToken &session_token, const std::vector &report ) const override { - return state_->submit_input_report(report); + return state_->submit_input_report(session_token, report); } std::optional read_output_report(HANDLE stop_event) const override { @@ -636,18 +667,15 @@ namespace lvh::detail { constexpr auto environment_name = "LIBVIRTUALHID_WINDOWS_CONTROL_DEVICE"; constexpr auto custom_path = R"(\\.\LibVirtualHid-Test)"; - std::array original_value {}; - const auto original_size = ::GetEnvironmentVariableA( - environment_name, - original_value.data(), - static_cast(original_value.size()) - ); - static_cast(::SetEnvironmentVariableA(environment_name, custom_path)); + std::string original_value; + const auto had_original_value = lizardbyte::common::get_env(environment_name, original_value); + static_cast(lizardbyte::common::set_env(environment_name, custom_path)); result.custom_device_paths = resolve_control_device_paths(); - static_cast(::SetEnvironmentVariableA( - environment_name, - original_size > 0U && original_size < original_value.size() ? original_value.data() : nullptr - )); + if (had_original_value) { + static_cast(lizardbyte::common::set_env(environment_name, original_value)); + } else { + static_cast(lizardbyte::common::unset_env(environment_name)); + } result.formatted_error_status = windows_failure(ErrorCode::backend_failure, "format known Windows error", ERROR_FILE_NOT_FOUND); diff --git a/tests/unit/test_license.cpp b/tests/unit/test_license.cpp new file mode 100644 index 0000000..82066f4 --- /dev/null +++ b/tests/unit/test_license.cpp @@ -0,0 +1,102 @@ +/** + * @file tests/unit/test_license.cpp + * @brief Tests for the provider-neutral license API. + */ + +// test includes +#include + +// local includes +#include "lvh_windows_github_actions_evaluation.hpp" + +// lib includes +#include + +// standard includes +#include + +#if defined(_WIN32) + // local includes + #include "platform/windows/windows_broker_client.hpp" +#endif + +TEST(LicenseStatusTest, LicensedReflectsCurrentState) { + lvh::LicenseStatus status; + EXPECT_FALSE(status.licensed()); + + status.state = lvh::LicenseState::licensed; + EXPECT_TRUE(status.licensed()); + + status.state = lvh::LicenseState::expired; + EXPECT_FALSE(status.licensed()); +} + +TEST(GitHubActionsEvaluationTest, IsActiveOnlyInsideFiveMinuteWindow) { + using namespace std::chrono_literals; + using lvh::windows::github_actions_evaluation::active; + + const auto started_at = lvh::windows::github_actions_evaluation::Clock::time_point {1000s}; + EXPECT_TRUE(active(started_at, started_at)); + EXPECT_TRUE(active(started_at, started_at + 5min - 1s)); + EXPECT_FALSE(active(started_at, started_at + 5min)); + EXPECT_FALSE(active(started_at, started_at - 1s)); +} + +TEST(GitHubActionsEvaluationTest, RemainingTimeClampsAtWindowBoundaries) { + using namespace std::chrono_literals; + using lvh::windows::github_actions_evaluation::remaining; + + const auto started_at = lvh::windows::github_actions_evaluation::Clock::time_point {1000s}; + EXPECT_EQ(remaining(started_at, started_at), 5min); + EXPECT_EQ(remaining(started_at, started_at + 4min), 1min); + EXPECT_EQ(remaining(started_at, started_at + 5min - 500ms), 1s); + EXPECT_EQ(remaining(started_at, started_at + 5min), 0s); + EXPECT_EQ(remaining(started_at, started_at - 1s), 0s); +} + +#if !defined(_WIN32) +TEST(LicenseApiTest, UnsupportedPlatformReturnsExplicitFailure) { + const auto queried = lvh::get_license_status(); + EXPECT_FALSE(queried); + EXPECT_EQ(queried.status.code(), lvh::ErrorCode::backend_unavailable); + EXPECT_EQ(queried.license.state, lvh::LicenseState::unavailable); + EXPECT_FALSE(queried.license.service_available); + + EXPECT_FALSE(lvh::activate_license("test-key")); + EXPECT_FALSE(lvh::validate_license()); + EXPECT_FALSE(lvh::deactivate_license()); +} +#endif + +#if defined(_WIN32) +TEST(WindowsBrokerClientTest, BuildsVersionedRequestHeader) { + const auto header = lvh::detail::windows_broker::make_request_header( + LvhWindowsBrokerRequestType::validate_license, + sizeof(LvhWindowsBrokerLicenseRequest) + ); + + EXPECT_EQ(header.version, LVH_WINDOWS_BROKER_PROTOCOL_VERSION); + EXPECT_EQ(header.size, sizeof(LvhWindowsBrokerLicenseRequest)); + EXPECT_EQ(header.type, static_cast(LvhWindowsBrokerRequestType::validate_license)); + EXPECT_EQ(header.reserved0, 0U); + EXPECT_EQ(LVH_WINDOWS_BROKER_PROTOCOL_VERSION, 2U); + EXPECT_EQ(sizeof(LvhWindowsBrokerCreateGamepadRequest), 2528U); +} + +TEST(WindowsBrokerClientTest, MapsLicenseAndTransportStatuses) { + using lvh::detail::windows_broker::response_status; + + EXPECT_TRUE(response_status(static_cast(LvhWindowsBrokerStatusCode::success), "").ok()); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::invalid_argument), "bad").code(), lvh::ErrorCode::invalid_argument); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::unsupported_profile), "bad").code(), lvh::ErrorCode::unsupported_profile); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::device_not_found), "bad").code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::backend_unavailable), "bad").code(), lvh::ErrorCode::backend_unavailable); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::license_required), "bad").code(), lvh::ErrorCode::license_required); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::license_invalid), "bad").code(), lvh::ErrorCode::license_invalid); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::activation_limit_reached), "bad").code(), lvh::ErrorCode::activation_limit_reached); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::network_unavailable), "bad").code(), lvh::ErrorCode::network_unavailable); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::backend_failure), "bad").code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(response_status(999U, "bad").code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(response_status(static_cast(LvhWindowsBrokerStatusCode::backend_failure), "").message(), "Windows broker request failed"); +} +#endif diff --git a/tests/unit/test_windows_protocol.cpp b/tests/unit/test_windows_protocol.cpp index af678f9..b1a950b 100644 --- a/tests/unit/test_windows_protocol.cpp +++ b/tests/unit/test_windows_protocol.cpp @@ -46,12 +46,21 @@ namespace { return values; } + LvhWindowsSessionToken test_session_token() { + LvhWindowsSessionToken token {}; + for (std::uint8_t index = 0; index < LVH_WINDOWS_SESSION_TOKEN_SIZE; ++index) { + token.bytes[index] = static_cast(0xA0U + index); + } + return token; + } + } // namespace TEST(WindowsProtocolTest, ExposesStableProtocolConstants) { EXPECT_STREQ(lvh::detail::windows::default_control_device_path.data(), R"(\\.\LibVirtualHid)"); EXPECT_STREQ(lvh::detail::windows::global_control_device_path.data(), R"(\\.\Global\LibVirtualHid)"); + EXPECT_EQ(LVH_WINDOWS_CONTROL_PROTOCOL_VERSION, 2U); EXPECT_EQ(LVH_WINDOWS_IOCTL_CREATE_GAMEPAD, 0x8000E000U); EXPECT_EQ(LVH_WINDOWS_IOCTL_DESTROY_DEVICE, 0x8000E004U); EXPECT_EQ(LVH_WINDOWS_IOCTL_SUBMIT_INPUT_REPORT, 0x8000E008U); @@ -60,9 +69,10 @@ TEST(WindowsProtocolTest, ExposesStableProtocolConstants) { EXPECT_EQ(sizeof(LvhWindowsGamepadHardwareIds), 14U); EXPECT_EQ(sizeof(LvhWindowsGamepadReportSizes), 24U); EXPECT_EQ(sizeof(LvhWindowsCreateGamepadRequest), 2498U); - EXPECT_EQ(sizeof(LvhWindowsCreateGamepadResponse), 284U); - EXPECT_EQ(sizeof(LvhWindowsDestroyDeviceRequest), 16U); - EXPECT_EQ(sizeof(LvhWindowsSubmitInputReportRequest), 280U); + EXPECT_EQ(sizeof(LvhWindowsSessionToken), 32U); + EXPECT_EQ(sizeof(LvhWindowsCreateGamepadResponse), 316U); + EXPECT_EQ(sizeof(LvhWindowsDestroyDeviceRequest), 48U); + EXPECT_EQ(sizeof(LvhWindowsSubmitInputReportRequest), 312U); EXPECT_EQ(sizeof(LvhWindowsOutputReportEvent), 280U); } @@ -546,19 +556,34 @@ TEST(WindowsProtocolTest, TruncatesOversizedGamepadCreateRequestFields) { TEST(WindowsProtocolTest, PacksSubmitAndDestroyRequests) { const std::vector report {1, 2, 3, 4, 5}; + const auto session_token = test_session_token(); - const auto submit = lvh::detail::windows::make_submit_input_report_request(17, report); + const auto submit = lvh::detail::windows::make_submit_input_report_request(17, session_token, report); EXPECT_EQ(submit.version, LVH_WINDOWS_CONTROL_PROTOCOL_VERSION); EXPECT_EQ(submit.size, sizeof(submit)); EXPECT_EQ(submit.driver_device_id, 17U); + EXPECT_EQ(submit.session_token.bytes[0], session_token.bytes[0]); + EXPECT_EQ(submit.session_token.bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE - 1U], session_token.bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE - 1U]); EXPECT_EQ(submit.report_size, report.size()); EXPECT_EQ(submit.report[0], report[0]); EXPECT_EQ(submit.report[4], report[4]); - const auto destroy = lvh::detail::windows::make_destroy_device_request(17); + const auto destroy = lvh::detail::windows::make_destroy_device_request(17, session_token); EXPECT_EQ(destroy.version, LVH_WINDOWS_CONTROL_PROTOCOL_VERSION); EXPECT_EQ(destroy.size, sizeof(destroy)); 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]); +} + +TEST(WindowsProtocolTest, CompatibilityRequestHelpersUseEmptySessionToken) { + const auto submit = lvh::detail::windows::make_submit_input_report_request(17, std::vector {1}); + const auto destroy = lvh::detail::windows::make_destroy_device_request(17); + + EXPECT_EQ(submit.session_token.bytes[0], 0U); + EXPECT_EQ(submit.session_token.bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE - 1U], 0U); + EXPECT_EQ(destroy.session_token.bytes[0], 0U); + EXPECT_EQ(destroy.session_token.bytes[LVH_WINDOWS_SESSION_TOKEN_SIZE - 1U], 0U); } TEST(WindowsProtocolTest, SubmitInputReportTruncatesAndZeroFills) { diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 7ea59f0..4ffdff2 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -64,6 +64,7 @@ if(WIN32) target_include_directories(virtualhid_control PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/windows" + "${PROJECT_SOURCE_DIR}/src/platform/windows/shared" "${PROJECT_SOURCE_DIR}") target_compile_definitions(virtualhid_control PRIVATE diff --git a/tools/virtualhid_control.cpp b/tools/virtualhid_control.cpp index 41f50ee..830c54b 100644 --- a/tools/virtualhid_control.cpp +++ b/tools/virtualhid_control.cpp @@ -214,6 +214,233 @@ namespace { return text; } + struct LicenseSnapshot { + bool broker_supported = false; + bool broker_available = false; + bool licensed = false; + std::string plan_name = "Unavailable"; + std::string customer_email; + std::string expires_at; + std::string state_text = "License broker unavailable."; + std::string message; + std::string purchase_url; + std::string manage_account_url; + std::uint32_t active_devices = 0; + std::uint32_t free_limit = 0; + std::uint32_t activation_limit = 0; + std::uint32_t activation_usage = 0; + }; + + bool open_url(std::string_view url, std::string &error) { + if (url.empty()) { + error = "URL is not configured."; + return false; + } + if (SDL_OpenURL(std::string {url}.c_str())) { + return true; + } + + error = SDL_GetError(); + if (error.empty()) { + error = "Unable to open URL."; + } + return false; + } + + std::string license_state_name(lvh::LicenseState state) { + using enum lvh::LicenseState; + + switch (state) { + case unlicensed: + return "Unlicensed"; + case licensed: + return "Licensed"; + case expired: + return "Expired"; + case disabled: + return "Disabled"; + case invalid: + return "Invalid"; + case unavailable: + default: + return "Unavailable"; + } + } + + LicenseSnapshot license_snapshot_from(const lvh::LicenseResult &result) { + const auto &status = result.license; + LicenseSnapshot snapshot; + snapshot.broker_supported = status.state != lvh::LicenseState::unavailable; + snapshot.broker_available = status.service_available; + snapshot.licensed = status.licensed(); + snapshot.plan_name = status.plan_name; + snapshot.customer_email = status.customer_email; + snapshot.expires_at = status.expires_at; + snapshot.message = status.message.empty() ? result.status.message() : status.message; + snapshot.purchase_url = status.purchase_url; + snapshot.manage_account_url = status.manage_account_url; + snapshot.active_devices = status.active_devices; + snapshot.activation_limit = status.activation_limit; + snapshot.activation_usage = status.activation_usage; + + const auto state = license_state_name(status.state); + snapshot.state_text = state; + if (!snapshot.plan_name.empty() && snapshot.plan_name != state) { + snapshot.state_text += " | " + snapshot.plan_name; + } + snapshot.state_text += std::format(" | active devices {}", status.active_devices); + if (status.licensed()) { + snapshot.state_text += " / unlimited"; + } else { + snapshot.state_text += " | license required"; + } + if (status.activation_limit > 0U) { + snapshot.state_text += std::format(" | machine limit {}", status.activation_limit); + } + if (!snapshot.expires_at.empty()) { + snapshot.state_text += " | expires " + snapshot.expires_at; + } + return snapshot; + } + + LicenseSnapshot license_snapshot_from(const lvh::LicenseResult &result, std::string &error) { + error = result.status.ok() ? std::string {} : result.status.message(); + return license_snapshot_from(result); + } + + class LicensePanel { + public: + LicensePanel() { + refresh(); + } + + void refresh() { + std::string ignored; + apply_result(lvh::get_license_status(), ignored); + } + + template + void render(ErrorHandler show_error) { + ImGui::TextUnformatted("License"); + ImGui::TextWrapped("%s", snapshot_.state_text.c_str()); + if (!snapshot_.customer_email.empty()) { + ImGui::TextWrapped("%s", snapshot_.customer_email.c_str()); + } + if (!snapshot_.message.empty()) { + ImGui::TextWrapped("%s", snapshot_.message.c_str()); + } + +#if defined(_WIN32) + ImGui::TextUnformatted("License key"); + ImGui::InputText("##license-key", license_key_input_.data(), license_key_input_.size()); + { + ScopedDisabled disabled {!snapshot_.broker_available || license_key_input_[0] == '\0'}; + if (ImGui::Button("Activate license", {-FLT_MIN, 0.0F})) { + activate(show_error); + } + } + { + ScopedDisabled disabled {!snapshot_.broker_available}; + if (ImGui::Button("Refresh", {-FLT_MIN, 0.0F})) { + validate(show_error); + } + if (ImGui::Button("Deactivate this machine", {-FLT_MIN, 0.0F})) { + deactivate(show_error); + } + } +#else + if (ImGui::Button("Refresh", {-FLT_MIN, 0.0F})) { + refresh_and_report(show_error); + } +#endif + + { + ScopedDisabled disabled {buy_url_.empty()}; + if (ImGui::Button("Buy license", {-FLT_MIN, 0.0F})) { + open_configured_url(buy_url_, show_error); + } + } + { + ScopedDisabled disabled {manage_account_url_.empty()}; + if (ImGui::Button("Manage account", {-FLT_MIN, 0.0F})) { + open_configured_url(manage_account_url_, show_error); + } + } + } + + template + void render_create_button(CreateHandler create_gamepad) const { +#if defined(_WIN32) + ScopedDisabled disabled {!snapshot_.broker_available || !snapshot_.licensed}; +#endif + if (ImGui::Button("Create", {-FLT_MIN, 0.0F})) { + create_gamepad(); + } + } + + private: + void apply_result(const lvh::LicenseResult &result, std::string &error) { + snapshot_ = license_snapshot_from(result, error); + buy_url_ = snapshot_.purchase_url; + manage_account_url_ = snapshot_.manage_account_url; + } + + template + void refresh_and_report(ErrorHandler &show_error) { + std::string error; + apply_result(lvh::get_license_status(), error); + if (!error.empty()) { + show_error(error); + } + } + + template + void open_configured_url(const std::string &url, ErrorHandler &show_error) const { + std::string error; + if (!open_url(url, error)) { + show_error(error); + } + } + +#if defined(_WIN32) + template + void activate(ErrorHandler &show_error) { + std::string error; + apply_result(lvh::activate_license(license_key_input_.data()), error); + if (!error.empty()) { + show_error(error); + } else { + license_key_input_.fill('\0'); + } + } + + template + void validate(ErrorHandler &show_error) { + std::string error; + apply_result(lvh::validate_license(), error); + if (!error.empty()) { + show_error(error); + } + } + + template + void deactivate(ErrorHandler &show_error) { + std::string error; + apply_result(lvh::deactivate_license(), error); + if (!error.empty()) { + show_error(error); + } + } +#endif + + LicenseSnapshot snapshot_; +#if defined(_WIN32) + std::array license_key_input_ {}; +#endif + std::string buy_url_; + std::string manage_account_url_; + }; + int axis_position(const SelectedSnapshot &selected, std::size_t index) { if (!selected.has_device) { return 0; @@ -367,6 +594,11 @@ namespace { } void render_device_panel(const std::vector &devices) { + license_panel_.render([this](std::string_view message) { + show_error(message); + }); + ImGui::Separator(); + ImGui::TextUnformatted("Profile"); const auto *choice = current_profile_choice(); if (const auto preview = choice == nullptr ? std::string {"Select profile"} : to_utf8(choice->label); ImGui::BeginCombo("##profile", preview.c_str())) { @@ -383,9 +615,9 @@ namespace { ImGui::EndCombo(); } - if (ImGui::Button("Create", {-FLT_MIN, 0.0F})) { + license_panel_.render_create_button([this] { create_gamepad(); - } + }); ImGui::Spacing(); ImGui::TextUnformatted("Devices"); @@ -675,6 +907,7 @@ namespace { devices_[id] = std::move(device); } selected_id_ = id; + license_panel_.refresh(); } void reset_selected_device() { @@ -714,12 +947,14 @@ namespace { show_error(status.message()); } } + license_panel_.refresh(); } void remove_all_devices() { const auto adapters = take_all_adapters(); button_active_.fill(false); close_adapters(adapters, true); + license_panel_.refresh(); } void toggle_selected_button(std::size_t index) { @@ -946,6 +1181,7 @@ namespace { bool open_error_popup_ = false; std::uint64_t next_metadata_index_ = 0; std::uint64_t next_output_sequence_ = 1; + LicensePanel license_panel_; static constexpr std::size_t max_output_events_ = 50; };