From f39ef901ff26a2d611b7d29f2a7ce70e67f66dc6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:50:57 -0400 Subject: [PATCH 01/28] Add MIT license and project README Replace the placeholder LICENSE with the MIT license (copyright 2026 LizardByte) and replace the template README with a full project README for libvirtualhid. The new README outlines goals, non-goals, reference projects, platform strategy (Windows, Linux, macOS), proposed public API, repository layout, phased implementation and testing plans, and notes that the project is MIT-licensed. --- LICENSE | 22 +++- README.md | 292 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 311 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index 644d7cc..584afb6 100644 --- a/LICENSE +++ b/LICENSE @@ -1 +1,21 @@ -No license, replace this file after using the template. +MIT License + +Copyright (c) 2026 LizardByte + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fd3e658..e4c7e8e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,290 @@ -# template-base -Base repository template for LizardByte. +# libvirtualhid + +`libvirtualhid` is a planned cross-platform C++ library for creating virtual HID +input devices for remote streaming hosts and similar low-latency input +applications. + +The primary target is gamepad input. Keyboard and mouse support are secondary +goals once the gamepad model, descriptor handling, and output report plumbing +are stable. + +## Goals + +- Provide the same public C++ API on Windows, Linux, and eventually macOS. +- Hide platform-specific virtual HID details behind backend implementations. +- Prefer user-mode platform facilities and avoid custom kernel-mode drivers. +- Build with CMake and support direct consumption through `add_subdirectory`, + `FetchContent`, installed CMake packages, or vendored source. +- Keep Sunshine as the first target consumer and validate the library against + Sunshine's current input lifecycle, controller profiles, and packaging needs. +- Keep network transport out of scope. Consumers such as streaming hosts own + network input collection and feed local reports into this library. +- Use the MIT license. + +## Non-goals + +- No anti-cheat bypass or stealth device hiding. +- No replication of controller authentication chips or private vendor secrets. +- No Windows kernel-mode driver. +- No built-in network protocol. + +## Reference Projects + +The initial design is informed by these projects: + +- [cgutman/WinUHid](https://github.com/cgutman/WinUHid): Windows virtual HID + device emulation with a UMDF-oriented driver/package shape. +- [hifihedgehog/HIDMaestro](https://github.com/hifihedgehog/HIDMaestro): + Windows user-mode UMDF2 game controller emulation, profile-driven controller + identity, output callbacks, hot-plug behavior, and no custom kernel driver. +- [games-on-whales/inputtino](https://github.com/games-on-whales/inputtino): + Linux C++ virtual input library built around `uinput`, `evdev`, and `uhid`, + including gamepad, keyboard, mouse, and output-event handling. +- LizardByte C++ project structure references: + [tray](https://github.com/LizardByte/tray) and + [libdisplaydevice](https://github.com/LizardByte/libdisplaydevice), especially + their CMake option shape, top-level-only test/doc setup, `third-party` + submodule layout, and GoogleTest wiring. + +## Platform Strategy + +### Windows + +Windows should use a UMDF2 HID minidriver and a C++ client library/backend. The +driver remains user-mode, but it is still a Windows driver package and must be +installed and trusted on the host machine. + +That means a consuming application can compile the C++ library as part of its +own build, but compiling the library alone is not enough to create virtual HID +devices on Windows. The project should provide: + +- A CMake-built C++ client library for consumers. +- A Windows driver package containing the INF, signed catalog, UMDF driver DLL, + and any helper/control component needed by the backend. +- Install/uninstall helpers suitable for developer machines and application + installers. +- A path for projects to either build the driver package themselves with the + Windows SDK/WDK or redistribute an official prebuilt, signed package. + +The public API should not expose these details. Consumers should create a +runtime, create devices, submit input state, and receive output reports the same +way they do on Linux. + +The Windows C++ client library should support both MSVC and MinGW/UCRT64 where +the code only depends on normal Win32 or C++ APIs. MinGW support matters for +consumers that already build their application with that toolchain. The UMDF2 +driver package is different: it should be treated as a Windows SDK/WDK build +artifact and built with the Microsoft driver toolchain, such as Visual Studio, +MSBuild, or EWDK. The boundary between the library and driver should therefore +be compiler-neutral: prefer a stable C ABI, named pipe, device interface IOCTL, +or similar control channel over passing C++ STL types across that boundary. + +### Linux + +Linux should compile directly into the consuming project and use standard kernel +user-space interfaces: + +- `uhid` for descriptor-driven HID gamepads where the raw HID identity and + output reports matter. +- `uinput` for keyboard, mouse, and simpler evdev-style devices. +- `evdev` or `libevdev` where it meaningfully reduces direct ioctl handling. +- X11/XTest as a last-resort keyboard and mouse fallback when `uinput` cannot + be opened and an X11 session is available. + +Linux deployment should be documentation and permissions focused: users need +access to `/dev/uinput` and/or `/dev/uhid`, usually through udev rules or group +membership. No out-of-tree kernel module should be required. + +The XTest fallback should not be treated as a gamepad backend. It can cover +keyboard and mouse injection on X11, but it does not create virtual HID devices, +does not help on Wayland, and should not replace `uhid`/`uinput` for gamepads. +Sunshine's removed legacy implementation is the first reference for this path: +commit `8227e8f8` added the XTest input fallback, and commit `f57aee90` removed +`src/platform/linux/input/legacy_input.cpp` when Sunshine moved fully to +inputtino. + +### macOS + +macOS is a later target. The first planning milestone is to validate whether the +backend should use `IOHIDUserDevice`, DriverKit/HIDDriverKit, or a combination +of both, then document the entitlement, signing, and distribution requirements. +The public API should already be shaped so the macOS backend can plug in without +breaking Windows or Linux consumers. + +## Proposed Public API Shape + +The exact names may change during implementation, but the API should center on +portable concepts instead of platform concepts: + +```cpp +#include + +auto runtime = lvh::Runtime::create(); + +auto gamepad = runtime->create_gamepad(lvh::profiles::xbox_360()); + +gamepad->set_output_callback([](const lvh::GamepadOutput& output) { + // Route rumble, LED, or trigger feedback back to the physical controller. +}); + +lvh::GamepadState state; +state.buttons.set(lvh::GamepadButton::A, true); +state.left_stick = {0.25f, -0.5f}; +state.right_trigger = 1.0f; + +gamepad->submit(state); +``` + +Expected core types: + +- `Runtime`: owns platform backend discovery, initialization, and shutdown. +- `VirtualDevice`: common lifecycle for create, destroy, and hot-plug. +- `Gamepad`: gamepad-specific state submission and output callbacks. +- `Keyboard` and `Mouse`: later secondary device types. +- `DeviceProfile`: VID/PID, product strings, bus type, HID descriptor, report + layout, and platform capability metadata. +- `GamepadState`: normalized buttons, axes, triggers, hats, motion sensors, and + optional touchpad data. +- `GamepadOutput`: normalized rumble, haptics, LEDs, adaptive triggers, and raw + output reports when a profile needs them. +- `BackendCapabilities`: runtime capability query for platform/backend limits, + such as `supports_virtual_hid`, `supports_output_reports`, + `supports_keyboard`, `supports_mouse`, `supports_xtest_fallback`, and + `requires_installed_driver`. + +## Sunshine Integration Requirements + +Sunshine is the first consumer to design against. The initial implementation +should cover Sunshine's active input behavior before optimizing for unrelated +consumers: + +- CMake consumption must work as a vendored dependency under Sunshine's + `third-party` tree. +- The API must support multiple client-relative and global gamepad indexes so + Sunshine can preserve stable controller lifecycles across arrival, update, + feedback, and removal events. +- Built-in profiles should cover Sunshine's current gamepad choices: automatic + selection, Xbox One-style, DualSense-style, and Switch Pro-style devices. Xbox + 360 can remain useful as a compatibility profile and test target. +- Controller metadata must be rich enough for Sunshine's selection rules: + client controller type, motion sensor capability, touchpad capability, RGB LED + support, battery state, and per-controller identity data. +- Output callbacks must carry rumble first, then RGB LED, adaptive trigger, + motion activation, and raw output report data where the selected profile + supports it. +- Keyboard and mouse APIs should map cleanly to Sunshine's current relative + mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, + and Unicode paths. +- Linux fallback behavior should match Sunshine's operational expectation: + prefer real virtual devices through `uhid`/`uinput`; only use XTest for + keyboard/mouse when virtual device creation fails and X11 is available. +- The library must not own Sunshine's network protocol, Moonlight packet + parsing, configuration system, or feedback queue. It should expose the device + primitives Sunshine needs to keep that ownership in Sunshine. + +## Tooling and Dependency Plan + +- Use CMake as the only build system for the core library. +- Follow the LizardByte `tray` and `libdisplaydevice` pattern: top-level-only + `BUILD_TESTS` and `BUILD_DOCS` options, reusable library targets, and tests + that do not force themselves on parent projects. +- Put all submodules under `third-party`. +- Add GoogleTest as a submodule at `third-party/googletest`; do not download it + during configure. +- Expose `libvirtualhid::libvirtualhid` as the main CMake target. +- Keep the public headers under `include/libvirtualhid` and the implementation + split into shared core code plus platform-specific backends. +- Add Windows CI coverage for the client library with MSVC and MinGW/UCRT64. + Add separate WDK/MSVC validation for the driver package once driver sources + exist. +- Add Linux CI coverage for GCC and Clang, with integration tests gated behind + explicit availability of `/dev/uinput`, `/dev/uhid`, or X11/XTest. + +## Repository Plan + +The intended project layout is: + +```text +include/libvirtualhid/ Public C++ headers +src/core/ Shared profile, descriptor, and report logic +src/platform/windows/ Windows client backend and UMDF control channel +src/platform/linux/ Linux uhid/uinput backend +src/platform/macos/ Future macOS backend +drivers/windows/ UMDF2 driver package sources +profiles/ Built-in gamepad profiles +examples/ Minimal consumers and platform smoke tests +tests/ Unit and integration tests +cmake/ Package config and helper modules +third-party/googletest/ GoogleTest submodule +``` + +## Implementation Plan + +### Phase 1: Project Foundation + +- Add CMake project scaffolding and exported target + `libvirtualhid::libvirtualhid`. +- Define the public C++ API, error model, device lifecycle, and ownership rules. +- Add a fake in-memory backend so API tests can run on every platform. +- Add GoogleTest as a submodule under `third-party/googletest` and wire tests + using the same top-level-only pattern as `tray` and `libdisplaydevice`. +- Add descriptor/profile models for at least Xbox 360, Xbox Series, DualSense, + and a generic HID gamepad. +- Add unit tests for state normalization and HID report packing. +- Add a Sunshine-oriented example or adapter test that exercises controller + arrival, state updates, output feedback, and removal without depending on + Sunshine internals. + +### Phase 2: Linux MVP + +- Implement gamepad creation over `uhid` for descriptor-driven controllers. +- Add `uinput` support for keyboard and mouse once the gamepad path is stable. +- Support output report callbacks for rumble and profile-specific feedback. +- Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's + historical legacy input implementation as the reference point. +- Add examples and integration tests that validate SDL/HIDAPI discovery where + available. +- Document required Linux permissions and sample udev rules. + +### Phase 3: Windows MVP + +- Build a UMDF2 HID minidriver package with CMake/WDK integration. +- Implement the Windows backend and control channel between the C++ library and + the UMDF driver. +- Keep the client library buildable with MSVC and MinGW/UCRT64. Keep the driver + package on the Microsoft WDK toolchain. +- Add install/uninstall tooling for developer workflows. +- Support hot-plug, multi-controller instances, and output report callbacks. +- Validate visibility through DirectInput, XInput where applicable, SDL/HIDAPI, + Windows.Gaming.Input/GameInput, and browser Gamepad API. + +### Phase 4: API Parity and Packaging + +- Keep one API surface across Windows and Linux, with capability queries for + platform limitations instead of platform-specific methods. +- Add installed CMake package support and `FetchContent` documentation. +- Add CI for formatting, static analysis, CMake configure/build, unit tests, and + platform smoke tests. +- Decide whether official Windows releases should ship signed driver packages + in addition to source. + +### Phase 5: macOS Research and Backend + +- Prototype macOS virtual HID creation and report submission. +- Document signing, entitlement, and installer constraints. +- Add macOS backend behind the existing public API. +- Add macOS discovery and smoke-test coverage. + +## Testing Plan + +- Unit test descriptor generation, report packing, axis scaling, button mapping, + and output report parsing. +- Run lifecycle tests for create, submit, output callback, destroy, repeated + hot-plug, and process shutdown cleanup. +- Validate multi-controller behavior and stable ordering. +- Test against real consumers where practical: SDL, HIDAPI, browser Gamepad API, + DirectInput/XInput/GameInput on Windows, and evdev/libinput tooling on Linux. + +## License + +`libvirtualhid` is licensed under the MIT License. See [LICENSE](LICENSE). From c728c8698b9ceb4270e16a86af405bcaa9e32804 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:29:41 -0400 Subject: [PATCH 02/28] Add googletest submodule Add GoogleTest as a git submodule at third-party/googletest. Adds .gitmodules entry pointing to https://github.com/google/googletest.git and pins the submodule to commit f8d7d77c06936315286eb55f8de22cd23c188571 to include the testing framework for unit tests. --- .gitmodules | 3 +++ third-party/googletest | 1 + 2 files changed, 4 insertions(+) create mode 100644 .gitmodules create mode 160000 third-party/googletest diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..3f127b8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "third-party/googletest"] + path = third-party/googletest + url = https://github.com/google/googletest.git diff --git a/third-party/googletest b/third-party/googletest new file mode 160000 index 0000000..f8d7d77 --- /dev/null +++ b/third-party/googletest @@ -0,0 +1 @@ +Subproject commit f8d7d77c06936315286eb55f8de22cd23c188571 From f229b7f50758fafef6f98e06dcb13d26c14b467d Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:50:08 -0400 Subject: [PATCH 03/28] Add AGENTS.md with build and platform guidelines Add AGENTS.md documenting build, testing, and platform guidance: msys2/ucrt64 invocation on Windows, prefix build dirs with `cmake-build-`, and test executable `test_libvirtualhid` location. Notes gtest is vendored under `third-party/googletest`, and instructs to keep the public C++ API platform-neutral with backend-specific HID details hidden. Specifies primary focus on gamepad support (validate against Sunshine adapter/tests), Windows must remain user-mode and library buildable with MSVC and MinGW/UCRT64, and Linux should prefer uinput/evdev/uhid. Also mandates updating public docs when headers/backends/behavior change and following .clang-format for C/C++ code. --- AGENTS.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b6a4e26 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +On Windows we use msys2 and ucrt64 to compile. +You need to prefix commands with `C:\msys64\msys2_shell.cmd -defterm -here -no-start -ucrt64 -c`. + +Prefix build directories with `cmake-build-`. + +The test executable is named `test_libvirtualhid` and will be located inside the `tests` directory within +the build directory. + +The project uses gtest as a test framework. GoogleTest is vendored as a submodule under `third-party/googletest`. + +Keep the public c++ API platform-neutral. Platform-specific virtual HID details belong behind backend +implementations and should not leak into consumer code. + +Gamepad support is the primary target. Sunshine is the first target consumer, so validate API and behavior +changes against the Sunshine-oriented adapter example and tests. + +Windows support must remain user-mode. Do not add a custom kernel-mode driver. The normal c++ library should +remain buildable with both MSVC and MinGW/UCRT64; any future UMDF driver package is a separate WDK/MSVC build +artifact. + +Linux gamepad support should prefer uinput, evdev, and uhid. X11 XTest fallbacks are only for secondary +keyboard and mouse goals. + +Always update public documentation when changing headers, backends, or consumer-facing behavior. + +Always follow the style guidelines defined in .clang-format for c/c++ code when that file is present. From 74f3109bf6410f39c0e7f68140e22c57caf512fc Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:50:19 -0400 Subject: [PATCH 04/28] Add libvirtualhid core, CMake, tests, examples Initial project scaffolding for libvirtualhid: add CMake build system and packaging helpers, public headers (profiles, report, runtime, types) and core source implementations (profiles, report packing, runtime/fake backend, types). Include an example (sunshine_gamepad_adapter), GoogleTest-based unit tests covering profiles, reports and runtime, and helper CMake files for tests and examples. Update .gitignore to ignore CMake build directories. Provides a working in-memory fake backend and utilities for creating/packing gamepad reports to drive further development. --- .gitignore | 4 + CMakeLists.txt | 95 +++++++++ cmake/libvirtualhid-config.cmake.in | 5 + examples/CMakeLists.txt | 8 + examples/sunshine_gamepad_adapter.cpp | 50 +++++ include/libvirtualhid/libvirtualhid.hpp | 6 + include/libvirtualhid/profiles.hpp | 20 ++ include/libvirtualhid/report.hpp | 18 ++ include/libvirtualhid/runtime.hpp | 88 +++++++++ include/libvirtualhid/types.hpp | 189 ++++++++++++++++++ src/CMakeLists.txt | 38 ++++ src/core/profiles.cpp | 189 ++++++++++++++++++ src/core/report.cpp | 159 +++++++++++++++ src/core/runtime.cpp | 246 ++++++++++++++++++++++++ src/core/types.cpp | 64 ++++++ tests/CMakeLists.txt | 28 +++ tests/unit/test_profiles.cpp | 44 +++++ tests/unit/test_report.cpp | 58 ++++++ tests/unit/test_runtime.cpp | 77 ++++++++ tests/unit/test_sunshine_adapter.cpp | 54 ++++++ 20 files changed, 1440 insertions(+) create mode 100644 CMakeLists.txt create mode 100644 cmake/libvirtualhid-config.cmake.in create mode 100644 examples/CMakeLists.txt create mode 100644 examples/sunshine_gamepad_adapter.cpp create mode 100644 include/libvirtualhid/libvirtualhid.hpp create mode 100644 include/libvirtualhid/profiles.hpp create mode 100644 include/libvirtualhid/report.hpp create mode 100644 include/libvirtualhid/runtime.hpp create mode 100644 include/libvirtualhid/types.hpp create mode 100644 src/CMakeLists.txt create mode 100644 src/core/profiles.cpp create mode 100644 src/core/report.cpp create mode 100644 src/core/runtime.cpp create mode 100644 src/core/types.cpp create mode 100644 tests/CMakeLists.txt create mode 100644 tests/unit/test_profiles.cpp create mode 100644 tests/unit/test_report.cpp create mode 100644 tests/unit/test_runtime.cpp create mode 100644 tests/unit/test_sunshine_adapter.cpp diff --git a/.gitignore b/.gitignore index e3f4af3..eed00dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,6 @@ # JetBrains IDEs .idea/ + +# CMake +build/ +cmake-build-*/ diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..19e2ce3 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,95 @@ +# +# Project configuration +# +cmake_minimum_required(VERSION 3.24) +project(libvirtualhid VERSION 0.1.0 + DESCRIPTION "Cross-platform virtual HID device library." + HOMEPAGE_URL "https://app.lizardbyte.dev" + LANGUAGES CXX) + +set(PROJECT_LICENSE "MIT") +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +set(LIBVIRTUALHID_IS_TOP_LEVEL OFF) +if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) + set(LIBVIRTUALHID_IS_TOP_LEVEL ON) +endif() + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + message(STATUS "Setting build type to 'Release' as none was specified.") + set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE) +endif() + +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") + +# +# Project optional configuration +# +option(BUILD_DOCS "Build documentation" OFF) +option(BUILD_TESTS "Build tests" ${LIBVIRTUALHID_IS_TOP_LEVEL}) +option(BUILD_EXAMPLES "Build examples" ${LIBVIRTUALHID_IS_TOP_LEVEL}) + +set(CMAKE_COLOR_MAKEFILE ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +include(CMakePackageConfigHelpers) +include(GNUInstallDirs) + +function(libvirtualhid_copy_mingw_runtime target_name) + if(NOT WIN32 OR NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + return() + endif() + + get_filename_component(_lvh_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + foreach(_lvh_runtime_dll IN ITEMS libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll) + set(_lvh_runtime_path "${_lvh_compiler_dir}/${_lvh_runtime_dll}") + if(EXISTS "${_lvh_runtime_path}") + add_custom_command(TARGET "${target_name}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${_lvh_runtime_path}" + "$" + COMMENT "Copying ${_lvh_runtime_dll} to $") + endif() + endforeach() +endfunction() + +# +# Library +# +add_subdirectory(src) + +# +# Examples, tests, and docs are top-level only +# +if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + if(BUILD_EXAMPLES) + add_subdirectory(examples) + endif() + + if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() +endif() + +# +# Package config +# +configure_package_config_file( + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/libvirtualhid-config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid" +) + +write_basic_package_version_file( + "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config-version.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion +) + +install(FILES + "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config.cmake" + "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config-version.cmake" + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid") diff --git a/cmake/libvirtualhid-config.cmake.in b/cmake/libvirtualhid-config.cmake.in new file mode 100644 index 0000000..6964c39 --- /dev/null +++ b/cmake/libvirtualhid-config.cmake.in @@ -0,0 +1,5 @@ +@PACKAGE_INIT@ + +include("${CMAKE_CURRENT_LIST_DIR}/libvirtualhid-targets.cmake") + +check_required_components(libvirtualhid) diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..a132894 --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,8 @@ +add_executable(sunshine_gamepad_adapter + "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_gamepad_adapter.cpp") + +target_link_libraries(sunshine_gamepad_adapter + PRIVATE + libvirtualhid::libvirtualhid) + +libvirtualhid_copy_mingw_runtime(sunshine_gamepad_adapter) diff --git a/examples/sunshine_gamepad_adapter.cpp b/examples/sunshine_gamepad_adapter.cpp new file mode 100644 index 0000000..59cb9ec --- /dev/null +++ b/examples/sunshine_gamepad_adapter.cpp @@ -0,0 +1,50 @@ +#include + +#include + +int main() { + auto runtime = lvh::Runtime::create(); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::dualsense(); + options.metadata.global_index = 0; + options.metadata.client_relative_index = 0; + options.metadata.client_type = lvh::ClientControllerType::playstation; + options.metadata.has_motion_sensors = true; + options.metadata.has_touchpad = true; + options.metadata.has_rgb_led = true; + options.metadata.has_battery = true; + options.metadata.stable_id = "sunshine-client-0"; + + auto created = runtime->create_gamepad(options); + if(!created) { + std::cerr << created.status.message() << '\n'; + return 1; + } + + created.gamepad->set_output_callback([](const lvh::GamepadOutput& output) { + if(output.kind == lvh::GamepadOutputKind::rumble) { + std::cout << "rumble " << output.low_frequency_rumble << ' ' + << output.high_frequency_rumble << '\n'; + } + }); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.left_stick = {0.25F, -0.5F}; + state.right_trigger = 1.0F; + + if(const auto status = created.gamepad->submit(state); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + + lvh::GamepadOutput rumble; + rumble.kind = lvh::GamepadOutputKind::rumble; + rumble.low_frequency_rumble = 0x4000; + rumble.high_frequency_rumble = 0x2000; + created.gamepad->dispatch_output(rumble); + created.gamepad->close(); + + return 0; +} diff --git a/include/libvirtualhid/libvirtualhid.hpp b/include/libvirtualhid/libvirtualhid.hpp new file mode 100644 index 0000000..a267386 --- /dev/null +++ b/include/libvirtualhid/libvirtualhid.hpp @@ -0,0 +1,6 @@ +#pragma once + +#include +#include +#include +#include diff --git a/include/libvirtualhid/profiles.hpp b/include/libvirtualhid/profiles.hpp new file mode 100644 index 0000000..e17002d --- /dev/null +++ b/include/libvirtualhid/profiles.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include +#include + +namespace lvh::profiles { + +DeviceProfile generic_gamepad(); +DeviceProfile xbox_360(); +DeviceProfile xbox_one(); +DeviceProfile xbox_series(); +DeviceProfile dualsense(); +DeviceProfile switch_pro(); + +std::optional gamepad_profile(GamepadProfileKind kind); +std::vector built_in_gamepad_profiles(); + +} // namespace lvh::profiles diff --git a/include/libvirtualhid/report.hpp b/include/libvirtualhid/report.hpp new file mode 100644 index 0000000..2b9129b --- /dev/null +++ b/include/libvirtualhid/report.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include +#include + +namespace lvh::reports { + +float clamp_axis(float value); +float clamp_trigger(float value); +std::int16_t normalize_axis(float value); +std::uint8_t normalize_trigger(float value); +GamepadState normalize_state(const GamepadState& state); +std::uint8_t hat_from_buttons(const ButtonSet& buttons); +std::vector pack_input_report(const DeviceProfile& profile, const GamepadState& state); + +} // namespace lvh::reports diff --git a/include/libvirtualhid/runtime.hpp b/include/libvirtualhid/runtime.hpp new file mode 100644 index 0000000..a438c80 --- /dev/null +++ b/include/libvirtualhid/runtime.hpp @@ -0,0 +1,88 @@ +#pragma once + +#include + +#include +#include +#include + +namespace lvh { + +namespace detail { +struct GamepadDevice; +class RuntimeState; +} // namespace detail + +class VirtualDevice { +public: + virtual ~VirtualDevice() = default; + + virtual DeviceId device_id() const = 0; + virtual const DeviceProfile& profile() const = 0; + virtual bool is_open() const = 0; + virtual Status close() = 0; +}; + +class Gamepad final: public VirtualDevice { +public: + Gamepad(const Gamepad&) = delete; + Gamepad& operator=(const Gamepad&) = delete; + Gamepad(Gamepad&&) noexcept; + Gamepad& operator=(Gamepad&&) noexcept; + ~Gamepad() override; + + DeviceId device_id() const override; + const DeviceProfile& profile() const override; + const GamepadMetadata& metadata() const; + bool is_open() const override; + Status close() override; + + Status submit(const GamepadState& state); + void set_output_callback(OutputCallback callback); + Status dispatch_output(const GamepadOutput& output); + + GamepadState last_submitted_state() const; + std::vector last_input_report() const; + std::size_t submit_count() const; + +private: + friend class Runtime; + + explicit Gamepad(std::shared_ptr device); + + std::shared_ptr device_; +}; + +struct GamepadCreationResult { + Status status; + std::unique_ptr gamepad; + + explicit operator bool() const { + return status.ok() && gamepad != nullptr; + } +}; + +class Runtime final { +public: + Runtime(const Runtime&) = delete; + Runtime& operator=(const Runtime&) = delete; + Runtime(Runtime&&) noexcept; + Runtime& operator=(Runtime&&) noexcept; + ~Runtime(); + + static std::unique_ptr create(RuntimeOptions options = {}); + + const BackendCapabilities& capabilities() const; + BackendKind backend_kind() const; + GamepadCreationResult create_gamepad(const DeviceProfile& profile); + GamepadCreationResult create_gamepad(const CreateGamepadOptions& options); + std::size_t active_device_count() const; + void close_all(); + +private: + explicit Runtime(RuntimeOptions options); + + std::shared_ptr state_; +}; + +} // namespace lvh diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp new file mode 100644 index 0000000..07b3f1b --- /dev/null +++ b/include/libvirtualhid/types.hpp @@ -0,0 +1,189 @@ +#pragma once + +#include +#include +#include +#include + +namespace lvh { + +using DeviceId = std::uint64_t; + +enum class ErrorCode { + ok, + invalid_argument, + backend_unavailable, + device_closed, + unsupported_profile, + backend_failure, +}; + +class Status { +public: + Status(); + Status(ErrorCode code, std::string message); + + static Status success(); + static Status failure(ErrorCode code, std::string message); + + bool ok() const; + ErrorCode code() const; + const std::string& message() const; + +private: + ErrorCode code_; + std::string message_; +}; + +enum class BackendKind { + fake, + platform_default, +}; + +struct RuntimeOptions { + BackendKind backend = BackendKind::fake; +}; + +struct BackendCapabilities { + std::string backend_name; + bool supports_virtual_hid = false; + bool supports_gamepad = false; + bool supports_keyboard = false; + bool supports_mouse = false; + bool supports_output_reports = false; + bool supports_xtest_fallback = false; + bool requires_installed_driver = false; +}; + +enum class DeviceType { + gamepad, + keyboard, + mouse, +}; + +enum class BusType { + unknown, + usb, + bluetooth, +}; + +enum class GamepadProfileKind { + generic, + xbox_360, + xbox_one, + xbox_series, + dualsense, + switch_pro, +}; + +struct GamepadProfileCapabilities { + bool supports_rumble = false; + bool supports_motion = false; + bool supports_touchpad = false; + bool supports_rgb_led = false; + bool supports_battery = false; + bool supports_adaptive_triggers = false; +}; + +struct DeviceProfile { + DeviceType device_type = DeviceType::gamepad; + GamepadProfileKind gamepad_kind = GamepadProfileKind::generic; + BusType bus_type = BusType::usb; + std::uint16_t vendor_id = 0; + std::uint16_t product_id = 0; + std::uint16_t version = 0; + std::uint8_t report_id = 1; + std::size_t input_report_size = 0; + std::string name; + std::string manufacturer; + GamepadProfileCapabilities capabilities; + std::vector report_descriptor; +}; + +enum class ClientControllerType { + unknown, + xbox, + playstation, + nintendo, +}; + +struct GamepadMetadata { + int global_index = -1; + int client_relative_index = -1; + ClientControllerType client_type = ClientControllerType::unknown; + bool has_motion_sensors = false; + bool has_touchpad = false; + bool has_rgb_led = false; + bool has_battery = false; + std::string stable_id; +}; + +struct CreateGamepadOptions { + DeviceProfile profile; + GamepadMetadata metadata; +}; + +enum class GamepadButton: std::uint8_t { + a = 0, + b, + x, + y, + back, + start, + guide, + left_stick, + right_stick, + left_shoulder, + right_shoulder, + dpad_up, + dpad_down, + dpad_left, + dpad_right, + misc1, +}; + +class ButtonSet { +public: + void set(GamepadButton button, bool pressed = true); + void reset(GamepadButton button); + void clear(); + bool test(GamepadButton button) const; + std::uint32_t raw_bits() const; + +private: + std::uint32_t bits_ = 0; +}; + +struct Stick { + float x = 0.0F; + float y = 0.0F; +}; + +struct GamepadState { + ButtonSet buttons; + Stick left_stick; + Stick right_stick; + float left_trigger = 0.0F; + float right_trigger = 0.0F; +}; + +enum class GamepadOutputKind { + rumble, + rgb_led, + adaptive_triggers, + raw_report, +}; + +struct GamepadOutput { + GamepadOutputKind kind = GamepadOutputKind::raw_report; + std::uint16_t low_frequency_rumble = 0; + std::uint16_t high_frequency_rumble = 0; + std::uint8_t red = 0; + std::uint8_t green = 0; + std::uint8_t blue = 0; + std::vector raw_report; +}; + +using OutputCallback = std::function; + +} // namespace lvh diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..e88468c --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,38 @@ +add_library(${PROJECT_NAME} STATIC) +add_library(libvirtualhid::libvirtualhid ALIAS ${PROJECT_NAME}) + +target_sources(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/core/profiles.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/report.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/runtime.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/types.cpp") + +target_include_directories(${PROJECT_NAME} + PUBLIC + $ + $) + +target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_20) +set_target_properties(${PROJECT_NAME} PROPERTIES + EXPORT_NAME libvirtualhid + OUTPUT_NAME virtualhid) + +if(MSVC) + target_compile_options(${PROJECT_NAME} PRIVATE /W4) +else() + target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) +endif() + +install(TARGETS ${PROJECT_NAME} + EXPORT libvirtualhid-targets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + +install(DIRECTORY "${PROJECT_SOURCE_DIR}/include/" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +install(EXPORT libvirtualhid-targets + NAMESPACE libvirtualhid:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid") diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp new file mode 100644 index 0000000..b27e6d1 --- /dev/null +++ b/src/core/profiles.cpp @@ -0,0 +1,189 @@ +#include + +#include + +namespace lvh::profiles { +namespace { + +constexpr std::size_t common_report_size = 14; + +std::vector make_gamepad_report_descriptor(std::uint8_t report_id) { + return { + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x05, // Usage (Game Pad) + 0xA1, 0x01, // Collection (Application) + 0x85, report_id, // Report ID + 0x05, 0x09, // Usage Page (Button) + 0x19, 0x01, // Usage Minimum (Button 1) + 0x29, 0x0C, // Usage Maximum (Button 12) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x01, // Logical Maximum (1) + 0x75, 0x01, // Report Size (1) + 0x95, 0x0C, // Report Count (12) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x75, 0x01, // Report Size (1) + 0x95, 0x04, // Report Count (4) + 0x81, 0x03, // Input (Const,Var,Abs) + 0x05, 0x01, // Usage Page (Generic Desktop) + 0x09, 0x39, // Usage (Hat switch) + 0x15, 0x00, // Logical Minimum (0) + 0x25, 0x07, // Logical Maximum (7) + 0x35, 0x00, // Physical Minimum (0) + 0x46, 0x3B, 0x01, // Physical Maximum (315) + 0x65, 0x14, // Unit (Degrees) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x42, // Input (Data,Var,Abs,Null) + 0x75, 0x04, // Report Size (4) + 0x95, 0x01, // Report Count (1) + 0x81, 0x03, // Input (Const,Var,Abs) + 0x16, 0x00, 0x80, // Logical Minimum (-32768) + 0x26, 0xFF, 0x7F, // Logical Maximum (32767) + 0x75, 0x10, // Report Size (16) + 0x95, 0x04, // Report Count (4) + 0x09, 0x30, // Usage (X) + 0x09, 0x31, // Usage (Y) + 0x09, 0x33, // Usage (Rx) + 0x09, 0x34, // Usage (Ry) + 0x81, 0x02, // Input (Data,Var,Abs) + 0x15, 0x00, // Logical Minimum (0) + 0x26, 0xFF, 0x00, // Logical Maximum (255) + 0x75, 0x08, // Report Size (8) + 0x95, 0x02, // Report Count (2) + 0x09, 0x32, // Usage (Z) + 0x09, 0x35, // Usage (Rz) + 0x81, 0x02, // Input (Data,Var,Abs) + 0xC0, // End Collection + }; +} + +DeviceProfile make_gamepad_profile( + GamepadProfileKind kind, + std::string name, + std::uint16_t vendor_id, + std::uint16_t product_id, + std::uint16_t version, + GamepadProfileCapabilities capabilities +) { + DeviceProfile profile; + profile.device_type = DeviceType::gamepad; + profile.gamepad_kind = kind; + profile.bus_type = BusType::usb; + profile.vendor_id = vendor_id; + profile.product_id = product_id; + profile.version = version; + profile.report_id = 1; + profile.input_report_size = common_report_size; + profile.name = std::move(name); + profile.manufacturer = "LizardByte"; + profile.capabilities = capabilities; + profile.report_descriptor = make_gamepad_report_descriptor(profile.report_id); + return profile; +} + +} // namespace + +DeviceProfile generic_gamepad() { + return make_gamepad_profile( + GamepadProfileKind::generic, + "libvirtualhid Generic Gamepad", + 0x1209, + 0x0001, + 0x0001, + {} + ); +} + +DeviceProfile xbox_360() { + return make_gamepad_profile( + GamepadProfileKind::xbox_360, + "Microsoft X-Box 360 pad", + 0x045E, + 0x028E, + 0x0114, + {.supports_rumble = true} + ); +} + +DeviceProfile xbox_one() { + return make_gamepad_profile( + GamepadProfileKind::xbox_one, + "Xbox One Controller", + 0x045E, + 0x02EA, + 0x0408, + {.supports_rumble = true} + ); +} + +DeviceProfile xbox_series() { + return make_gamepad_profile( + GamepadProfileKind::xbox_series, + "Xbox Wireless Controller", + 0x045E, + 0x0B12, + 0x0500, + {.supports_rumble = true, .supports_battery = true} + ); +} + +DeviceProfile dualsense() { + return make_gamepad_profile( + GamepadProfileKind::dualsense, + "DualSense Wireless Controller", + 0x054C, + 0x0CE6, + 0x8111, + { + .supports_rumble = true, + .supports_motion = true, + .supports_touchpad = true, + .supports_rgb_led = true, + .supports_battery = true, + .supports_adaptive_triggers = true, + } + ); +} + +DeviceProfile switch_pro() { + return make_gamepad_profile( + GamepadProfileKind::switch_pro, + "Nintendo Switch Pro Controller", + 0x057E, + 0x2009, + 0x8111, + {.supports_rumble = true, .supports_motion = true, .supports_battery = true} + ); +} + +std::optional gamepad_profile(GamepadProfileKind kind) { + switch(kind) { + case GamepadProfileKind::generic: + return generic_gamepad(); + case GamepadProfileKind::xbox_360: + return xbox_360(); + case GamepadProfileKind::xbox_one: + return xbox_one(); + case GamepadProfileKind::xbox_series: + return xbox_series(); + case GamepadProfileKind::dualsense: + return dualsense(); + case GamepadProfileKind::switch_pro: + return switch_pro(); + } + + return std::nullopt; +} + +std::vector built_in_gamepad_profiles() { + return { + generic_gamepad(), + xbox_360(), + xbox_one(), + xbox_series(), + dualsense(), + switch_pro(), + }; +} + +} // namespace lvh::profiles diff --git a/src/core/report.cpp b/src/core/report.cpp new file mode 100644 index 0000000..34794db --- /dev/null +++ b/src/core/report.cpp @@ -0,0 +1,159 @@ +#include + +#include +#include + +namespace lvh::reports { +namespace { + +constexpr std::uint8_t neutral_hat = 8; + +void append_u16(std::vector& report, std::uint16_t value) { + report.push_back(static_cast(value & 0xFFU)); + report.push_back(static_cast((value >> 8U) & 0xFFU)); +} + +void append_i16(std::vector& report, std::int16_t value) { + append_u16(report, static_cast(value)); +} + +std::uint16_t report_button_bits(const ButtonSet& buttons) { + std::uint16_t bits = 0; + + const auto add = [&bits, &buttons](GamepadButton button, std::uint16_t bit) { + if(buttons.test(button)) { + bits |= bit; + } + }; + + add(GamepadButton::a, 1U << 0U); + add(GamepadButton::b, 1U << 1U); + add(GamepadButton::x, 1U << 2U); + add(GamepadButton::y, 1U << 3U); + add(GamepadButton::back, 1U << 4U); + add(GamepadButton::start, 1U << 5U); + add(GamepadButton::guide, 1U << 6U); + add(GamepadButton::left_stick, 1U << 7U); + add(GamepadButton::right_stick, 1U << 8U); + add(GamepadButton::left_shoulder, 1U << 9U); + add(GamepadButton::right_shoulder, 1U << 10U); + add(GamepadButton::misc1, 1U << 11U); + + return bits; +} + +} // namespace + +float clamp_axis(float value) { + if(std::isnan(value)) { + return 0.0F; + } + + return std::clamp(value, -1.0F, 1.0F); +} + +float clamp_trigger(float value) { + if(std::isnan(value)) { + return 0.0F; + } + + return std::clamp(value, 0.0F, 1.0F); +} + +std::int16_t normalize_axis(float value) { + const auto clamped = clamp_axis(value); + if(clamped <= -1.0F) { + return -32768; + } + if(clamped >= 1.0F) { + return 32767; + } + if(clamped < 0.0F) { + return static_cast(std::lround(clamped * 32768.0F)); + } + return static_cast(std::lround(clamped * 32767.0F)); +} + +std::uint8_t normalize_trigger(float value) { + return static_cast(std::lround(clamp_trigger(value) * 255.0F)); +} + +GamepadState normalize_state(const GamepadState& state) { + auto normalized = state; + normalized.left_stick.x = clamp_axis(state.left_stick.x); + normalized.left_stick.y = clamp_axis(state.left_stick.y); + normalized.right_stick.x = clamp_axis(state.right_stick.x); + normalized.right_stick.y = clamp_axis(state.right_stick.y); + normalized.left_trigger = clamp_trigger(state.left_trigger); + normalized.right_trigger = clamp_trigger(state.right_trigger); + return normalized; +} + +std::uint8_t hat_from_buttons(const ButtonSet& buttons) { + auto up = buttons.test(GamepadButton::dpad_up); + auto down = buttons.test(GamepadButton::dpad_down); + auto left = buttons.test(GamepadButton::dpad_left); + auto right = buttons.test(GamepadButton::dpad_right); + + if(up && down) { + up = false; + down = false; + } + if(left && right) { + left = false; + right = false; + } + + if(up && right) { + return 1; + } + if(down && right) { + return 3; + } + if(down && left) { + return 5; + } + if(up && left) { + return 7; + } + if(up) { + return 0; + } + if(right) { + return 2; + } + if(down) { + return 4; + } + if(left) { + return 6; + } + + return neutral_hat; +} + +std::vector pack_input_report(const DeviceProfile& profile, const GamepadState& state) { + constexpr std::size_t common_report_size = 14; + if(profile.device_type != DeviceType::gamepad || profile.input_report_size < common_report_size) { + return {}; + } + + const auto normalized = normalize_state(state); + + std::vector report; + report.reserve(common_report_size); + report.push_back(profile.report_id); + append_u16(report, report_button_bits(normalized.buttons)); + report.push_back(hat_from_buttons(normalized.buttons)); + append_i16(report, normalize_axis(normalized.left_stick.x)); + append_i16(report, normalize_axis(normalized.left_stick.y)); + append_i16(report, normalize_axis(normalized.right_stick.x)); + append_i16(report, normalize_axis(normalized.right_stick.y)); + report.push_back(normalize_trigger(normalized.left_trigger)); + report.push_back(normalize_trigger(normalized.right_trigger)); + + report.resize(profile.input_report_size, 0); + return report; +} + +} // namespace lvh::reports diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp new file mode 100644 index 0000000..0e2bc1c --- /dev/null +++ b/src/core/runtime.cpp @@ -0,0 +1,246 @@ +#include + +#include + +#include +#include +#include + +namespace lvh::detail { + +struct GamepadDevice { + explicit GamepadDevice(DeviceId device_id, CreateGamepadOptions create_options): + id {device_id}, + options {std::move(create_options)} {} + + DeviceId id; + CreateGamepadOptions options; + bool open = true; + GamepadState last_state; + std::vector last_report; + std::size_t submitted_reports = 0; + OutputCallback output_callback; + mutable std::mutex mutex; +}; + +class RuntimeState { +public: + explicit RuntimeState(RuntimeOptions runtime_options): + options {runtime_options}, + caps {make_capabilities(runtime_options.backend)} {} + + static BackendCapabilities make_capabilities(BackendKind kind) { + BackendCapabilities capabilities; + if(kind == BackendKind::fake) { + capabilities.backend_name = "fake"; + capabilities.supports_gamepad = true; + capabilities.supports_output_reports = true; + } + else { + capabilities.backend_name = "platform-default-unimplemented"; + } + return capabilities; + } + + RuntimeOptions options; + BackendCapabilities caps; + DeviceId next_device_id = 1; + std::vector> gamepads; + mutable std::mutex mutex; +}; + +} // namespace lvh::detail + +namespace lvh { +namespace { + +Status validate_gamepad_options(const CreateGamepadOptions& options) { + if(options.profile.device_type != DeviceType::gamepad) { + return Status::failure(ErrorCode::unsupported_profile, "device profile is not a gamepad"); + } + if(options.profile.name.empty()) { + return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + if(options.profile.report_descriptor.empty()) { + return Status::failure(ErrorCode::invalid_argument, "device profile report descriptor must not be empty"); + } + if(options.profile.report_id == 0) { + return Status::failure(ErrorCode::invalid_argument, "device profile report id must not be zero"); + } + if(options.profile.input_report_size == 0) { + return Status::failure(ErrorCode::invalid_argument, "device profile input report size must not be zero"); + } + + return Status::success(); +} + +template +auto with_device(const std::shared_ptr& device, Func&& func) { + std::lock_guard lock {device->mutex}; + return func(*device); +} + +} // namespace + +Gamepad::Gamepad(std::shared_ptr device): + device_ {std::move(device)} {} + +Gamepad::Gamepad(Gamepad&&) noexcept = default; +Gamepad& Gamepad::operator=(Gamepad&&) noexcept = default; +Gamepad::~Gamepad() = default; + +DeviceId Gamepad::device_id() const { + return device_->id; +} + +const DeviceProfile& Gamepad::profile() const { + return device_->options.profile; +} + +const GamepadMetadata& Gamepad::metadata() const { + return device_->options.metadata; +} + +bool Gamepad::is_open() const { + return with_device(device_, [](const auto& device) { + return device.open; + }); +} + +Status Gamepad::close() { + return with_device(device_, [](auto& device) { + if(!device.open) { + return Status::success(); + } + device.open = false; + return Status::success(); + }); +} + +Status Gamepad::submit(const GamepadState& state) { + return with_device(device_, [&state](auto& device) { + if(!device.open) { + return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + } + + auto report = reports::pack_input_report(device.options.profile, state); + if(report.empty()) { + return Status::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); + } + + device.last_state = reports::normalize_state(state); + device.last_report = std::move(report); + ++device.submitted_reports; + return Status::success(); + }); +} + +void Gamepad::set_output_callback(OutputCallback callback) { + with_device(device_, [&callback](auto& device) { + device.output_callback = std::move(callback); + return 0; + }); +} + +Status Gamepad::dispatch_output(const GamepadOutput& output) { + OutputCallback callback; + const auto status = with_device(device_, [&callback](auto& device) { + if(!device.open) { + return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + } + callback = device.output_callback; + return Status::success(); + }); + + if(!status.ok()) { + return status; + } + if(callback) { + callback(output); + } + return Status::success(); +} + +GamepadState Gamepad::last_submitted_state() const { + return with_device(device_, [](const auto& device) { + return device.last_state; + }); +} + +std::vector Gamepad::last_input_report() const { + return with_device(device_, [](const auto& device) { + return device.last_report; + }); +} + +std::size_t Gamepad::submit_count() const { + return with_device(device_, [](const auto& device) { + return device.submitted_reports; + }); +} + +Runtime::Runtime(RuntimeOptions options): + state_ {std::make_shared(options)} {} + +Runtime::Runtime(Runtime&&) noexcept = default; +Runtime& Runtime::operator=(Runtime&&) noexcept = default; +Runtime::~Runtime() = default; + +std::unique_ptr Runtime::create(RuntimeOptions options) { + return std::unique_ptr {new Runtime {options}}; +} + +const BackendCapabilities& Runtime::capabilities() const { + return state_->caps; +} + +BackendKind Runtime::backend_kind() const { + return state_->options.backend; +} + +GamepadCreationResult Runtime::create_gamepad(const DeviceProfile& profile) { + CreateGamepadOptions options; + options.profile = profile; + return create_gamepad(options); +} + +GamepadCreationResult Runtime::create_gamepad(const CreateGamepadOptions& options) { + if(state_->options.backend != BackendKind::fake) { + return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + if(const auto validation = validate_gamepad_options(options); !validation.ok()) { + return {validation, nullptr}; + } + + std::lock_guard lock {state_->mutex}; + const auto id = state_->next_device_id++; + auto device = std::make_shared(id, options); + state_->gamepads.emplace_back(device); + return {Status::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; +} + +std::size_t Runtime::active_device_count() const { + std::lock_guard lock {state_->mutex}; + std::size_t count = 0; + for(const auto& weak_device : state_->gamepads) { + if(const auto device = weak_device.lock()) { + if(device->open) { + ++count; + } + } + } + return count; +} + +void Runtime::close_all() { + std::lock_guard lock {state_->mutex}; + for(const auto& weak_device : state_->gamepads) { + if(const auto device = weak_device.lock()) { + std::lock_guard device_lock {device->mutex}; + device->open = false; + } + } +} + +} // namespace lvh diff --git a/src/core/types.cpp b/src/core/types.cpp new file mode 100644 index 0000000..f6c8379 --- /dev/null +++ b/src/core/types.cpp @@ -0,0 +1,64 @@ +#include + +#include + +namespace lvh { + +Status::Status(): + code_ {ErrorCode::ok}, + message_ {} {} + +Status::Status(ErrorCode code, std::string message): + code_ {code}, + message_ {std::move(message)} {} + +Status Status::success() { + return {}; +} + +Status Status::failure(ErrorCode code, std::string message) { + if(code == ErrorCode::ok) { + return {}; + } + return {code, std::move(message)}; +} + +bool Status::ok() const { + return code_ == ErrorCode::ok; +} + +ErrorCode Status::code() const { + return code_; +} + +const std::string& Status::message() const { + return message_; +} + +void ButtonSet::set(GamepadButton button, bool pressed) { + const auto mask = 1U << static_cast(button); + if(pressed) { + bits_ |= mask; + } + else { + bits_ &= ~mask; + } +} + +void ButtonSet::reset(GamepadButton button) { + set(button, false); +} + +void ButtonSet::clear() { + bits_ = 0; +} + +bool ButtonSet::test(GamepadButton button) const { + return (bits_ & (1U << static_cast(button))) != 0; +} + +std::uint32_t ButtonSet::raw_bits() const { + return bits_; +} + +} // namespace lvh diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..e8cd1f8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,28 @@ +# +# Setup GoogleTest +# +set(INSTALL_GTEST OFF) +set(INSTALL_GMOCK OFF) +if(WIN32) + set(gtest_force_shared_crt ON CACHE BOOL "Always use msvcrt.dll" FORCE) # cmake-lint: disable=C0103 +endif() + +include(GoogleTest) +add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/googletest" "third-party/googletest") + +set(TEST_BINARY test_libvirtualhid) + +add_executable(${TEST_BINARY} + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_sunshine_adapter.cpp") + +target_link_libraries(${TEST_BINARY} + PRIVATE + gmock_main + libvirtualhid::libvirtualhid) + +libvirtualhid_copy_mingw_runtime(${TEST_BINARY}) + +gtest_discover_tests(${TEST_BINARY}) diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp new file mode 100644 index 0000000..198cbfe --- /dev/null +++ b/tests/unit/test_profiles.cpp @@ -0,0 +1,44 @@ +#include + +#include + +TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { + const auto profiles = lvh::profiles::built_in_gamepad_profiles(); + + ASSERT_GE(profiles.size(), 4U); + for(const auto& profile : profiles) { + EXPECT_EQ(profile.device_type, lvh::DeviceType::gamepad); + EXPECT_FALSE(profile.name.empty()); + EXPECT_NE(profile.vendor_id, 0); + EXPECT_NE(profile.product_id, 0); + EXPECT_NE(profile.report_id, 0); + EXPECT_GE(profile.input_report_size, 14U); + EXPECT_FALSE(profile.report_descriptor.empty()); + } +} + +TEST(ProfileTest, SunshineProfilesArePresent) { + const auto xbox_one = lvh::profiles::xbox_one(); + const auto dualsense = lvh::profiles::dualsense(); + const auto switch_pro = lvh::profiles::switch_pro(); + + EXPECT_EQ(xbox_one.vendor_id, 0x045E); + EXPECT_EQ(xbox_one.product_id, 0x02EA); + EXPECT_TRUE(xbox_one.capabilities.supports_rumble); + + EXPECT_EQ(dualsense.vendor_id, 0x054C); + EXPECT_TRUE(dualsense.capabilities.supports_motion); + EXPECT_TRUE(dualsense.capabilities.supports_touchpad); + EXPECT_TRUE(dualsense.capabilities.supports_rgb_led); + EXPECT_TRUE(dualsense.capabilities.supports_adaptive_triggers); + + EXPECT_EQ(switch_pro.vendor_id, 0x057E); + EXPECT_EQ(switch_pro.product_id, 0x2009); +} + +TEST(ProfileTest, CanFindProfileByKind) { + const auto profile = lvh::profiles::gamepad_profile(lvh::GamepadProfileKind::xbox_series); + + ASSERT_TRUE(profile.has_value()); + EXPECT_EQ(profile->gamepad_kind, lvh::GamepadProfileKind::xbox_series); +} diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp new file mode 100644 index 0000000..f9b9914 --- /dev/null +++ b/tests/unit/test_report.cpp @@ -0,0 +1,58 @@ +#include +#include + +#include + +TEST(ReportTest, NormalizesAxesAndTriggers) { + EXPECT_EQ(lvh::reports::normalize_axis(-2.0F), -32768); + EXPECT_EQ(lvh::reports::normalize_axis(-1.0F), -32768); + EXPECT_EQ(lvh::reports::normalize_axis(0.0F), 0); + EXPECT_EQ(lvh::reports::normalize_axis(1.0F), 32767); + EXPECT_EQ(lvh::reports::normalize_axis(2.0F), 32767); + + EXPECT_EQ(lvh::reports::normalize_trigger(-1.0F), 0); + EXPECT_EQ(lvh::reports::normalize_trigger(0.0F), 0); + EXPECT_EQ(lvh::reports::normalize_trigger(1.0F), 255); + EXPECT_EQ(lvh::reports::normalize_trigger(2.0F), 255); +} + +TEST(ReportTest, EncodesHatSwitch) { + lvh::ButtonSet buttons; + EXPECT_EQ(lvh::reports::hat_from_buttons(buttons), 8); + + buttons.set(lvh::GamepadButton::dpad_up); + EXPECT_EQ(lvh::reports::hat_from_buttons(buttons), 0); + + buttons.set(lvh::GamepadButton::dpad_right); + EXPECT_EQ(lvh::reports::hat_from_buttons(buttons), 1); + + buttons.set(lvh::GamepadButton::dpad_down); + EXPECT_EQ(lvh::reports::hat_from_buttons(buttons), 2); +} + +TEST(ReportTest, PacksCommonGamepadReport) { + auto profile = lvh::profiles::xbox_360(); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.buttons.set(lvh::GamepadButton::start); + state.buttons.set(lvh::GamepadButton::dpad_left); + state.left_stick = {1.0F, -1.0F}; + state.right_stick = {0.5F, -0.5F}; + state.left_trigger = 0.25F; + state.right_trigger = 1.0F; + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(report[0], profile.report_id); + EXPECT_EQ(report[1], 0x21); // A + Start + EXPECT_EQ(report[2], 0x00); + EXPECT_EQ(report[3], 6); // D-pad left + EXPECT_EQ(report[4], 0xFF); + EXPECT_EQ(report[5], 0x7F); + EXPECT_EQ(report[6], 0x00); + EXPECT_EQ(report[7], 0x80); + EXPECT_EQ(report[12], 64); + EXPECT_EQ(report[13], 255); +} diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp new file mode 100644 index 0000000..a4942ee --- /dev/null +++ b/tests/unit/test_runtime.cpp @@ -0,0 +1,77 @@ +#include + +#include + +TEST(RuntimeTest, FakeBackendReportsCapabilities) { + auto runtime = lvh::Runtime::create(); + + EXPECT_EQ(runtime->backend_kind(), lvh::BackendKind::fake); + EXPECT_EQ(runtime->capabilities().backend_name, "fake"); + EXPECT_TRUE(runtime->capabilities().supports_gamepad); + EXPECT_TRUE(runtime->capabilities().supports_output_reports); + EXPECT_FALSE(runtime->capabilities().requires_installed_driver); +} + +TEST(RuntimeTest, PlatformDefaultIsUnavailableInPhaseOne) { + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(options); + + EXPECT_EQ(runtime->backend_kind(), lvh::BackendKind::platform_default); + EXPECT_EQ(runtime->capabilities().backend_name, "platform-default-unimplemented"); + EXPECT_FALSE(runtime->capabilities().supports_gamepad); + + auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); + EXPECT_FALSE(created); + EXPECT_EQ(created.status.code(), lvh::ErrorCode::backend_unavailable); +} + +TEST(RuntimeTest, CreatesSubmitsAndClosesGamepad) { + auto runtime = lvh::Runtime::create(); + auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); + + ASSERT_TRUE(created); + ASSERT_NE(created.gamepad, nullptr); + EXPECT_TRUE(created.gamepad->is_open()); + EXPECT_EQ(runtime->active_device_count(), 1U); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::b); + state.left_stick.x = 2.0F; + state.left_trigger = 2.0F; + + EXPECT_TRUE(created.gamepad->submit(state).ok()); + EXPECT_EQ(created.gamepad->submit_count(), 1U); + EXPECT_EQ(created.gamepad->last_submitted_state().left_stick.x, 1.0F); + EXPECT_EQ(created.gamepad->last_submitted_state().left_trigger, 1.0F); + EXPECT_FALSE(created.gamepad->last_input_report().empty()); + + EXPECT_TRUE(created.gamepad->close().ok()); + EXPECT_FALSE(created.gamepad->is_open()); + EXPECT_EQ(runtime->active_device_count(), 0U); + EXPECT_EQ(created.gamepad->submit(state).code(), lvh::ErrorCode::device_closed); +} + +TEST(RuntimeTest, DispatchesOutputCallback) { + auto runtime = lvh::Runtime::create(); + auto created = runtime->create_gamepad(lvh::profiles::dualsense()); + ASSERT_TRUE(created); + + lvh::GamepadOutput received; + bool was_called = false; + created.gamepad->set_output_callback([&](const lvh::GamepadOutput& output) { + received = output; + was_called = true; + }); + + lvh::GamepadOutput output; + output.kind = lvh::GamepadOutputKind::rumble; + output.low_frequency_rumble = 123; + output.high_frequency_rumble = 456; + + EXPECT_TRUE(created.gamepad->dispatch_output(output).ok()); + EXPECT_TRUE(was_called); + EXPECT_EQ(received.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(received.low_frequency_rumble, 123); + EXPECT_EQ(received.high_frequency_rumble, 456); +} diff --git a/tests/unit/test_sunshine_adapter.cpp b/tests/unit/test_sunshine_adapter.cpp new file mode 100644 index 0000000..db355ca --- /dev/null +++ b/tests/unit/test_sunshine_adapter.cpp @@ -0,0 +1,54 @@ +#include + +#include + +TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { + auto runtime = lvh::Runtime::create(); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::dualsense(); + options.metadata.global_index = 2; + options.metadata.client_relative_index = 0; + options.metadata.client_type = lvh::ClientControllerType::playstation; + options.metadata.has_motion_sensors = true; + options.metadata.has_touchpad = true; + options.metadata.has_rgb_led = true; + options.metadata.has_battery = true; + options.metadata.stable_id = "moonlight-client-0"; + + auto created = runtime->create_gamepad(options); + ASSERT_TRUE(created); + + EXPECT_EQ(created.gamepad->metadata().global_index, 2); + EXPECT_EQ(created.gamepad->metadata().client_relative_index, 0); + EXPECT_EQ(created.gamepad->metadata().client_type, lvh::ClientControllerType::playstation); + EXPECT_TRUE(created.gamepad->profile().capabilities.supports_motion); + EXPECT_TRUE(created.gamepad->profile().capabilities.supports_touchpad); + + bool feedback_received = false; + created.gamepad->set_output_callback([&](const lvh::GamepadOutput& output) { + feedback_received = output.kind == lvh::GamepadOutputKind::rumble && + output.low_frequency_rumble == 0x4000 && + output.high_frequency_rumble == 0x2000; + }); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.buttons.set(lvh::GamepadButton::dpad_up); + state.left_stick = {0.25F, -0.5F}; + state.right_trigger = 1.0F; + + EXPECT_TRUE(created.gamepad->submit(state).ok()); + EXPECT_EQ(created.gamepad->submit_count(), 1U); + + lvh::GamepadOutput rumble; + rumble.kind = lvh::GamepadOutputKind::rumble; + rumble.low_frequency_rumble = 0x4000; + rumble.high_frequency_rumble = 0x2000; + + EXPECT_TRUE(created.gamepad->dispatch_output(rumble).ok()); + EXPECT_TRUE(feedback_received); + + EXPECT_TRUE(created.gamepad->close().ok()); + EXPECT_EQ(runtime->active_device_count(), 0U); +} From a339c1823e52dac043c6ef07a34c0ebfbe81a63f Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:32:16 -0400 Subject: [PATCH 05/28] Add GitHub Actions CI workflow Add a new CI workflow (.github/workflows/ci.yml) that provides cross-platform build and test coverage using a matrix for Linux (GCC/Clang), macOS, Windows (MinGW UCRT64) and Windows MSVC. The workflow checks out the repo, installs platform-specific dependencies, configures CMake (Ninja or Visual Studio), builds, runs ctest, runs an example binary, installs artifacts and uploads the install tree per-matrix job. Also update README.md to document the new CI coverage. --- .github/workflows/ci.yml | 161 +++++++++++++++++++++++++++++++++++++++ README.md | 3 + 2 files changed, 164 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4759277 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,161 @@ +--- +name: CI +permissions: {} + +on: + pull_request: + push: + branches: + - master + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +jobs: + build: + name: Build (${{ matrix.name }}) + permissions: + contents: read + runs-on: ${{ matrix.os }} + defaults: + run: + shell: ${{ matrix.shell }} + strategy: + fail-fast: false + matrix: + include: + - name: Linux-GCC + os: ubuntu-latest + shell: bash + kind: unix + cc: gcc + cxx: g++ + - name: Linux-Clang + os: ubuntu-latest + shell: bash + kind: unix + cc: clang + cxx: clang++ + - name: macOS + os: macos-latest + shell: bash + kind: unix + cc: clang + cxx: clang++ + - name: Windows-MinGW-UCRT64 + os: windows-latest + shell: msys2 {0} + kind: msys2 + cc: gcc + cxx: g++ + msystem: ucrt64 + toolchain: ucrt-x86_64 + - name: Windows-MSVC + os: windows-latest + shell: pwsh + kind: msvc + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + submodules: recursive + + - name: Setup Dependencies Linux + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + clang \ + cmake \ + ninja-build + + - name: Setup Dependencies macOS + if: runner.os == 'macOS' + run: | + brew install \ + cmake \ + ninja + + - name: Setup Dependencies Windows MinGW + if: matrix.kind == 'msys2' + uses: msys2/setup-msys2@66cd2cce69caa17b53920067426061ca1de3a884 # v2.32.0 + with: + msystem: ${{ matrix.msystem }} + update: true + install: >- + mingw-w64-${{ matrix.toolchain }}-cmake + mingw-w64-${{ matrix.toolchain }}-ninja + mingw-w64-${{ matrix.toolchain }}-toolchain + + - name: Configure + if: matrix.kind != 'msvc' + env: + CC: ${{ matrix.cc }} + CXX: ${{ matrix.cxx }} + run: | + cmake \ + -DBUILD_DOCS=OFF \ + -DBUILD_EXAMPLES=ON \ + -DBUILD_TESTS=ON \ + -DCMAKE_BUILD_TYPE:STRING=Debug \ + -B cmake-build-ci \ + -G Ninja \ + -S . + + - name: Configure MSVC + if: matrix.kind == 'msvc' + run: | + cmake ` + -DBUILD_DOCS=OFF ` + -DBUILD_EXAMPLES=ON ` + -DBUILD_TESTS=ON ` + -A x64 ` + -B cmake-build-ci ` + -G "Visual Studio 17 2022" ` + -S . + + - name: Build + if: matrix.kind != 'msvc' + run: cmake --build cmake-build-ci -- -j2 + + - name: Build MSVC + if: matrix.kind == 'msvc' + run: cmake --build cmake-build-ci --config Debug --parallel 2 + + - name: Run tests + if: matrix.kind != 'msvc' + run: ctest --test-dir cmake-build-ci --output-on-failure + + - name: Run tests MSVC + if: matrix.kind == 'msvc' + run: ctest --test-dir cmake-build-ci --build-config Debug --output-on-failure + + - name: Run Sunshine adapter example + if: matrix.kind != 'msvc' + run: | + if [[ "${RUNNER_OS}" == "Windows" ]]; then + ./cmake-build-ci/examples/sunshine_gamepad_adapter.exe + else + ./cmake-build-ci/examples/sunshine_gamepad_adapter + fi + + - name: Run Sunshine adapter example MSVC + if: matrix.kind == 'msvc' + run: .\cmake-build-ci\examples\Debug\sunshine_gamepad_adapter.exe + + - name: Install + if: matrix.kind != 'msvc' + run: cmake --install cmake-build-ci --prefix cmake-build-ci/install + + - name: Install MSVC + if: matrix.kind == 'msvc' + run: cmake --install cmake-build-ci --config Debug --prefix cmake-build-ci/install + + - name: Upload install artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: install-${{ matrix.name }} + path: cmake-build-ci/install + if-no-files-found: error diff --git a/README.md b/README.md index e4c7e8e..b72fa89 100644 --- a/README.md +++ b/README.md @@ -228,6 +228,9 @@ third-party/googletest/ GoogleTest submodule - Add a fake in-memory backend so API tests can run on every platform. - Add GoogleTest as a submodule under `third-party/googletest` and wire tests using the same top-level-only pattern as `tray` and `libdisplaydevice`. +- Add CI using the `libdisplaydevice` workflow pattern for Linux GCC, Linux + Clang, macOS, Windows MinGW/UCRT64, and Windows MSVC configure/build/test + coverage. - Add descriptor/profile models for at least Xbox 360, Xbox Series, DualSense, and a generic HID gamepad. - Add unit tests for state normalization and HID report packing. From 54b239391c2bb8c8d809233c9563b1f716ccc744 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:40:37 -0400 Subject: [PATCH 06/28] Add Doxygen config, submodule, and API docs Add Doxygen/Read the Docs plumbing and document the public API. - Add .readthedocs.yaml and docs/Doxyfile to configure Read the Docs and Doxygen. - Add third-party/doxyconfig as a git submodule and update .gitmodules and .gitignore. - Wire docs submodule into CMake (top-level BUILD_DOCS option) so docs can be built when top-level. - Document public headers: add Doxygen-style comments and API surface annotations to include/libvirtualhid/*.hpp (libvirtualhid.hpp, profiles.hpp, report.hpp, runtime.hpp, types.hpp). - Update README to mention the new docs directory and the doxyconfig submodule. These changes enable building hosted documentation (Read the Docs) and provide inline API documentation for consumers of the public headers. --- .gitignore | 6 + .gitmodules | 4 + .readthedocs.yaml | 30 ++ CMakeLists.txt | 6 +- README.md | 6 + docs/Doxyfile | 39 +++ include/libvirtualhid/libvirtualhid.hpp | 5 + include/libvirtualhid/profiles.hpp | 47 +++ include/libvirtualhid/report.hpp | 49 +++ include/libvirtualhid/runtime.hpp | 208 +++++++++++- include/libvirtualhid/types.hpp | 419 +++++++++++++++++++++--- third-party/doxyconfig | 1 + 12 files changed, 771 insertions(+), 49 deletions(-) create mode 100644 .readthedocs.yaml create mode 100644 docs/Doxyfile create mode 160000 third-party/doxyconfig diff --git a/.gitignore b/.gitignore index eed00dc..1ee74c6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ # CMake build/ cmake-build-*/ + +# Local temp directories +.tmp/ + +# doxyconfig +docs/doxyconfig* diff --git a/.gitmodules b/.gitmodules index 3f127b8..4e26a7d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ +[submodule "third-party/doxyconfig"] + path = third-party/doxyconfig + url = https://github.com/LizardByte/doxyconfig.git + branch = master [submodule "third-party/googletest"] path = third-party/googletest url = https://github.com/google/googletest.git diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..ee2f3bb --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,30 @@ +--- +# .readthedocs.yaml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "miniconda-latest" + commands: + - | + if [ -f readthedocs_build.sh ]; then + doxyconfig_dir="." + else + doxyconfig_dir="./third-party/doxyconfig" + fi + chmod +x "${doxyconfig_dir}/readthedocs_build.sh" + export DOXYCONFIG_DIR="${doxyconfig_dir}" + "${doxyconfig_dir}/readthedocs_build.sh" + +# using conda, we can get newer doxygen and graphviz than ubuntu provide +# https://github.com/readthedocs/readthedocs.org/issues/8151#issuecomment-890359661 +conda: + environment: third-party/doxyconfig/environment.yml + +submodules: + include: all + recursive: true diff --git a/CMakeLists.txt b/CMakeLists.txt index 19e2ce3..e5c6d23 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,7 +27,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") # # Project optional configuration # -option(BUILD_DOCS "Build documentation" OFF) +option(BUILD_DOCS "Build documentation" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_TESTS "Build tests" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_EXAMPLES "Build examples" ${LIBVIRTUALHID_IS_TOP_LEVEL}) @@ -64,6 +64,10 @@ add_subdirectory(src) # Examples, tests, and docs are top-level only # if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) + if(BUILD_DOCS) + add_subdirectory(third-party/doxyconfig docs) + endif() + if(BUILD_EXAMPLES) add_subdirectory(examples) endif() diff --git a/README.md b/README.md index b72fa89..6df3490 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,8 @@ consumers: - Put all submodules under `third-party`. - Add GoogleTest as a submodule at `third-party/googletest`; do not download it during configure. +- Add the LizardByte Doxygen configuration as a submodule at + `third-party/doxyconfig` and use it for local docs and Read the Docs builds. - Expose `libvirtualhid::libvirtualhid` as the main CMake target. - Keep the public headers under `include/libvirtualhid` and the implementation split into shared core code plus platform-specific backends. @@ -215,6 +217,8 @@ profiles/ Built-in gamepad profiles examples/ Minimal consumers and platform smoke tests tests/ Unit and integration tests cmake/ Package config and helper modules +docs/ Project Doxygen configuration +third-party/doxyconfig/ LizardByte Doxygen configuration submodule third-party/googletest/ GoogleTest submodule ``` @@ -228,6 +232,8 @@ third-party/googletest/ GoogleTest submodule - Add a fake in-memory backend so API tests can run on every platform. - Add GoogleTest as a submodule under `third-party/googletest` and wire tests using the same top-level-only pattern as `tray` and `libdisplaydevice`. +- Add Doxygen documentation wiring with `third-party/doxyconfig`, a project + `docs/Doxyfile`, and Read the Docs configuration. - Add CI using the `libdisplaydevice` workflow pattern for Linux GCC, Linux Clang, macOS, Windows MinGW/UCRT64, and Windows MSVC configure/build/test coverage. diff --git a/docs/Doxyfile b/docs/Doxyfile new file mode 100644 index 0000000..9c7ba8f --- /dev/null +++ b/docs/Doxyfile @@ -0,0 +1,39 @@ +# This file describes the settings to be used by the documentation system +# doxygen (www.doxygen.org) for a project. +# +# All text after a double hash (##) is considered a comment and is placed in +# front of the TAG it is preceding. +# +# All text after a single hash (#) is considered a comment and will be ignored. +# The format is: +# TAG = value [value, ...] +# For lists, items can also be appended using: +# TAG += value [value, ...] +# Values that contain spaces should be placed between quotes (\" \"). +# +# Note: +# +# Use doxygen to compare the used configuration file with the template +# configuration file: +# doxygen -x [configFile] +# Use doxygen to compare the used configuration file with the template +# configuration file without replacing the environment variables or CMake type +# replacement variables: +# doxygen -x_noenv [configFile] + +# project metadata +DOCSET_BUNDLE_ID = dev.lizardbyte.libvirtualhid +DOCSET_PUBLISHER_ID = dev.lizardbyte.libvirtualhid.documentation +PROJECT_BRIEF = "Cross-platform C++ library for virtual HID devices." +PROJECT_NAME = libvirtualhid + +# project specific settings +DOT_GRAPH_MAX_NODES = 50 +INCLUDE_PATH = +WARN_IF_UNDOCUMENTED = YES + +# files and directories to process +USE_MDFILE_AS_MAINPAGE = ../README.md +INPUT = ../README.md \ + ../third-party/doxyconfig/docs/source_code.md \ + ../include diff --git a/include/libvirtualhid/libvirtualhid.hpp b/include/libvirtualhid/libvirtualhid.hpp index a267386..71ad969 100644 --- a/include/libvirtualhid/libvirtualhid.hpp +++ b/include/libvirtualhid/libvirtualhid.hpp @@ -1,5 +1,10 @@ #pragma once +/** + * @file libvirtualhid/libvirtualhid.hpp + * @brief Aggregate include for the libvirtualhid public C++ API. + */ + #include #include #include diff --git a/include/libvirtualhid/profiles.hpp b/include/libvirtualhid/profiles.hpp index e17002d..8ad4c5b 100644 --- a/include/libvirtualhid/profiles.hpp +++ b/include/libvirtualhid/profiles.hpp @@ -7,14 +7,61 @@ namespace lvh::profiles { +/** + * @brief Create the generic HID gamepad profile. + * + * @return Generic gamepad device profile. + */ DeviceProfile generic_gamepad(); + +/** + * @brief Create the Xbox 360-compatible gamepad profile. + * + * @return Xbox 360-compatible device profile. + */ DeviceProfile xbox_360(); + +/** + * @brief Create the Xbox One-compatible gamepad profile. + * + * @return Xbox One-compatible device profile. + */ DeviceProfile xbox_one(); + +/** + * @brief Create the Xbox Series-compatible gamepad profile. + * + * @return Xbox Series-compatible device profile. + */ DeviceProfile xbox_series(); + +/** + * @brief Create the PlayStation DualSense-compatible gamepad profile. + * + * @return DualSense-compatible device profile. + */ DeviceProfile dualsense(); + +/** + * @brief Create the Nintendo Switch Pro-compatible gamepad profile. + * + * @return Switch Pro-compatible device profile. + */ DeviceProfile switch_pro(); +/** + * @brief Look up a built-in gamepad profile by kind. + * + * @param kind Built-in gamepad profile kind. + * @return Matching profile, or `std::nullopt` when the kind is unknown. + */ std::optional gamepad_profile(GamepadProfileKind kind); + +/** + * @brief Get every built-in gamepad profile. + * + * @return Built-in gamepad profiles. + */ std::vector built_in_gamepad_profiles(); } // namespace lvh::profiles diff --git a/include/libvirtualhid/report.hpp b/include/libvirtualhid/report.hpp index 2b9129b..c3642e0 100644 --- a/include/libvirtualhid/report.hpp +++ b/include/libvirtualhid/report.hpp @@ -7,12 +7,61 @@ namespace lvh::reports { +/** + * @brief Clamp a stick axis value to the normalized range. + * + * @param value Axis value. + * @return Clamped axis value in the inclusive range `[-1.0, 1.0]`. + */ float clamp_axis(float value); + +/** + * @brief Clamp a trigger value to the normalized range. + * + * @param value Trigger value. + * @return Clamped trigger value in the inclusive range `[0.0, 1.0]`. + */ float clamp_trigger(float value); + +/** + * @brief Convert a normalized axis value to a signed HID axis value. + * + * @param value Axis value in the inclusive range `[-1.0, 1.0]`. + * @return Signed 16-bit HID axis value. + */ std::int16_t normalize_axis(float value); + +/** + * @brief Convert a normalized trigger value to an unsigned HID trigger value. + * + * @param value Trigger value in the inclusive range `[0.0, 1.0]`. + * @return Unsigned 8-bit HID trigger value. + */ std::uint8_t normalize_trigger(float value); + +/** + * @brief Normalize all scalar fields in a gamepad state. + * + * @param state Gamepad state to normalize. + * @return Normalized gamepad state. + */ GamepadState normalize_state(const GamepadState& state); + +/** + * @brief Convert directional pad buttons to a HID hat switch value. + * + * @param buttons Button set containing directional pad state. + * @return HID hat switch value, or `8` for neutral. + */ std::uint8_t hat_from_buttons(const ButtonSet& buttons); + +/** + * @brief Pack a gamepad state into the profile's common input report format. + * + * @param profile Device profile used for report identity and size. + * @param state Gamepad state to pack. + * @return Packed input report bytes. + */ std::vector pack_input_report(const DeviceProfile& profile, const GamepadState& state); } // namespace lvh::reports diff --git a/include/libvirtualhid/runtime.hpp b/include/libvirtualhid/runtime.hpp index a438c80..7b29ef1 100644 --- a/include/libvirtualhid/runtime.hpp +++ b/include/libvirtualhid/runtime.hpp @@ -13,36 +13,151 @@ struct GamepadDevice; class RuntimeState; } // namespace detail +/** + * @brief Common interface for virtual device handles. + */ class VirtualDevice { public: + /** + * @brief Destroy the virtual device handle. + */ virtual ~VirtualDevice() = default; + /** + * @brief Get the device identifier assigned by the runtime. + * + * @return Device identifier. + */ virtual DeviceId device_id() const = 0; + + /** + * @brief Get the profile used to create this device. + * + * @return Device profile. + */ virtual const DeviceProfile& profile() const = 0; + + /** + * @brief Check whether the device is open. + * + * @return `true` when the device can accept operations. + */ virtual bool is_open() const = 0; + + /** + * @brief Close the virtual device. + * + * @return Close operation status. + */ virtual Status close() = 0; }; +/** + * @brief Virtual gamepad device handle. + */ class Gamepad final: public VirtualDevice { public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ Gamepad(const Gamepad&) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This gamepad handle. + */ Gamepad& operator=(const Gamepad&) = delete; - Gamepad(Gamepad&&) noexcept; - Gamepad& operator=(Gamepad&&) noexcept; + + /** + * @brief Move construct a gamepad handle. + * + * @param other Handle to move from. + */ + Gamepad(Gamepad&& other) noexcept; + + /** + * @brief Move assign a gamepad handle. + * + * @param other Handle to move from. + * @return This gamepad handle. + */ + Gamepad& operator=(Gamepad&& other) noexcept; + + /** + * @brief Destroy the gamepad handle. + */ ~Gamepad() override; + /** + * @copydoc VirtualDevice::device_id + */ DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ const DeviceProfile& profile() const override; + + /** + * @brief Get the metadata supplied when the gamepad was created. + * + * @return Gamepad metadata. + */ const GamepadMetadata& metadata() const; + + /** + * @copydoc VirtualDevice::is_open + */ bool is_open() const override; + + /** + * @copydoc VirtualDevice::close + */ Status close() override; + /** + * @brief Submit the latest gamepad input state. + * + * @param state Gamepad input state. + * @return Submit operation status. + */ Status submit(const GamepadState& state); + + /** + * @brief Register a callback for backend output events. + * + * @param callback Output callback. Passing an empty callback clears it. + */ void set_output_callback(OutputCallback callback); + + /** + * @brief Dispatch an output event to the registered callback. + * + * @param output Output event. + * @return Dispatch operation status. + */ Status dispatch_output(const GamepadOutput& output); + /** + * @brief Get the most recently submitted gamepad state. + * + * @return Last submitted state. + */ GamepadState last_submitted_state() const; + + /** + * @brief Get the most recently packed input report. + * + * @return Last input report bytes. + */ std::vector last_input_report() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ std::size_t submit_count() const; private: @@ -53,30 +168,115 @@ class Gamepad final: public VirtualDevice { std::shared_ptr device_; }; +/** + * @brief Result returned by gamepad creation. + */ struct GamepadCreationResult { + /** + * @brief Creation status. + */ Status status; + + /** + * @brief Created gamepad handle when creation succeeds. + */ std::unique_ptr gamepad; + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ explicit operator bool() const { return status.ok() && gamepad != nullptr; } }; +/** + * @brief Runtime that owns backend state and creates virtual devices. + */ class Runtime final { public: + /** + * @brief Copy construction is disabled because the runtime owns backend state. + */ Runtime(const Runtime&) = delete; + + /** + * @brief Copy assignment is disabled because the runtime owns backend state. + * + * @return This runtime. + */ Runtime& operator=(const Runtime&) = delete; - Runtime(Runtime&&) noexcept; - Runtime& operator=(Runtime&&) noexcept; + + /** + * @brief Move construct a runtime. + * + * @param other Runtime to move from. + */ + Runtime(Runtime&& other) noexcept; + + /** + * @brief Move assign a runtime. + * + * @param other Runtime to move from. + * @return This runtime. + */ + Runtime& operator=(Runtime&& other) noexcept; + + /** + * @brief Destroy the runtime and close any remaining devices. + */ ~Runtime(); + /** + * @brief Create a runtime with the requested options. + * + * @param options Runtime creation options. + * @return Runtime instance. + */ static std::unique_ptr create(RuntimeOptions options = {}); + /** + * @brief Get capabilities for the selected backend. + * + * @return Backend capabilities. + */ const BackendCapabilities& capabilities() const; + + /** + * @brief Get the backend kind used by this runtime. + * + * @return Backend kind. + */ BackendKind backend_kind() const; + + /** + * @brief Create a gamepad from a profile. + * + * @param profile Device profile. + * @return Gamepad creation result. + */ GamepadCreationResult create_gamepad(const DeviceProfile& profile); + + /** + * @brief Create a gamepad from full creation options. + * + * @param options Gamepad creation options. + * @return Gamepad creation result. + */ GamepadCreationResult create_gamepad(const CreateGamepadOptions& options); + + /** + * @brief Get the number of open devices owned by the runtime. + * + * @return Active device count. + */ std::size_t active_device_count() const; + + /** + * @brief Close every device owned by the runtime. + */ void close_all(); private: diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index 07b3f1b..3be2069 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -1,33 +1,86 @@ #pragma once +#include #include #include #include #include +/** + * @brief Public libvirtualhid API namespace. + */ namespace lvh { +/** + * @brief Stable identifier assigned to a virtual device instance. + */ using DeviceId = std::uint64_t; +/** + * @brief Error categories returned by libvirtualhid operations. + */ enum class ErrorCode { - ok, - invalid_argument, - backend_unavailable, - device_closed, - unsupported_profile, - backend_failure, + ok, ///< Operation completed successfully. + invalid_argument, ///< Caller supplied invalid input. + 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. + backend_failure, ///< Backend-specific operation failed. }; +/** + * @brief Result status with an error category and human-readable message. + */ class Status { public: + /** + * @brief Construct a successful status. + */ Status(); + + /** + * @brief Construct a status with an explicit error code and message. + * + * @param code Error category. + * @param message Human-readable status message. + */ Status(ErrorCode code, std::string message); + /** + * @brief Create a successful status. + * + * @return Successful status object. + */ static Status success(); + + /** + * @brief Create a failing status. + * + * @param code Error category. + * @param message Human-readable failure message. + * @return Failing status object. + */ static Status failure(ErrorCode code, std::string message); + /** + * @brief Check whether the operation succeeded. + * + * @return `true` when the status is successful. + */ bool ok() const; + + /** + * @brief Get the status error category. + * + * @return Error category. + */ ErrorCode code() const; + + /** + * @brief Get the human-readable status message. + * + * @return Status message. + */ const std::string& message() const; private: @@ -35,155 +88,433 @@ class Status { std::string message_; }; +/** + * @brief Backend implementation selection. + */ enum class BackendKind { - fake, - platform_default, + fake, ///< In-memory backend for tests and API validation. + platform_default, ///< Native backend for the current platform. }; +/** + * @brief Runtime creation options. + */ struct RuntimeOptions { + /** + * @brief Backend implementation requested by the caller. + */ BackendKind backend = BackendKind::fake; }; +/** + * @brief Feature set exposed by the selected backend. + */ struct BackendCapabilities { + /** + * @brief Human-readable backend name. + */ std::string backend_name; + + /** + * @brief Whether the backend can create virtual HID devices. + */ bool supports_virtual_hid = false; + + /** + * @brief Whether the backend can create gamepad devices. + */ bool supports_gamepad = false; + + /** + * @brief Whether the backend can create keyboard devices. + */ bool supports_keyboard = false; + + /** + * @brief Whether the backend can create mouse devices. + */ bool supports_mouse = false; + + /** + * @brief Whether the backend can deliver output reports to callers. + */ bool supports_output_reports = false; + + /** + * @brief Whether the backend can fall back to X11 XTest input. + */ bool supports_xtest_fallback = false; + + /** + * @brief Whether the backend requires an installed driver package. + */ bool requires_installed_driver = false; }; +/** + * @brief Device categories supported by the public profile model. + */ enum class DeviceType { - gamepad, - keyboard, - mouse, + gamepad, ///< Game controller device. + keyboard, ///< Keyboard device. + mouse, ///< Mouse or pointer device. }; +/** + * @brief Transport bus identity advertised by a device profile. + */ enum class BusType { - unknown, - usb, - bluetooth, + unknown, ///< Bus is unknown or not meaningful for the backend. + usb, ///< USB-style device identity. + bluetooth, ///< Bluetooth-style device identity. }; +/** + * @brief Built-in gamepad profile identifiers. + */ enum class GamepadProfileKind { - generic, - xbox_360, - xbox_one, - xbox_series, - dualsense, - switch_pro, + generic, ///< Generic HID gamepad profile. + xbox_360, ///< Xbox 360-compatible profile. + xbox_one, ///< Xbox One-compatible profile. + xbox_series, ///< Xbox Series-compatible profile. + dualsense, ///< PlayStation DualSense-compatible profile. + switch_pro, ///< Nintendo Switch Pro-compatible profile. }; +/** + * @brief Optional behavior advertised by a gamepad profile. + */ struct GamepadProfileCapabilities { + /** + * @brief Whether the profile supports rumble output. + */ bool supports_rumble = false; + + /** + * @brief Whether the profile exposes motion sensors. + */ bool supports_motion = false; + + /** + * @brief Whether the profile exposes touchpad input. + */ bool supports_touchpad = false; + + /** + * @brief Whether the profile supports an RGB LED output. + */ bool supports_rgb_led = false; + + /** + * @brief Whether the profile supports battery state. + */ bool supports_battery = false; + + /** + * @brief Whether the profile supports adaptive trigger output. + */ bool supports_adaptive_triggers = false; }; +/** + * @brief Descriptor and identity data used to create a virtual device. + */ struct DeviceProfile { + /** + * @brief Device category for this profile. + */ DeviceType device_type = DeviceType::gamepad; + + /** + * @brief Built-in gamepad profile identifier. + */ GamepadProfileKind gamepad_kind = GamepadProfileKind::generic; + + /** + * @brief Transport bus identity advertised by the profile. + */ BusType bus_type = BusType::usb; + + /** + * @brief USB-style vendor identifier. + */ std::uint16_t vendor_id = 0; + + /** + * @brief USB-style product identifier. + */ std::uint16_t product_id = 0; + + /** + * @brief Device version number. + */ std::uint16_t version = 0; + + /** + * @brief Primary input report identifier. + */ std::uint8_t report_id = 1; + + /** + * @brief Expected packed input report size in bytes. + */ std::size_t input_report_size = 0; + + /** + * @brief Human-readable device name. + */ std::string name; + + /** + * @brief Human-readable device manufacturer. + */ std::string manufacturer; + + /** + * @brief Profile feature flags. + */ GamepadProfileCapabilities capabilities; + + /** + * @brief HID report descriptor bytes. + */ std::vector report_descriptor; }; +/** + * @brief Controller family reported by a streaming client. + */ enum class ClientControllerType { - unknown, - xbox, - playstation, - nintendo, + unknown, ///< Controller family is unknown. + xbox, ///< Xbox-style client controller. + playstation, ///< PlayStation-style client controller. + nintendo, ///< Nintendo-style client controller. }; +/** + * @brief Consumer-provided metadata for a gamepad device. + */ struct GamepadMetadata { + /** + * @brief Stable index across all connected controllers, or `-1` if unset. + */ int global_index = -1; + + /** + * @brief Stable index within the client session, or `-1` if unset. + */ int client_relative_index = -1; + + /** + * @brief Controller family reported by the client. + */ ClientControllerType client_type = ClientControllerType::unknown; + + /** + * @brief Whether the client reports motion sensor capability. + */ bool has_motion_sensors = false; + + /** + * @brief Whether the client reports touchpad capability. + */ bool has_touchpad = false; + + /** + * @brief Whether the client reports RGB LED capability. + */ bool has_rgb_led = false; + + /** + * @brief Whether the client reports battery state capability. + */ bool has_battery = false; + + /** + * @brief Consumer-defined stable identity string. + */ std::string stable_id; }; +/** + * @brief Full gamepad creation request. + */ struct CreateGamepadOptions { + /** + * @brief Device profile to instantiate. + */ DeviceProfile profile; + + /** + * @brief Consumer metadata associated with the device. + */ GamepadMetadata metadata; }; +/** + * @brief Logical gamepad buttons accepted by the common gamepad state model. + */ enum class GamepadButton: std::uint8_t { - a = 0, - b, - x, - y, - back, - start, - guide, - left_stick, - right_stick, - left_shoulder, - right_shoulder, - dpad_up, - dpad_down, - dpad_left, - dpad_right, - misc1, + a = 0, ///< South face button. + b, ///< East face button. + x, ///< West face button. + y, ///< North face button. + back, ///< Back, select, or share button. + start, ///< Start or options button. + guide, ///< System guide button. + left_stick, ///< Left stick press. + right_stick, ///< Right stick press. + left_shoulder, ///< Left shoulder button. + right_shoulder, ///< Right shoulder button. + dpad_up, ///< Directional pad up. + dpad_down, ///< Directional pad down. + dpad_left, ///< Directional pad left. + dpad_right, ///< Directional pad right. + misc1, ///< Profile-specific miscellaneous button. }; +/** + * @brief Compact set of pressed gamepad buttons. + */ class ButtonSet { public: + /** + * @brief Set or clear a button. + * + * @param button Button to update. + * @param pressed Whether the button is pressed. + */ void set(GamepadButton button, bool pressed = true); + + /** + * @brief Clear a button. + * + * @param button Button to clear. + */ void reset(GamepadButton button); + + /** + * @brief Clear all buttons. + */ void clear(); + + /** + * @brief Check whether a button is pressed. + * + * @param button Button to test. + * @return `true` when the button is pressed. + */ bool test(GamepadButton button) const; + + /** + * @brief Get the raw bitset value. + * + * @return Raw button bits. + */ std::uint32_t raw_bits() const; private: std::uint32_t bits_ = 0; }; +/** + * @brief Normalized two-axis stick state. + */ struct Stick { + /** + * @brief Horizontal axis in the inclusive range `[-1.0, 1.0]`. + */ float x = 0.0F; + + /** + * @brief Vertical axis in the inclusive range `[-1.0, 1.0]`. + */ float y = 0.0F; }; +/** + * @brief Common gamepad input state accepted by libvirtualhid. + */ struct GamepadState { + /** + * @brief Pressed button set. + */ ButtonSet buttons; + + /** + * @brief Left stick state. + */ Stick left_stick; + + /** + * @brief Right stick state. + */ Stick right_stick; + + /** + * @brief Left trigger value in the inclusive range `[0.0, 1.0]`. + */ float left_trigger = 0.0F; + + /** + * @brief Right trigger value in the inclusive range `[0.0, 1.0]`. + */ float right_trigger = 0.0F; }; +/** + * @brief Output report categories delivered by a gamepad backend. + */ enum class GamepadOutputKind { - rumble, - rgb_led, - adaptive_triggers, - raw_report, + rumble, ///< Rumble motor output. + rgb_led, ///< RGB LED color output. + adaptive_triggers, ///< Adaptive trigger output. + raw_report, ///< Raw output report bytes. }; +/** + * @brief Normalized gamepad output event delivered to the consumer. + */ struct GamepadOutput { + /** + * @brief Output event category. + */ GamepadOutputKind kind = GamepadOutputKind::raw_report; + + /** + * @brief Low-frequency rumble motor strength. + */ std::uint16_t low_frequency_rumble = 0; + + /** + * @brief High-frequency rumble motor strength. + */ std::uint16_t high_frequency_rumble = 0; + + /** + * @brief Red LED channel value. + */ std::uint8_t red = 0; + + /** + * @brief Green LED channel value. + */ std::uint8_t green = 0; + + /** + * @brief Blue LED channel value. + */ std::uint8_t blue = 0; + + /** + * @brief Raw output report payload. + */ std::vector raw_report; }; +/** + * @brief Callback invoked when a gamepad receives output from the backend. + */ using OutputCallback = std::function; } // namespace lvh diff --git a/third-party/doxyconfig b/third-party/doxyconfig new file mode 160000 index 0000000..4c94524 --- /dev/null +++ b/third-party/doxyconfig @@ -0,0 +1 @@ +Subproject commit 4c9452482bd01cb36764dc914d4537b278ad4218 From a29c6bd6ab91fea28ad226b6a7e972f9c2efeae1 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:05:14 -0400 Subject: [PATCH 07/28] Add .clang-format and lizardbyte common submodule Introduce a repository-wide .clang-format (centrally managed) to standardize C/C++ formatting, update .gitignore to ignore Python virtualenvs (.venv), and add the third-party/lizardbyte-common submodule (pointing to commit 0317edf, branch master). --- .clang-format | 116 ++++++++++++++++++++++++++++++++++ .gitignore | 3 + .gitmodules | 4 ++ third-party/lizardbyte-common | 1 + 4 files changed, 124 insertions(+) create mode 100644 .clang-format create mode 160000 third-party/lizardbyte-common diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..782d157 --- /dev/null +++ b/.clang-format @@ -0,0 +1,116 @@ +--- +# This file is centrally managed in https://github.com//.github/ +# Don't make changes to this file in this repo as they will be overwritten with changes made to the same file in +# the above-mentioned repo. + +# Generated from CLion C/C++ Code Style settings +BasedOnStyle: LLVM +AccessModifierOffset: -2 +AlignAfterOpenBracket: BlockIndent +AlignConsecutiveAssignments: None +AlignEscapedNewlines: DontAlign +AlignOperands: Align +AllowAllArgumentsOnNextLine: false +AllowAllConstructorInitializersOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: Empty +AllowShortCaseLabelsOnASingleLine: false +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: None +AllowShortLoopsOnASingleLine: true +AlignTrailingComments: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: MultiLine +BinPackArguments: false +BinPackParameters: false +BracedInitializerIndentWidth: 2 +BraceWrapping: + AfterCaseLabel: false + AfterClass: false + AfterControlStatement: Never + AfterEnum: false + AfterExternBlock: true + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterUnion: false + BeforeCatch: true + BeforeElse: true + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: true +BreakArrays: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Attach +BreakBeforeTernaryOperators: false +BreakConstructorInitializers: AfterColon +BreakInheritanceList: AfterColon +ColumnLimit: 0 +CompactNamespaces: false +ContinuationIndentWidth: 2 +Cpp11BracedListStyle: true +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: Always +ExperimentalAutoDetectBinPacking: true +FixNamespaceComments: true +IncludeBlocks: Regroup +IndentAccessModifiers: false +IndentCaseBlocks: true +IndentCaseLabels: true +IndentExternBlock: Indent +IndentGotoLabels: true +IndentPPDirectives: BeforeHash +IndentWidth: 2 +IndentWrappedFunctionNames: true +InsertBraces: true +InsertNewlineAtEOF: true +KeepEmptyLinesAtTheStartOfBlocks: false +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: All +ObjCBinPackProtocolList: Never +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PackConstructorInitializers: Never +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 1 +PenaltyBreakString: 1 +PenaltyBreakFirstLessLess: 0 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 100000000 +PointerAlignment: Right +ReferenceAlignment: Pointer +ReflowComments: true +RemoveBracesLLVM: false +RemoveSemicolon: false +SeparateDefinitionBlocks: Always +SortIncludes: CaseInsensitive +SortUsingDeclarations: Lexicographic +SpaceAfterCStyleCast: true +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: true +SpaceBeforeCtorInitializerColon: false +SpaceBeforeInheritanceColon: false +SpaceBeforeJsonColon: false +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: Never +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: false +SpacesInLineCommentPrefix: + Maximum: 3 + Minimum: 1 +SpacesInParentheses: false +SpacesInSquareBrackets: false +TabWidth: 2 +UseTab: Never diff --git a/.gitignore b/.gitignore index 1ee74c6..c1eca37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,9 @@ # JetBrains IDEs .idea/ +# Python +.venv/ + # CMake build/ cmake-build-*/ diff --git a/.gitmodules b/.gitmodules index 4e26a7d..0c946a7 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,7 @@ [submodule "third-party/googletest"] path = third-party/googletest url = https://github.com/google/googletest.git +[submodule "third-party/lizardbyte-common"] + path = third-party/lizardbyte-common + url = https://github.com/LizardByte/lizardbyte-common.git + branch = master diff --git a/third-party/lizardbyte-common b/third-party/lizardbyte-common new file mode 160000 index 0000000..0317edf --- /dev/null +++ b/third-party/lizardbyte-common @@ -0,0 +1 @@ +Subproject commit 0317edf2fe3d09cf668e8ff909a95152ae4d1566 From 0cd2e860e0e65afb28e920b19e5678fc77c8baa6 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:07:39 -0400 Subject: [PATCH 08/28] Fix lint errors --- CMakeLists.txt | 77 ++--- examples/CMakeLists.txt | 6 +- src/CMakeLists.txt | 38 +-- src/core/profiles.cpp | 391 ++++++++++++++----------- src/core/report.cpp | 263 +++++++++-------- src/core/runtime.cpp | 407 +++++++++++++-------------- src/core/types.cpp | 84 +++--- tests/CMakeLists.txt | 20 +- tests/unit/test_profiles.cpp | 5 +- tests/unit/test_report.cpp | 3 +- tests/unit/test_runtime.cpp | 5 +- tests/unit/test_sunshine_adapter.cpp | 5 +- 12 files changed, 674 insertions(+), 630 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e5c6d23..7b59b3e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,12 +14,13 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(LIBVIRTUALHID_IS_TOP_LEVEL OFF) if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) - set(LIBVIRTUALHID_IS_TOP_LEVEL ON) + set(LIBVIRTUALHID_IS_TOP_LEVEL ON) endif() if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - message(STATUS "Setting build type to 'Release' as none was specified.") - set(CMAKE_BUILD_TYPE "Release" CACHE STRING "Choose the type of build." FORCE) + message(STATUS "Setting build type to 'Release' as none was specified.") + set(CMAKE_BUILD_TYPE "Release" CACHE STRING + "Choose the type of build." FORCE) endif() list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") @@ -37,22 +38,26 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) include(CMakePackageConfigHelpers) include(GNUInstallDirs) +# 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") - return() + if(NOT WIN32 OR NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + return() + endif() + + get_filename_component(lvh_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + foreach(lvh_runtime_dll IN ITEMS + libgcc_s_seh-1.dll + libstdc++-6.dll + libwinpthread-1.dll) + set(lvh_runtime_path "${lvh_compiler_dir}/${lvh_runtime_dll}") + if(EXISTS "${lvh_runtime_path}") + add_custom_command(TARGET "${target_name}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${lvh_runtime_path}" + "$" + COMMENT "Copying MinGW runtime ${lvh_runtime_dll}") endif() - - get_filename_component(_lvh_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) - foreach(_lvh_runtime_dll IN ITEMS libgcc_s_seh-1.dll libstdc++-6.dll libwinpthread-1.dll) - set(_lvh_runtime_path "${_lvh_compiler_dir}/${_lvh_runtime_dll}") - if(EXISTS "${_lvh_runtime_path}") - add_custom_command(TARGET "${target_name}" POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${_lvh_runtime_path}" - "$" - COMMENT "Copying ${_lvh_runtime_dll} to $") - endif() - endforeach() + endforeach() endfunction() # @@ -64,36 +69,36 @@ add_subdirectory(src) # Examples, tests, and docs are top-level only # if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) - if(BUILD_DOCS) - add_subdirectory(third-party/doxyconfig docs) - endif() - - if(BUILD_EXAMPLES) - add_subdirectory(examples) - endif() - - if(BUILD_TESTS) - enable_testing() - add_subdirectory(tests) - endif() + if(BUILD_DOCS) + add_subdirectory(third-party/doxyconfig docs) + endif() + + if(BUILD_EXAMPLES) + add_subdirectory(examples) + endif() + + if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() endif() # # Package config # configure_package_config_file( - "${CMAKE_CURRENT_SOURCE_DIR}/cmake/libvirtualhid-config.cmake.in" - "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config.cmake" - INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid" + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/libvirtualhid-config.cmake.in" + "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config.cmake" + INSTALL_DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid" ) write_basic_package_version_file( - "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config-version.cmake" - VERSION ${PROJECT_VERSION} - COMPATIBILITY SameMajorVersion + "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config-version.cmake" + VERSION ${PROJECT_VERSION} + COMPATIBILITY SameMajorVersion ) install(FILES "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config.cmake" "${CMAKE_CURRENT_BINARY_DIR}/libvirtualhid-config-version.cmake" - DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid") + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid") diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index a132894..34199cb 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,8 +1,8 @@ add_executable(sunshine_gamepad_adapter - "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_gamepad_adapter.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_gamepad_adapter.cpp") target_link_libraries(sunshine_gamepad_adapter - PRIVATE - libvirtualhid::libvirtualhid) + PRIVATE + libvirtualhid::libvirtualhid) libvirtualhid_copy_mingw_runtime(sunshine_gamepad_adapter) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e88468c..5ec279c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,37 +2,37 @@ add_library(${PROJECT_NAME} STATIC) add_library(libvirtualhid::libvirtualhid ALIAS ${PROJECT_NAME}) target_sources(${PROJECT_NAME} - PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/core/profiles.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/core/report.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/core/runtime.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/core/types.cpp") + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/core/profiles.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/report.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/runtime.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/core/types.cpp") target_include_directories(${PROJECT_NAME} - PUBLIC - $ - $) + PUBLIC + $ + $) target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_20) set_target_properties(${PROJECT_NAME} PROPERTIES - EXPORT_NAME libvirtualhid - OUTPUT_NAME virtualhid) + EXPORT_NAME libvirtualhid + OUTPUT_NAME virtualhid) if(MSVC) - target_compile_options(${PROJECT_NAME} PRIVATE /W4) + target_compile_options(${PROJECT_NAME} PRIVATE /W4) else() - target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) + target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) endif() install(TARGETS ${PROJECT_NAME} - EXPORT libvirtualhid-targets - ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" - LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" - RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") + EXPORT libvirtualhid-targets + ARCHIVE DESTINATION "${CMAKE_INSTALL_LIBDIR}" + LIBRARY DESTINATION "${CMAKE_INSTALL_LIBDIR}" + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") install(DIRECTORY "${PROJECT_SOURCE_DIR}/include/" - DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") install(EXPORT libvirtualhid-targets - NAMESPACE libvirtualhid:: - DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid") + NAMESPACE libvirtualhid:: + DESTINATION "${CMAKE_INSTALL_LIBDIR}/cmake/libvirtualhid") diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index b27e6d1..eba0b0b 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -1,189 +1,236 @@ -#include - #include +#include namespace lvh::profiles { -namespace { - -constexpr std::size_t common_report_size = 14; + namespace { + + constexpr std::size_t common_report_size = 14; + + std::vector make_gamepad_report_descriptor(std::uint8_t report_id) { + return { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x05, // Usage (Game Pad) + 0xA1, + 0x01, // Collection (Application) + 0x85, + report_id, // Report ID + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (Button 1) + 0x29, + 0x0C, // Usage Maximum (Button 12) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x0C, // Report Count (12) + 0x81, + 0x02, // Input (Data,Var,Abs) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x04, // Report Count (4) + 0x81, + 0x03, // Input (Const,Var,Abs) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat switch) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x07, // Logical Maximum (7) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x65, + 0x14, // Unit (Degrees) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data,Var,Abs,Null) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Const,Var,Abs) + 0x16, + 0x00, + 0x80, // Logical Minimum (-32768) + 0x26, + 0xFF, + 0x7F, // Logical Maximum (32767) + 0x75, + 0x10, // Report Size (16) + 0x95, + 0x04, // Report Count (4) + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x81, + 0x02, // Input (Data,Var,Abs) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x02, // Report Count (2) + 0x09, + 0x32, // Usage (Z) + 0x09, + 0x35, // Usage (Rz) + 0x81, + 0x02, // Input (Data,Var,Abs) + 0xC0, // End Collection + }; + } -std::vector make_gamepad_report_descriptor(std::uint8_t report_id) { - return { - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x05, // Usage (Game Pad) - 0xA1, 0x01, // Collection (Application) - 0x85, report_id, // Report ID - 0x05, 0x09, // Usage Page (Button) - 0x19, 0x01, // Usage Minimum (Button 1) - 0x29, 0x0C, // Usage Maximum (Button 12) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x01, // Logical Maximum (1) - 0x75, 0x01, // Report Size (1) - 0x95, 0x0C, // Report Count (12) - 0x81, 0x02, // Input (Data,Var,Abs) - 0x75, 0x01, // Report Size (1) - 0x95, 0x04, // Report Count (4) - 0x81, 0x03, // Input (Const,Var,Abs) - 0x05, 0x01, // Usage Page (Generic Desktop) - 0x09, 0x39, // Usage (Hat switch) - 0x15, 0x00, // Logical Minimum (0) - 0x25, 0x07, // Logical Maximum (7) - 0x35, 0x00, // Physical Minimum (0) - 0x46, 0x3B, 0x01, // Physical Maximum (315) - 0x65, 0x14, // Unit (Degrees) - 0x75, 0x04, // Report Size (4) - 0x95, 0x01, // Report Count (1) - 0x81, 0x42, // Input (Data,Var,Abs,Null) - 0x75, 0x04, // Report Size (4) - 0x95, 0x01, // Report Count (1) - 0x81, 0x03, // Input (Const,Var,Abs) - 0x16, 0x00, 0x80, // Logical Minimum (-32768) - 0x26, 0xFF, 0x7F, // Logical Maximum (32767) - 0x75, 0x10, // Report Size (16) - 0x95, 0x04, // Report Count (4) - 0x09, 0x30, // Usage (X) - 0x09, 0x31, // Usage (Y) - 0x09, 0x33, // Usage (Rx) - 0x09, 0x34, // Usage (Ry) - 0x81, 0x02, // Input (Data,Var,Abs) - 0x15, 0x00, // Logical Minimum (0) - 0x26, 0xFF, 0x00, // Logical Maximum (255) - 0x75, 0x08, // Report Size (8) - 0x95, 0x02, // Report Count (2) - 0x09, 0x32, // Usage (Z) - 0x09, 0x35, // Usage (Rz) - 0x81, 0x02, // Input (Data,Var,Abs) - 0xC0, // End Collection - }; -} + DeviceProfile make_gamepad_profile( + GamepadProfileKind kind, + std::string name, + std::uint16_t vendor_id, + std::uint16_t product_id, + std::uint16_t version, + GamepadProfileCapabilities capabilities + ) { + DeviceProfile profile; + profile.device_type = DeviceType::gamepad; + profile.gamepad_kind = kind; + profile.bus_type = BusType::usb; + profile.vendor_id = vendor_id; + profile.product_id = product_id; + profile.version = version; + profile.report_id = 1; + profile.input_report_size = common_report_size; + profile.name = std::move(name); + profile.manufacturer = "LizardByte"; + profile.capabilities = capabilities; + profile.report_descriptor = make_gamepad_report_descriptor(profile.report_id); + return profile; + } -DeviceProfile make_gamepad_profile( - GamepadProfileKind kind, - std::string name, - std::uint16_t vendor_id, - std::uint16_t product_id, - std::uint16_t version, - GamepadProfileCapabilities capabilities -) { - DeviceProfile profile; - profile.device_type = DeviceType::gamepad; - profile.gamepad_kind = kind; - profile.bus_type = BusType::usb; - profile.vendor_id = vendor_id; - profile.product_id = product_id; - profile.version = version; - profile.report_id = 1; - profile.input_report_size = common_report_size; - profile.name = std::move(name); - profile.manufacturer = "LizardByte"; - profile.capabilities = capabilities; - profile.report_descriptor = make_gamepad_report_descriptor(profile.report_id); - return profile; -} + } // namespace + + DeviceProfile generic_gamepad() { + return make_gamepad_profile( + GamepadProfileKind::generic, + "libvirtualhid Generic Gamepad", + 0x1209, + 0x0001, + 0x0001, + {} + ); + } -} // namespace + DeviceProfile xbox_360() { + return make_gamepad_profile( + GamepadProfileKind::xbox_360, + "Microsoft X-Box 360 pad", + 0x045E, + 0x028E, + 0x0114, + {.supports_rumble = true} + ); + } -DeviceProfile generic_gamepad() { - return make_gamepad_profile( - GamepadProfileKind::generic, - "libvirtualhid Generic Gamepad", - 0x1209, - 0x0001, - 0x0001, - {} - ); -} + DeviceProfile xbox_one() { + return make_gamepad_profile( + GamepadProfileKind::xbox_one, + "Xbox One Controller", + 0x045E, + 0x02EA, + 0x0408, + {.supports_rumble = true} + ); + } -DeviceProfile xbox_360() { - return make_gamepad_profile( - GamepadProfileKind::xbox_360, - "Microsoft X-Box 360 pad", - 0x045E, - 0x028E, - 0x0114, - {.supports_rumble = true} - ); -} + DeviceProfile xbox_series() { + return make_gamepad_profile( + GamepadProfileKind::xbox_series, + "Xbox Wireless Controller", + 0x045E, + 0x0B12, + 0x0500, + {.supports_rumble = true, .supports_battery = true} + ); + } -DeviceProfile xbox_one() { - return make_gamepad_profile( - GamepadProfileKind::xbox_one, - "Xbox One Controller", - 0x045E, - 0x02EA, - 0x0408, - {.supports_rumble = true} - ); -} + DeviceProfile dualsense() { + return make_gamepad_profile( + GamepadProfileKind::dualsense, + "DualSense Wireless Controller", + 0x054C, + 0x0CE6, + 0x8111, + { + .supports_rumble = true, + .supports_motion = true, + .supports_touchpad = true, + .supports_rgb_led = true, + .supports_battery = true, + .supports_adaptive_triggers = true, + } + ); + } -DeviceProfile xbox_series() { - return make_gamepad_profile( - GamepadProfileKind::xbox_series, - "Xbox Wireless Controller", - 0x045E, - 0x0B12, - 0x0500, - {.supports_rumble = true, .supports_battery = true} - ); -} + DeviceProfile switch_pro() { + return make_gamepad_profile( + GamepadProfileKind::switch_pro, + "Nintendo Switch Pro Controller", + 0x057E, + 0x2009, + 0x8111, + {.supports_rumble = true, .supports_motion = true, .supports_battery = true} + ); + } -DeviceProfile dualsense() { - return make_gamepad_profile( - GamepadProfileKind::dualsense, - "DualSense Wireless Controller", - 0x054C, - 0x0CE6, - 0x8111, - { - .supports_rumble = true, - .supports_motion = true, - .supports_touchpad = true, - .supports_rgb_led = true, - .supports_battery = true, - .supports_adaptive_triggers = true, + std::optional gamepad_profile(GamepadProfileKind kind) { + switch (kind) { + case GamepadProfileKind::generic: + return generic_gamepad(); + case GamepadProfileKind::xbox_360: + return xbox_360(); + case GamepadProfileKind::xbox_one: + return xbox_one(); + case GamepadProfileKind::xbox_series: + return xbox_series(); + case GamepadProfileKind::dualsense: + return dualsense(); + case GamepadProfileKind::switch_pro: + return switch_pro(); } - ); -} -DeviceProfile switch_pro() { - return make_gamepad_profile( - GamepadProfileKind::switch_pro, - "Nintendo Switch Pro Controller", - 0x057E, - 0x2009, - 0x8111, - {.supports_rumble = true, .supports_motion = true, .supports_battery = true} - ); -} - -std::optional gamepad_profile(GamepadProfileKind kind) { - switch(kind) { - case GamepadProfileKind::generic: - return generic_gamepad(); - case GamepadProfileKind::xbox_360: - return xbox_360(); - case GamepadProfileKind::xbox_one: - return xbox_one(); - case GamepadProfileKind::xbox_series: - return xbox_series(); - case GamepadProfileKind::dualsense: - return dualsense(); - case GamepadProfileKind::switch_pro: - return switch_pro(); + return std::nullopt; } - return std::nullopt; -} - -std::vector built_in_gamepad_profiles() { - return { - generic_gamepad(), - xbox_360(), - xbox_one(), - xbox_series(), - dualsense(), - switch_pro(), - }; -} + std::vector built_in_gamepad_profiles() { + return { + generic_gamepad(), + xbox_360(), + xbox_one(), + xbox_series(), + dualsense(), + switch_pro(), + }; + } } // namespace lvh::profiles diff --git a/src/core/report.cpp b/src/core/report.cpp index 34794db..4f2eab2 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -1,159 +1,158 @@ -#include - #include #include +#include namespace lvh::reports { -namespace { + namespace { -constexpr std::uint8_t neutral_hat = 8; + constexpr std::uint8_t neutral_hat = 8; -void append_u16(std::vector& report, std::uint16_t value) { - report.push_back(static_cast(value & 0xFFU)); - report.push_back(static_cast((value >> 8U) & 0xFFU)); -} - -void append_i16(std::vector& report, std::int16_t value) { - append_u16(report, static_cast(value)); -} - -std::uint16_t report_button_bits(const ButtonSet& buttons) { - std::uint16_t bits = 0; + void append_u16(std::vector &report, std::uint16_t value) { + report.push_back(static_cast(value & 0xFFU)); + report.push_back(static_cast((value >> 8U) & 0xFFU)); + } - const auto add = [&bits, &buttons](GamepadButton button, std::uint16_t bit) { - if(buttons.test(button)) { - bits |= bit; + void append_i16(std::vector &report, std::int16_t value) { + append_u16(report, static_cast(value)); } - }; - add(GamepadButton::a, 1U << 0U); - add(GamepadButton::b, 1U << 1U); - add(GamepadButton::x, 1U << 2U); - add(GamepadButton::y, 1U << 3U); - add(GamepadButton::back, 1U << 4U); - add(GamepadButton::start, 1U << 5U); - add(GamepadButton::guide, 1U << 6U); - add(GamepadButton::left_stick, 1U << 7U); - add(GamepadButton::right_stick, 1U << 8U); - add(GamepadButton::left_shoulder, 1U << 9U); - add(GamepadButton::right_shoulder, 1U << 10U); - add(GamepadButton::misc1, 1U << 11U); + std::uint16_t report_button_bits(const ButtonSet &buttons) { + std::uint16_t bits = 0; + + const auto add = [&bits, &buttons](GamepadButton button, std::uint16_t bit) { + if (buttons.test(button)) { + bits |= bit; + } + }; + + add(GamepadButton::a, 1U << 0U); + add(GamepadButton::b, 1U << 1U); + add(GamepadButton::x, 1U << 2U); + add(GamepadButton::y, 1U << 3U); + add(GamepadButton::back, 1U << 4U); + add(GamepadButton::start, 1U << 5U); + add(GamepadButton::guide, 1U << 6U); + add(GamepadButton::left_stick, 1U << 7U); + add(GamepadButton::right_stick, 1U << 8U); + add(GamepadButton::left_shoulder, 1U << 9U); + add(GamepadButton::right_shoulder, 1U << 10U); + add(GamepadButton::misc1, 1U << 11U); + + return bits; + } - return bits; -} + } // namespace -} // namespace + float clamp_axis(float value) { + if (std::isnan(value)) { + return 0.0F; + } -float clamp_axis(float value) { - if(std::isnan(value)) { - return 0.0F; + return std::clamp(value, -1.0F, 1.0F); } - return std::clamp(value, -1.0F, 1.0F); -} + float clamp_trigger(float value) { + if (std::isnan(value)) { + return 0.0F; + } -float clamp_trigger(float value) { - if(std::isnan(value)) { - return 0.0F; + return std::clamp(value, 0.0F, 1.0F); } - return std::clamp(value, 0.0F, 1.0F); -} - -std::int16_t normalize_axis(float value) { - const auto clamped = clamp_axis(value); - if(clamped <= -1.0F) { - return -32768; - } - if(clamped >= 1.0F) { - return 32767; - } - if(clamped < 0.0F) { - return static_cast(std::lround(clamped * 32768.0F)); - } - return static_cast(std::lround(clamped * 32767.0F)); -} - -std::uint8_t normalize_trigger(float value) { - return static_cast(std::lround(clamp_trigger(value) * 255.0F)); -} - -GamepadState normalize_state(const GamepadState& state) { - auto normalized = state; - normalized.left_stick.x = clamp_axis(state.left_stick.x); - normalized.left_stick.y = clamp_axis(state.left_stick.y); - normalized.right_stick.x = clamp_axis(state.right_stick.x); - normalized.right_stick.y = clamp_axis(state.right_stick.y); - normalized.left_trigger = clamp_trigger(state.left_trigger); - normalized.right_trigger = clamp_trigger(state.right_trigger); - return normalized; -} - -std::uint8_t hat_from_buttons(const ButtonSet& buttons) { - auto up = buttons.test(GamepadButton::dpad_up); - auto down = buttons.test(GamepadButton::dpad_down); - auto left = buttons.test(GamepadButton::dpad_left); - auto right = buttons.test(GamepadButton::dpad_right); - - if(up && down) { - up = false; - down = false; - } - if(left && right) { - left = false; - right = false; + std::int16_t normalize_axis(float value) { + const auto clamped = clamp_axis(value); + if (clamped <= -1.0F) { + return -32768; + } + if (clamped >= 1.0F) { + return 32767; + } + if (clamped < 0.0F) { + return static_cast(std::lround(clamped * 32768.0F)); + } + return static_cast(std::lround(clamped * 32767.0F)); } - if(up && right) { - return 1; - } - if(down && right) { - return 3; - } - if(down && left) { - return 5; - } - if(up && left) { - return 7; - } - if(up) { - return 0; + std::uint8_t normalize_trigger(float value) { + return static_cast(std::lround(clamp_trigger(value) * 255.0F)); } - if(right) { - return 2; - } - if(down) { - return 4; - } - if(left) { - return 6; + + GamepadState normalize_state(const GamepadState &state) { + auto normalized = state; + normalized.left_stick.x = clamp_axis(state.left_stick.x); + normalized.left_stick.y = clamp_axis(state.left_stick.y); + normalized.right_stick.x = clamp_axis(state.right_stick.x); + normalized.right_stick.y = clamp_axis(state.right_stick.y); + normalized.left_trigger = clamp_trigger(state.left_trigger); + normalized.right_trigger = clamp_trigger(state.right_trigger); + return normalized; } - return neutral_hat; -} + std::uint8_t hat_from_buttons(const ButtonSet &buttons) { + auto up = buttons.test(GamepadButton::dpad_up); + auto down = buttons.test(GamepadButton::dpad_down); + auto left = buttons.test(GamepadButton::dpad_left); + auto right = buttons.test(GamepadButton::dpad_right); + + if (up && down) { + up = false; + down = false; + } + if (left && right) { + left = false; + right = false; + } + + if (up && right) { + return 1; + } + if (down && right) { + return 3; + } + if (down && left) { + return 5; + } + if (up && left) { + return 7; + } + if (up) { + return 0; + } + if (right) { + return 2; + } + if (down) { + return 4; + } + if (left) { + return 6; + } -std::vector pack_input_report(const DeviceProfile& profile, const GamepadState& state) { - constexpr std::size_t common_report_size = 14; - if(profile.device_type != DeviceType::gamepad || profile.input_report_size < common_report_size) { - return {}; + return neutral_hat; } - const auto normalized = normalize_state(state); - - std::vector report; - report.reserve(common_report_size); - report.push_back(profile.report_id); - append_u16(report, report_button_bits(normalized.buttons)); - report.push_back(hat_from_buttons(normalized.buttons)); - append_i16(report, normalize_axis(normalized.left_stick.x)); - append_i16(report, normalize_axis(normalized.left_stick.y)); - append_i16(report, normalize_axis(normalized.right_stick.x)); - append_i16(report, normalize_axis(normalized.right_stick.y)); - report.push_back(normalize_trigger(normalized.left_trigger)); - report.push_back(normalize_trigger(normalized.right_trigger)); - - report.resize(profile.input_report_size, 0); - return report; -} + std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state) { + constexpr std::size_t common_report_size = 14; + if (profile.device_type != DeviceType::gamepad || profile.input_report_size < common_report_size) { + return {}; + } + + const auto normalized = normalize_state(state); + + std::vector report; + report.reserve(common_report_size); + report.push_back(profile.report_id); + append_u16(report, report_button_bits(normalized.buttons)); + report.push_back(hat_from_buttons(normalized.buttons)); + append_i16(report, normalize_axis(normalized.left_stick.x)); + append_i16(report, normalize_axis(normalized.left_stick.y)); + append_i16(report, normalize_axis(normalized.right_stick.x)); + append_i16(report, normalize_axis(normalized.right_stick.y)); + report.push_back(normalize_trigger(normalized.left_trigger)); + report.push_back(normalize_trigger(normalized.right_trigger)); + + report.resize(profile.input_report_size, 0); + return report; + } } // namespace lvh::reports diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 0e2bc1c..a8e42d9 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -1,246 +1,243 @@ -#include - -#include - #include +#include +#include #include #include namespace lvh::detail { -struct GamepadDevice { - explicit GamepadDevice(DeviceId device_id, CreateGamepadOptions create_options): - id {device_id}, - options {std::move(create_options)} {} - - DeviceId id; - CreateGamepadOptions options; - bool open = true; - GamepadState last_state; - std::vector last_report; - std::size_t submitted_reports = 0; - OutputCallback output_callback; - mutable std::mutex mutex; -}; - -class RuntimeState { -public: - explicit RuntimeState(RuntimeOptions runtime_options): - options {runtime_options}, - caps {make_capabilities(runtime_options.backend)} {} - - static BackendCapabilities make_capabilities(BackendKind kind) { - BackendCapabilities capabilities; - if(kind == BackendKind::fake) { - capabilities.backend_name = "fake"; - capabilities.supports_gamepad = true; - capabilities.supports_output_reports = true; - } - else { - capabilities.backend_name = "platform-default-unimplemented"; + struct GamepadDevice { + explicit GamepadDevice(DeviceId device_id, CreateGamepadOptions create_options): + id {device_id}, + options {std::move(create_options)} {} + + DeviceId id; + CreateGamepadOptions options; + bool open = true; + GamepadState last_state; + std::vector last_report; + std::size_t submitted_reports = 0; + OutputCallback output_callback; + mutable std::mutex mutex; + }; + + class RuntimeState { + public: + explicit RuntimeState(RuntimeOptions runtime_options): + options {runtime_options}, + caps {make_capabilities(runtime_options.backend)} {} + + static BackendCapabilities make_capabilities(BackendKind kind) { + BackendCapabilities capabilities; + if (kind == BackendKind::fake) { + capabilities.backend_name = "fake"; + capabilities.supports_gamepad = true; + capabilities.supports_output_reports = true; + } else { + capabilities.backend_name = "platform-default-unimplemented"; + } + return capabilities; } - return capabilities; - } - RuntimeOptions options; - BackendCapabilities caps; - DeviceId next_device_id = 1; - std::vector> gamepads; - mutable std::mutex mutex; -}; + RuntimeOptions options; + BackendCapabilities caps; + DeviceId next_device_id = 1; + std::vector> gamepads; + mutable std::mutex mutex; + }; } // namespace lvh::detail namespace lvh { -namespace { + namespace { + + Status validate_gamepad_options(const CreateGamepadOptions &options) { + if (options.profile.device_type != DeviceType::gamepad) { + return Status::failure(ErrorCode::unsupported_profile, "device profile is not a gamepad"); + } + if (options.profile.name.empty()) { + return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + if (options.profile.report_descriptor.empty()) { + return Status::failure(ErrorCode::invalid_argument, "device profile report descriptor must not be empty"); + } + if (options.profile.report_id == 0) { + return Status::failure(ErrorCode::invalid_argument, "device profile report id must not be zero"); + } + if (options.profile.input_report_size == 0) { + return Status::failure(ErrorCode::invalid_argument, "device profile input report size must not be zero"); + } -Status validate_gamepad_options(const CreateGamepadOptions& options) { - if(options.profile.device_type != DeviceType::gamepad) { - return Status::failure(ErrorCode::unsupported_profile, "device profile is not a gamepad"); + return Status::success(); + } + + template + auto with_device(const std::shared_ptr &device, Func &&func) { + std::lock_guard lock {device->mutex}; + return func(*device); + } + + } // namespace + + Gamepad::Gamepad(std::shared_ptr device): + device_ {std::move(device)} {} + + Gamepad::Gamepad(Gamepad &&) noexcept = default; + Gamepad &Gamepad::operator=(Gamepad &&) noexcept = default; + Gamepad::~Gamepad() = default; + + DeviceId Gamepad::device_id() const { + return device_->id; } - if(options.profile.name.empty()) { - return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + + const DeviceProfile &Gamepad::profile() const { + return device_->options.profile; } - if(options.profile.report_descriptor.empty()) { - return Status::failure(ErrorCode::invalid_argument, "device profile report descriptor must not be empty"); + + const GamepadMetadata &Gamepad::metadata() const { + return device_->options.metadata; } - if(options.profile.report_id == 0) { - return Status::failure(ErrorCode::invalid_argument, "device profile report id must not be zero"); + + bool Gamepad::is_open() const { + return with_device(device_, [](const auto &device) { + return device.open; + }); } - if(options.profile.input_report_size == 0) { - return Status::failure(ErrorCode::invalid_argument, "device profile input report size must not be zero"); + + Status Gamepad::close() { + return with_device(device_, [](auto &device) { + if (!device.open) { + return Status::success(); + } + device.open = false; + return Status::success(); + }); } - return Status::success(); -} + Status Gamepad::submit(const GamepadState &state) { + return with_device(device_, [&state](auto &device) { + if (!device.open) { + return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + } -template -auto with_device(const std::shared_ptr& device, Func&& func) { - std::lock_guard lock {device->mutex}; - return func(*device); -} + auto report = reports::pack_input_report(device.options.profile, state); + if (report.empty()) { + return Status::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); + } -} // namespace + device.last_state = reports::normalize_state(state); + device.last_report = std::move(report); + ++device.submitted_reports; + return Status::success(); + }); + } -Gamepad::Gamepad(std::shared_ptr device): - device_ {std::move(device)} {} + void Gamepad::set_output_callback(OutputCallback callback) { + with_device(device_, [&callback](auto &device) { + device.output_callback = std::move(callback); + return 0; + }); + } -Gamepad::Gamepad(Gamepad&&) noexcept = default; -Gamepad& Gamepad::operator=(Gamepad&&) noexcept = default; -Gamepad::~Gamepad() = default; + Status Gamepad::dispatch_output(const GamepadOutput &output) { + OutputCallback callback; + const auto status = with_device(device_, [&callback](auto &device) { + if (!device.open) { + return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + } + callback = device.output_callback; + return Status::success(); + }); -DeviceId Gamepad::device_id() const { - return device_->id; -} + if (!status.ok()) { + return status; + } + if (callback) { + callback(output); + } + return Status::success(); + } -const DeviceProfile& Gamepad::profile() const { - return device_->options.profile; -} + GamepadState Gamepad::last_submitted_state() const { + return with_device(device_, [](const auto &device) { + return device.last_state; + }); + } -const GamepadMetadata& Gamepad::metadata() const { - return device_->options.metadata; -} + std::vector Gamepad::last_input_report() const { + return with_device(device_, [](const auto &device) { + return device.last_report; + }); + } -bool Gamepad::is_open() const { - return with_device(device_, [](const auto& device) { - return device.open; - }); -} + std::size_t Gamepad::submit_count() const { + return with_device(device_, [](const auto &device) { + return device.submitted_reports; + }); + } -Status Gamepad::close() { - return with_device(device_, [](auto& device) { - if(!device.open) { - return Status::success(); - } - device.open = false; - return Status::success(); - }); -} + Runtime::Runtime(RuntimeOptions options): + state_ {std::make_shared(options)} {} -Status Gamepad::submit(const GamepadState& state) { - return with_device(device_, [&state](auto& device) { - if(!device.open) { - return Status::failure(ErrorCode::device_closed, "gamepad is closed"); - } + Runtime::Runtime(Runtime &&) noexcept = default; + Runtime &Runtime::operator=(Runtime &&) noexcept = default; + Runtime::~Runtime() = default; + + std::unique_ptr Runtime::create(RuntimeOptions options) { + return std::unique_ptr {new Runtime {options}}; + } + + const BackendCapabilities &Runtime::capabilities() const { + return state_->caps; + } + + BackendKind Runtime::backend_kind() const { + return state_->options.backend; + } - auto report = reports::pack_input_report(device.options.profile, state); - if(report.empty()) { - return Status::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); + GamepadCreationResult Runtime::create_gamepad(const DeviceProfile &profile) { + CreateGamepadOptions options; + options.profile = profile; + return create_gamepad(options); + } + + GamepadCreationResult Runtime::create_gamepad(const CreateGamepadOptions &options) { + if (state_->options.backend != BackendKind::fake) { + return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; } - device.last_state = reports::normalize_state(state); - device.last_report = std::move(report); - ++device.submitted_reports; - return Status::success(); - }); -} - -void Gamepad::set_output_callback(OutputCallback callback) { - with_device(device_, [&callback](auto& device) { - device.output_callback = std::move(callback); - return 0; - }); -} - -Status Gamepad::dispatch_output(const GamepadOutput& output) { - OutputCallback callback; - const auto status = with_device(device_, [&callback](auto& device) { - if(!device.open) { - return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + if (const auto validation = validate_gamepad_options(options); !validation.ok()) { + return {validation, nullptr}; } - callback = device.output_callback; - return Status::success(); - }); - - if(!status.ok()) { - return status; - } - if(callback) { - callback(output); - } - return Status::success(); -} - -GamepadState Gamepad::last_submitted_state() const { - return with_device(device_, [](const auto& device) { - return device.last_state; - }); -} - -std::vector Gamepad::last_input_report() const { - return with_device(device_, [](const auto& device) { - return device.last_report; - }); -} - -std::size_t Gamepad::submit_count() const { - return with_device(device_, [](const auto& device) { - return device.submitted_reports; - }); -} - -Runtime::Runtime(RuntimeOptions options): - state_ {std::make_shared(options)} {} - -Runtime::Runtime(Runtime&&) noexcept = default; -Runtime& Runtime::operator=(Runtime&&) noexcept = default; -Runtime::~Runtime() = default; - -std::unique_ptr Runtime::create(RuntimeOptions options) { - return std::unique_ptr {new Runtime {options}}; -} - -const BackendCapabilities& Runtime::capabilities() const { - return state_->caps; -} - -BackendKind Runtime::backend_kind() const { - return state_->options.backend; -} - -GamepadCreationResult Runtime::create_gamepad(const DeviceProfile& profile) { - CreateGamepadOptions options; - options.profile = profile; - return create_gamepad(options); -} - -GamepadCreationResult Runtime::create_gamepad(const CreateGamepadOptions& options) { - if(state_->options.backend != BackendKind::fake) { - return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; - } - - if(const auto validation = validate_gamepad_options(options); !validation.ok()) { - return {validation, nullptr}; - } - - std::lock_guard lock {state_->mutex}; - const auto id = state_->next_device_id++; - auto device = std::make_shared(id, options); - state_->gamepads.emplace_back(device); - return {Status::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; -} - -std::size_t Runtime::active_device_count() const { - std::lock_guard lock {state_->mutex}; - std::size_t count = 0; - for(const auto& weak_device : state_->gamepads) { - if(const auto device = weak_device.lock()) { - if(device->open) { - ++count; + + std::lock_guard lock {state_->mutex}; + const auto id = state_->next_device_id++; + auto device = std::make_shared(id, options); + state_->gamepads.emplace_back(device); + return {Status::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; + } + + std::size_t Runtime::active_device_count() const { + std::lock_guard lock {state_->mutex}; + std::size_t count = 0; + for (const auto &weak_device : state_->gamepads) { + if (const auto device = weak_device.lock()) { + if (device->open) { + ++count; + } } } + return count; } - return count; -} -void Runtime::close_all() { - std::lock_guard lock {state_->mutex}; - for(const auto& weak_device : state_->gamepads) { - if(const auto device = weak_device.lock()) { - std::lock_guard device_lock {device->mutex}; - device->open = false; + void Runtime::close_all() { + std::lock_guard lock {state_->mutex}; + for (const auto &weak_device : state_->gamepads) { + if (const auto device = weak_device.lock()) { + std::lock_guard device_lock {device->mutex}; + device->open = false; + } } } -} } // namespace lvh diff --git a/src/core/types.cpp b/src/core/types.cpp index f6c8379..2e033dd 100644 --- a/src/core/types.cpp +++ b/src/core/types.cpp @@ -1,64 +1,62 @@ #include - #include namespace lvh { -Status::Status(): - code_ {ErrorCode::ok}, - message_ {} {} - -Status::Status(ErrorCode code, std::string message): - code_ {code}, - message_ {std::move(message)} {} + Status::Status(): + code_ {ErrorCode::ok}, + message_ {} {} -Status Status::success() { - return {}; -} + Status::Status(ErrorCode code, std::string message): + code_ {code}, + message_ {std::move(message)} {} -Status Status::failure(ErrorCode code, std::string message) { - if(code == ErrorCode::ok) { + Status Status::success() { return {}; } - return {code, std::move(message)}; -} -bool Status::ok() const { - return code_ == ErrorCode::ok; -} + Status Status::failure(ErrorCode code, std::string message) { + if (code == ErrorCode::ok) { + return {}; + } + return {code, std::move(message)}; + } -ErrorCode Status::code() const { - return code_; -} + bool Status::ok() const { + return code_ == ErrorCode::ok; + } -const std::string& Status::message() const { - return message_; -} + ErrorCode Status::code() const { + return code_; + } -void ButtonSet::set(GamepadButton button, bool pressed) { - const auto mask = 1U << static_cast(button); - if(pressed) { - bits_ |= mask; + const std::string &Status::message() const { + return message_; } - else { - bits_ &= ~mask; + + void ButtonSet::set(GamepadButton button, bool pressed) { + const auto mask = 1U << static_cast(button); + if (pressed) { + bits_ |= mask; + } else { + bits_ &= ~mask; + } } -} -void ButtonSet::reset(GamepadButton button) { - set(button, false); -} + void ButtonSet::reset(GamepadButton button) { + set(button, false); + } -void ButtonSet::clear() { - bits_ = 0; -} + void ButtonSet::clear() { + bits_ = 0; + } -bool ButtonSet::test(GamepadButton button) const { - return (bits_ & (1U << static_cast(button))) != 0; -} + bool ButtonSet::test(GamepadButton button) const { + return (bits_ & (1U << static_cast(button))) != 0; + } -std::uint32_t ButtonSet::raw_bits() const { - return bits_; -} + std::uint32_t ButtonSet::raw_bits() const { + return bits_; + } } // namespace lvh diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e8cd1f8..f846922 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,24 +4,26 @@ set(INSTALL_GTEST OFF) set(INSTALL_GMOCK OFF) if(WIN32) - set(gtest_force_shared_crt ON CACHE BOOL "Always use msvcrt.dll" FORCE) # cmake-lint: disable=C0103 + set(gtest_force_shared_crt ON CACHE BOOL # cmake-lint: disable=C0103 + "Always use msvcrt.dll" FORCE) endif() include(GoogleTest) -add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/googletest" "third-party/googletest") +add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/googletest" + "third-party/googletest") set(TEST_BINARY test_libvirtualhid) add_executable(${TEST_BINARY} - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_sunshine_adapter.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" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_sunshine_adapter.cpp") target_link_libraries(${TEST_BINARY} - PRIVATE - gmock_main - libvirtualhid::libvirtualhid) + PRIVATE + gmock_main + libvirtualhid::libvirtualhid) libvirtualhid_copy_mingw_runtime(${TEST_BINARY}) diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 198cbfe..f6ed673 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -1,12 +1,11 @@ -#include - #include +#include TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { const auto profiles = lvh::profiles::built_in_gamepad_profiles(); ASSERT_GE(profiles.size(), 4U); - for(const auto& profile : profiles) { + for (const auto &profile : profiles) { EXPECT_EQ(profile.device_type, lvh::DeviceType::gamepad); EXPECT_FALSE(profile.name.empty()); EXPECT_NE(profile.vendor_id, 0); diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index f9b9914..d5534e4 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -1,8 +1,7 @@ +#include #include #include -#include - TEST(ReportTest, NormalizesAxesAndTriggers) { EXPECT_EQ(lvh::reports::normalize_axis(-2.0F), -32768); EXPECT_EQ(lvh::reports::normalize_axis(-1.0F), -32768); diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index a4942ee..2839eee 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -1,6 +1,5 @@ -#include - #include +#include TEST(RuntimeTest, FakeBackendReportsCapabilities) { auto runtime = lvh::Runtime::create(); @@ -59,7 +58,7 @@ TEST(RuntimeTest, DispatchesOutputCallback) { lvh::GamepadOutput received; bool was_called = false; - created.gamepad->set_output_callback([&](const lvh::GamepadOutput& output) { + created.gamepad->set_output_callback([&](const lvh::GamepadOutput &output) { received = output; was_called = true; }); diff --git a/tests/unit/test_sunshine_adapter.cpp b/tests/unit/test_sunshine_adapter.cpp index db355ca..047f4f8 100644 --- a/tests/unit/test_sunshine_adapter.cpp +++ b/tests/unit/test_sunshine_adapter.cpp @@ -1,6 +1,5 @@ -#include - #include +#include TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { auto runtime = lvh::Runtime::create(); @@ -26,7 +25,7 @@ TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { EXPECT_TRUE(created.gamepad->profile().capabilities.supports_touchpad); bool feedback_received = false; - created.gamepad->set_output_callback([&](const lvh::GamepadOutput& output) { + created.gamepad->set_output_callback([&](const lvh::GamepadOutput &output) { feedback_received = output.kind == lvh::GamepadOutputKind::rumble && output.low_frequency_rumble == 0x4000 && output.high_frequency_rumble == 0x2000; From a1a3cdc328f93ec800e251c4a6fe13c3d0139c70 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:21:04 -0400 Subject: [PATCH 09/28] Add file headers and tidy includes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add file-level Doxygen comments and reorganize includes/formatting across the project. Group standard vs local includes, adjust whitespace and const-& style, and reflow declarations in public headers (profiles.hpp, report.hpp, runtime.hpp, types.hpp). Also update examples and unit test headers to match the new file headers and include ordering. These are formatting and documentation changes only — no functional behavior intended to be changed. --- examples/sunshine_gamepad_adapter.cpp | 17 +- include/libvirtualhid/libvirtualhid.hpp | 4 +- include/libvirtualhid/profiles.hpp | 108 +-- include/libvirtualhid/report.hpp | 110 +-- include/libvirtualhid/runtime.hpp | 562 +++++++------- include/libvirtualhid/types.hpp | 979 ++++++++++++------------ src/core/profiles.cpp | 12 +- src/core/report.cpp | 8 + src/core/runtime.cpp | 12 +- src/core/types.cpp | 10 +- tests/unit/test_profiles.cpp | 8 + tests/unit/test_report.cpp | 8 + tests/unit/test_runtime.cpp | 8 + tests/unit/test_sunshine_adapter.cpp | 8 + 14 files changed, 975 insertions(+), 879 deletions(-) diff --git a/examples/sunshine_gamepad_adapter.cpp b/examples/sunshine_gamepad_adapter.cpp index 59cb9ec..015578c 100644 --- a/examples/sunshine_gamepad_adapter.cpp +++ b/examples/sunshine_gamepad_adapter.cpp @@ -1,7 +1,14 @@ -#include +/** + * @file examples/sunshine_gamepad_adapter.cpp + * @brief Minimal Sunshine-oriented gamepad adapter example. + */ +// standard includes #include +// local includes +#include + int main() { auto runtime = lvh::Runtime::create(); @@ -17,13 +24,13 @@ int main() { options.metadata.stable_id = "sunshine-client-0"; auto created = runtime->create_gamepad(options); - if(!created) { + if (!created) { std::cerr << created.status.message() << '\n'; return 1; } - created.gamepad->set_output_callback([](const lvh::GamepadOutput& output) { - if(output.kind == lvh::GamepadOutputKind::rumble) { + created.gamepad->set_output_callback([](const lvh::GamepadOutput &output) { + if (output.kind == lvh::GamepadOutputKind::rumble) { std::cout << "rumble " << output.low_frequency_rumble << ' ' << output.high_frequency_rumble << '\n'; } @@ -34,7 +41,7 @@ int main() { state.left_stick = {0.25F, -0.5F}; state.right_trigger = 1.0F; - if(const auto status = created.gamepad->submit(state); !status.ok()) { + if (const auto status = created.gamepad->submit(state); !status.ok()) { std::cerr << status.message() << '\n'; return 1; } diff --git a/include/libvirtualhid/libvirtualhid.hpp b/include/libvirtualhid/libvirtualhid.hpp index 71ad969..4bc6226 100644 --- a/include/libvirtualhid/libvirtualhid.hpp +++ b/include/libvirtualhid/libvirtualhid.hpp @@ -1,10 +1,10 @@ -#pragma once - /** * @file libvirtualhid/libvirtualhid.hpp * @brief Aggregate include for the libvirtualhid public C++ API. */ +#pragma once +// local includes #include #include #include diff --git a/include/libvirtualhid/profiles.hpp b/include/libvirtualhid/profiles.hpp index 8ad4c5b..b18ebe4 100644 --- a/include/libvirtualhid/profiles.hpp +++ b/include/libvirtualhid/profiles.hpp @@ -1,67 +1,73 @@ +/** + * @file include/libvirtualhid/profiles.hpp + * @brief Built-in virtual gamepad profile declarations. + */ #pragma once -#include - +// standard includes #include #include +// local includes +#include + namespace lvh::profiles { -/** - * @brief Create the generic HID gamepad profile. - * - * @return Generic gamepad device profile. - */ -DeviceProfile generic_gamepad(); + /** + * @brief Create the generic HID gamepad profile. + * + * @return Generic gamepad device profile. + */ + DeviceProfile generic_gamepad(); -/** - * @brief Create the Xbox 360-compatible gamepad profile. - * - * @return Xbox 360-compatible device profile. - */ -DeviceProfile xbox_360(); + /** + * @brief Create the Xbox 360-compatible gamepad profile. + * + * @return Xbox 360-compatible device profile. + */ + DeviceProfile xbox_360(); -/** - * @brief Create the Xbox One-compatible gamepad profile. - * - * @return Xbox One-compatible device profile. - */ -DeviceProfile xbox_one(); + /** + * @brief Create the Xbox One-compatible gamepad profile. + * + * @return Xbox One-compatible device profile. + */ + DeviceProfile xbox_one(); -/** - * @brief Create the Xbox Series-compatible gamepad profile. - * - * @return Xbox Series-compatible device profile. - */ -DeviceProfile xbox_series(); + /** + * @brief Create the Xbox Series-compatible gamepad profile. + * + * @return Xbox Series-compatible device profile. + */ + DeviceProfile xbox_series(); -/** - * @brief Create the PlayStation DualSense-compatible gamepad profile. - * - * @return DualSense-compatible device profile. - */ -DeviceProfile dualsense(); + /** + * @brief Create the PlayStation DualSense-compatible gamepad profile. + * + * @return DualSense-compatible device profile. + */ + DeviceProfile dualsense(); -/** - * @brief Create the Nintendo Switch Pro-compatible gamepad profile. - * - * @return Switch Pro-compatible device profile. - */ -DeviceProfile switch_pro(); + /** + * @brief Create the Nintendo Switch Pro-compatible gamepad profile. + * + * @return Switch Pro-compatible device profile. + */ + DeviceProfile switch_pro(); -/** - * @brief Look up a built-in gamepad profile by kind. - * - * @param kind Built-in gamepad profile kind. - * @return Matching profile, or `std::nullopt` when the kind is unknown. - */ -std::optional gamepad_profile(GamepadProfileKind kind); + /** + * @brief Look up a built-in gamepad profile by kind. + * + * @param kind Built-in gamepad profile kind. + * @return Matching profile, or `std::nullopt` when the kind is unknown. + */ + std::optional gamepad_profile(GamepadProfileKind kind); -/** - * @brief Get every built-in gamepad profile. - * - * @return Built-in gamepad profiles. - */ -std::vector built_in_gamepad_profiles(); + /** + * @brief Get every built-in gamepad profile. + * + * @return Built-in gamepad profiles. + */ + std::vector built_in_gamepad_profiles(); } // namespace lvh::profiles diff --git a/include/libvirtualhid/report.hpp b/include/libvirtualhid/report.hpp index c3642e0..5ad4905 100644 --- a/include/libvirtualhid/report.hpp +++ b/include/libvirtualhid/report.hpp @@ -1,67 +1,73 @@ +/** + * @file include/libvirtualhid/report.hpp + * @brief Gamepad state normalization and report packing declarations. + */ #pragma once -#include - +// standard includes #include #include +// local includes +#include + namespace lvh::reports { -/** - * @brief Clamp a stick axis value to the normalized range. - * - * @param value Axis value. - * @return Clamped axis value in the inclusive range `[-1.0, 1.0]`. - */ -float clamp_axis(float value); + /** + * @brief Clamp a stick axis value to the normalized range. + * + * @param value Axis value. + * @return Clamped axis value in the inclusive range `[-1.0, 1.0]`. + */ + float clamp_axis(float value); -/** - * @brief Clamp a trigger value to the normalized range. - * - * @param value Trigger value. - * @return Clamped trigger value in the inclusive range `[0.0, 1.0]`. - */ -float clamp_trigger(float value); + /** + * @brief Clamp a trigger value to the normalized range. + * + * @param value Trigger value. + * @return Clamped trigger value in the inclusive range `[0.0, 1.0]`. + */ + float clamp_trigger(float value); -/** - * @brief Convert a normalized axis value to a signed HID axis value. - * - * @param value Axis value in the inclusive range `[-1.0, 1.0]`. - * @return Signed 16-bit HID axis value. - */ -std::int16_t normalize_axis(float value); + /** + * @brief Convert a normalized axis value to a signed HID axis value. + * + * @param value Axis value in the inclusive range `[-1.0, 1.0]`. + * @return Signed 16-bit HID axis value. + */ + std::int16_t normalize_axis(float value); -/** - * @brief Convert a normalized trigger value to an unsigned HID trigger value. - * - * @param value Trigger value in the inclusive range `[0.0, 1.0]`. - * @return Unsigned 8-bit HID trigger value. - */ -std::uint8_t normalize_trigger(float value); + /** + * @brief Convert a normalized trigger value to an unsigned HID trigger value. + * + * @param value Trigger value in the inclusive range `[0.0, 1.0]`. + * @return Unsigned 8-bit HID trigger value. + */ + std::uint8_t normalize_trigger(float value); -/** - * @brief Normalize all scalar fields in a gamepad state. - * - * @param state Gamepad state to normalize. - * @return Normalized gamepad state. - */ -GamepadState normalize_state(const GamepadState& state); + /** + * @brief Normalize all scalar fields in a gamepad state. + * + * @param state Gamepad state to normalize. + * @return Normalized gamepad state. + */ + GamepadState normalize_state(const GamepadState &state); -/** - * @brief Convert directional pad buttons to a HID hat switch value. - * - * @param buttons Button set containing directional pad state. - * @return HID hat switch value, or `8` for neutral. - */ -std::uint8_t hat_from_buttons(const ButtonSet& buttons); + /** + * @brief Convert directional pad buttons to a HID hat switch value. + * + * @param buttons Button set containing directional pad state. + * @return HID hat switch value, or `8` for neutral. + */ + std::uint8_t hat_from_buttons(const ButtonSet &buttons); -/** - * @brief Pack a gamepad state into the profile's common input report format. - * - * @param profile Device profile used for report identity and size. - * @param state Gamepad state to pack. - * @return Packed input report bytes. - */ -std::vector pack_input_report(const DeviceProfile& profile, const GamepadState& state); + /** + * @brief Pack a gamepad state into the profile's common input report format. + * + * @param profile Device profile used for report identity and size. + * @param state Gamepad state to pack. + * @return Packed input report bytes. + */ + std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state); } // namespace lvh::reports diff --git a/include/libvirtualhid/runtime.hpp b/include/libvirtualhid/runtime.hpp index 7b29ef1..8526f91 100644 --- a/include/libvirtualhid/runtime.hpp +++ b/include/libvirtualhid/runtime.hpp @@ -1,288 +1,294 @@ +/** + * @file include/libvirtualhid/runtime.hpp + * @brief Runtime and virtual device handle declarations. + */ #pragma once -#include - +// standard includes #include #include #include -namespace lvh { - -namespace detail { -struct GamepadDevice; -class RuntimeState; -} // namespace detail - -/** - * @brief Common interface for virtual device handles. - */ -class VirtualDevice { -public: - /** - * @brief Destroy the virtual device handle. - */ - virtual ~VirtualDevice() = default; - - /** - * @brief Get the device identifier assigned by the runtime. - * - * @return Device identifier. - */ - virtual DeviceId device_id() const = 0; - - /** - * @brief Get the profile used to create this device. - * - * @return Device profile. - */ - virtual const DeviceProfile& profile() const = 0; - - /** - * @brief Check whether the device is open. - * - * @return `true` when the device can accept operations. - */ - virtual bool is_open() const = 0; - - /** - * @brief Close the virtual device. - * - * @return Close operation status. - */ - virtual Status close() = 0; -}; - -/** - * @brief Virtual gamepad device handle. - */ -class Gamepad final: public VirtualDevice { -public: - /** - * @brief Copy construction is disabled because the handle owns device lifetime. - */ - Gamepad(const Gamepad&) = delete; - - /** - * @brief Copy assignment is disabled because the handle owns device lifetime. - * - * @return This gamepad handle. - */ - Gamepad& operator=(const Gamepad&) = delete; - - /** - * @brief Move construct a gamepad handle. - * - * @param other Handle to move from. - */ - Gamepad(Gamepad&& other) noexcept; - - /** - * @brief Move assign a gamepad handle. - * - * @param other Handle to move from. - * @return This gamepad handle. - */ - Gamepad& operator=(Gamepad&& other) noexcept; - - /** - * @brief Destroy the gamepad handle. - */ - ~Gamepad() override; - - /** - * @copydoc VirtualDevice::device_id - */ - DeviceId device_id() const override; - - /** - * @copydoc VirtualDevice::profile - */ - const DeviceProfile& profile() const override; - - /** - * @brief Get the metadata supplied when the gamepad was created. - * - * @return Gamepad metadata. - */ - const GamepadMetadata& metadata() const; - - /** - * @copydoc VirtualDevice::is_open - */ - bool is_open() const override; - - /** - * @copydoc VirtualDevice::close - */ - Status close() override; - - /** - * @brief Submit the latest gamepad input state. - * - * @param state Gamepad input state. - * @return Submit operation status. - */ - Status submit(const GamepadState& state); - - /** - * @brief Register a callback for backend output events. - * - * @param callback Output callback. Passing an empty callback clears it. - */ - void set_output_callback(OutputCallback callback); - - /** - * @brief Dispatch an output event to the registered callback. - * - * @param output Output event. - * @return Dispatch operation status. - */ - Status dispatch_output(const GamepadOutput& output); - - /** - * @brief Get the most recently submitted gamepad state. - * - * @return Last submitted state. - */ - GamepadState last_submitted_state() const; - - /** - * @brief Get the most recently packed input report. - * - * @return Last input report bytes. - */ - std::vector last_input_report() const; - - /** - * @brief Get the number of successful submit operations. - * - * @return Submit count. - */ - std::size_t submit_count() const; - -private: - friend class Runtime; - - explicit Gamepad(std::shared_ptr device); - - std::shared_ptr device_; -}; - -/** - * @brief Result returned by gamepad creation. - */ -struct GamepadCreationResult { - /** - * @brief Creation status. - */ - Status status; - - /** - * @brief Created gamepad handle when creation succeeds. - */ - std::unique_ptr gamepad; - - /** - * @brief Check whether creation succeeded and produced a handle. - * - * @return `true` when creation succeeded. - */ - explicit operator bool() const { - return status.ok() && gamepad != nullptr; - } -}; - -/** - * @brief Runtime that owns backend state and creates virtual devices. - */ -class Runtime final { -public: - /** - * @brief Copy construction is disabled because the runtime owns backend state. - */ - Runtime(const Runtime&) = delete; - - /** - * @brief Copy assignment is disabled because the runtime owns backend state. - * - * @return This runtime. - */ - Runtime& operator=(const Runtime&) = delete; - - /** - * @brief Move construct a runtime. - * - * @param other Runtime to move from. - */ - Runtime(Runtime&& other) noexcept; - - /** - * @brief Move assign a runtime. - * - * @param other Runtime to move from. - * @return This runtime. - */ - Runtime& operator=(Runtime&& other) noexcept; - - /** - * @brief Destroy the runtime and close any remaining devices. - */ - ~Runtime(); - - /** - * @brief Create a runtime with the requested options. - * - * @param options Runtime creation options. - * @return Runtime instance. - */ - static std::unique_ptr create(RuntimeOptions options = {}); - - /** - * @brief Get capabilities for the selected backend. - * - * @return Backend capabilities. - */ - const BackendCapabilities& capabilities() const; - - /** - * @brief Get the backend kind used by this runtime. - * - * @return Backend kind. - */ - BackendKind backend_kind() const; - - /** - * @brief Create a gamepad from a profile. - * - * @param profile Device profile. - * @return Gamepad creation result. - */ - GamepadCreationResult create_gamepad(const DeviceProfile& profile); - - /** - * @brief Create a gamepad from full creation options. - * - * @param options Gamepad creation options. - * @return Gamepad creation result. - */ - GamepadCreationResult create_gamepad(const CreateGamepadOptions& options); - - /** - * @brief Get the number of open devices owned by the runtime. - * - * @return Active device count. - */ - std::size_t active_device_count() const; - - /** - * @brief Close every device owned by the runtime. - */ - void close_all(); +// local includes +#include -private: - explicit Runtime(RuntimeOptions options); +namespace lvh { - std::shared_ptr state_; -}; + namespace detail { + struct GamepadDevice; + class RuntimeState; + } // namespace detail + + /** + * @brief Common interface for virtual device handles. + */ + class VirtualDevice { + public: + /** + * @brief Destroy the virtual device handle. + */ + virtual ~VirtualDevice() = default; + + /** + * @brief Get the device identifier assigned by the runtime. + * + * @return Device identifier. + */ + virtual DeviceId device_id() const = 0; + + /** + * @brief Get the profile used to create this device. + * + * @return Device profile. + */ + virtual const DeviceProfile &profile() const = 0; + + /** + * @brief Check whether the device is open. + * + * @return `true` when the device can accept operations. + */ + virtual bool is_open() const = 0; + + /** + * @brief Close the virtual device. + * + * @return Close operation status. + */ + virtual Status close() = 0; + }; + + /** + * @brief Virtual gamepad device handle. + */ + class Gamepad final: public VirtualDevice { + public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ + Gamepad(const Gamepad &) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This gamepad handle. + */ + Gamepad &operator=(const Gamepad &) = delete; + + /** + * @brief Move construct a gamepad handle. + * + * @param other Handle to move from. + */ + Gamepad(Gamepad &&other) noexcept; + + /** + * @brief Move assign a gamepad handle. + * + * @param other Handle to move from. + * @return This gamepad handle. + */ + Gamepad &operator=(Gamepad &&other) noexcept; + + /** + * @brief Destroy the gamepad handle. + */ + ~Gamepad() override; + + /** + * @copydoc VirtualDevice::device_id + */ + DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ + const DeviceProfile &profile() const override; + + /** + * @brief Get the metadata supplied when the gamepad was created. + * + * @return Gamepad metadata. + */ + const GamepadMetadata &metadata() const; + + /** + * @copydoc VirtualDevice::is_open + */ + bool is_open() const override; + + /** + * @copydoc VirtualDevice::close + */ + Status close() override; + + /** + * @brief Submit the latest gamepad input state. + * + * @param state Gamepad input state. + * @return Submit operation status. + */ + Status submit(const GamepadState &state); + + /** + * @brief Register a callback for backend output events. + * + * @param callback Output callback. Passing an empty callback clears it. + */ + void set_output_callback(OutputCallback callback); + + /** + * @brief Dispatch an output event to the registered callback. + * + * @param output Output event. + * @return Dispatch operation status. + */ + Status dispatch_output(const GamepadOutput &output); + + /** + * @brief Get the most recently submitted gamepad state. + * + * @return Last submitted state. + */ + GamepadState last_submitted_state() const; + + /** + * @brief Get the most recently packed input report. + * + * @return Last input report bytes. + */ + std::vector last_input_report() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ + std::size_t submit_count() const; + + private: + friend class Runtime; + + explicit Gamepad(std::shared_ptr device); + + std::shared_ptr device_; + }; + + /** + * @brief Result returned by gamepad creation. + */ + struct GamepadCreationResult { + /** + * @brief Creation status. + */ + Status status; + + /** + * @brief Created gamepad handle when creation succeeds. + */ + std::unique_ptr gamepad; + + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && gamepad != nullptr; + } + }; + + /** + * @brief Runtime that owns backend state and creates virtual devices. + */ + class Runtime final { + public: + /** + * @brief Copy construction is disabled because the runtime owns backend state. + */ + Runtime(const Runtime &) = delete; + + /** + * @brief Copy assignment is disabled because the runtime owns backend state. + * + * @return This runtime. + */ + Runtime &operator=(const Runtime &) = delete; + + /** + * @brief Move construct a runtime. + * + * @param other Runtime to move from. + */ + Runtime(Runtime &&other) noexcept; + + /** + * @brief Move assign a runtime. + * + * @param other Runtime to move from. + * @return This runtime. + */ + Runtime &operator=(Runtime &&other) noexcept; + + /** + * @brief Destroy the runtime and close any remaining devices. + */ + ~Runtime(); + + /** + * @brief Create a runtime with the requested options. + * + * @param options Runtime creation options. + * @return Runtime instance. + */ + static std::unique_ptr create(RuntimeOptions options = {}); + + /** + * @brief Get capabilities for the selected backend. + * + * @return Backend capabilities. + */ + const BackendCapabilities &capabilities() const; + + /** + * @brief Get the backend kind used by this runtime. + * + * @return Backend kind. + */ + BackendKind backend_kind() const; + + /** + * @brief Create a gamepad from a profile. + * + * @param profile Device profile. + * @return Gamepad creation result. + */ + GamepadCreationResult create_gamepad(const DeviceProfile &profile); + + /** + * @brief Create a gamepad from full creation options. + * + * @param options Gamepad creation options. + * @return Gamepad creation result. + */ + GamepadCreationResult create_gamepad(const CreateGamepadOptions &options); + + /** + * @brief Get the number of open devices owned by the runtime. + * + * @return Active device count. + */ + std::size_t active_device_count() const; + + /** + * @brief Close every device owned by the runtime. + */ + void close_all(); + + private: + explicit Runtime(RuntimeOptions options); + + std::shared_ptr state_; + }; } // namespace lvh diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index 3be2069..81c76fe 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -1,5 +1,10 @@ +/** + * @file include/libvirtualhid/types.hpp + * @brief Core public types for libvirtualhid. + */ #pragma once +// standard includes #include #include #include @@ -11,510 +16,510 @@ */ namespace lvh { -/** - * @brief Stable identifier assigned to a virtual device instance. - */ -using DeviceId = std::uint64_t; - -/** - * @brief Error categories returned by libvirtualhid operations. - */ -enum class ErrorCode { - ok, ///< Operation completed successfully. - invalid_argument, ///< Caller supplied invalid input. - 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. - backend_failure, ///< Backend-specific operation failed. -}; - -/** - * @brief Result status with an error category and human-readable message. - */ -class Status { -public: - /** - * @brief Construct a successful status. - */ - Status(); - - /** - * @brief Construct a status with an explicit error code and message. - * - * @param code Error category. - * @param message Human-readable status message. - */ - Status(ErrorCode code, std::string message); - - /** - * @brief Create a successful status. - * - * @return Successful status object. - */ - static Status success(); - - /** - * @brief Create a failing status. - * - * @param code Error category. - * @param message Human-readable failure message. - * @return Failing status object. - */ - static Status failure(ErrorCode code, std::string message); - - /** - * @brief Check whether the operation succeeded. - * - * @return `true` when the status is successful. - */ - bool ok() const; - - /** - * @brief Get the status error category. - * - * @return Error category. - */ - ErrorCode code() const; - - /** - * @brief Get the human-readable status message. - * - * @return Status message. - */ - const std::string& message() const; - -private: - ErrorCode code_; - std::string message_; -}; - -/** - * @brief Backend implementation selection. - */ -enum class BackendKind { - fake, ///< In-memory backend for tests and API validation. - platform_default, ///< Native backend for the current platform. -}; - -/** - * @brief Runtime creation options. - */ -struct RuntimeOptions { - /** - * @brief Backend implementation requested by the caller. - */ - BackendKind backend = BackendKind::fake; -}; - -/** - * @brief Feature set exposed by the selected backend. - */ -struct BackendCapabilities { - /** - * @brief Human-readable backend name. - */ - std::string backend_name; - - /** - * @brief Whether the backend can create virtual HID devices. - */ - bool supports_virtual_hid = false; - - /** - * @brief Whether the backend can create gamepad devices. - */ - bool supports_gamepad = false; - - /** - * @brief Whether the backend can create keyboard devices. - */ - bool supports_keyboard = false; - - /** - * @brief Whether the backend can create mouse devices. - */ - bool supports_mouse = false; - - /** - * @brief Whether the backend can deliver output reports to callers. - */ - bool supports_output_reports = false; - - /** - * @brief Whether the backend can fall back to X11 XTest input. - */ - bool supports_xtest_fallback = false; - - /** - * @brief Whether the backend requires an installed driver package. - */ - bool requires_installed_driver = false; -}; - -/** - * @brief Device categories supported by the public profile model. - */ -enum class DeviceType { - gamepad, ///< Game controller device. - keyboard, ///< Keyboard device. - mouse, ///< Mouse or pointer device. -}; - -/** - * @brief Transport bus identity advertised by a device profile. - */ -enum class BusType { - unknown, ///< Bus is unknown or not meaningful for the backend. - usb, ///< USB-style device identity. - bluetooth, ///< Bluetooth-style device identity. -}; - -/** - * @brief Built-in gamepad profile identifiers. - */ -enum class GamepadProfileKind { - generic, ///< Generic HID gamepad profile. - xbox_360, ///< Xbox 360-compatible profile. - xbox_one, ///< Xbox One-compatible profile. - xbox_series, ///< Xbox Series-compatible profile. - dualsense, ///< PlayStation DualSense-compatible profile. - switch_pro, ///< Nintendo Switch Pro-compatible profile. -}; - -/** - * @brief Optional behavior advertised by a gamepad profile. - */ -struct GamepadProfileCapabilities { - /** - * @brief Whether the profile supports rumble output. - */ - bool supports_rumble = false; - - /** - * @brief Whether the profile exposes motion sensors. - */ - bool supports_motion = false; - - /** - * @brief Whether the profile exposes touchpad input. - */ - bool supports_touchpad = false; - - /** - * @brief Whether the profile supports an RGB LED output. - */ - bool supports_rgb_led = false; - - /** - * @brief Whether the profile supports battery state. - */ - bool supports_battery = false; - - /** - * @brief Whether the profile supports adaptive trigger output. - */ - bool supports_adaptive_triggers = false; -}; - -/** - * @brief Descriptor and identity data used to create a virtual device. - */ -struct DeviceProfile { - /** - * @brief Device category for this profile. - */ - DeviceType device_type = DeviceType::gamepad; - - /** - * @brief Built-in gamepad profile identifier. - */ - GamepadProfileKind gamepad_kind = GamepadProfileKind::generic; - - /** - * @brief Transport bus identity advertised by the profile. - */ - BusType bus_type = BusType::usb; - - /** - * @brief USB-style vendor identifier. - */ - std::uint16_t vendor_id = 0; - - /** - * @brief USB-style product identifier. - */ - std::uint16_t product_id = 0; - - /** - * @brief Device version number. - */ - std::uint16_t version = 0; - /** - * @brief Primary input report identifier. - */ - std::uint8_t report_id = 1; + * @brief Stable identifier assigned to a virtual device instance. + */ + using DeviceId = std::uint64_t; /** - * @brief Expected packed input report size in bytes. - */ - std::size_t input_report_size = 0; + * @brief Error categories returned by libvirtualhid operations. + */ + enum class ErrorCode { + ok, ///< Operation completed successfully. + invalid_argument, ///< Caller supplied invalid input. + 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. + backend_failure, ///< Backend-specific operation failed. + }; + + /** + * @brief Result status with an error category and human-readable message. + */ + class Status { + public: + /** + * @brief Construct a successful status. + */ + Status(); + + /** + * @brief Construct a status with an explicit error code and message. + * + * @param code Error category. + * @param message Human-readable status message. + */ + Status(ErrorCode code, std::string message); + + /** + * @brief Create a successful status. + * + * @return Successful status object. + */ + static Status success(); + + /** + * @brief Create a failing status. + * + * @param code Error category. + * @param message Human-readable failure message. + * @return Failing status object. + */ + static Status failure(ErrorCode code, std::string message); + + /** + * @brief Check whether the operation succeeded. + * + * @return `true` when the status is successful. + */ + bool ok() const; - /** - * @brief Human-readable device name. - */ - std::string name; + /** + * @brief Get the status error category. + * + * @return Error category. + */ + ErrorCode code() const; - /** - * @brief Human-readable device manufacturer. - */ - std::string manufacturer; + /** + * @brief Get the human-readable status message. + * + * @return Status message. + */ + const std::string &message() const; - /** - * @brief Profile feature flags. - */ - GamepadProfileCapabilities capabilities; + private: + ErrorCode code_; + std::string message_; + }; /** - * @brief HID report descriptor bytes. + * @brief Backend implementation selection. */ - std::vector report_descriptor; -}; + enum class BackendKind { + fake, ///< In-memory backend for tests and API validation. + platform_default, ///< Native backend for the current platform. + }; -/** - * @brief Controller family reported by a streaming client. - */ -enum class ClientControllerType { - unknown, ///< Controller family is unknown. - xbox, ///< Xbox-style client controller. - playstation, ///< PlayStation-style client controller. - nintendo, ///< Nintendo-style client controller. -}; - -/** - * @brief Consumer-provided metadata for a gamepad device. - */ -struct GamepadMetadata { /** - * @brief Stable index across all connected controllers, or `-1` if unset. - */ - int global_index = -1; - - /** - * @brief Stable index within the client session, or `-1` if unset. - */ - int client_relative_index = -1; - - /** - * @brief Controller family reported by the client. - */ - ClientControllerType client_type = ClientControllerType::unknown; - + * @brief Runtime creation options. + */ + struct RuntimeOptions { + /** + * @brief Backend implementation requested by the caller. + */ + BackendKind backend = BackendKind::fake; + }; + + /** + * @brief Feature set exposed by the selected backend. + */ + struct BackendCapabilities { + /** + * @brief Human-readable backend name. + */ + std::string backend_name; + + /** + * @brief Whether the backend can create virtual HID devices. + */ + bool supports_virtual_hid = false; + + /** + * @brief Whether the backend can create gamepad devices. + */ + bool supports_gamepad = false; + + /** + * @brief Whether the backend can create keyboard devices. + */ + bool supports_keyboard = false; + + /** + * @brief Whether the backend can create mouse devices. + */ + bool supports_mouse = false; + + /** + * @brief Whether the backend can deliver output reports to callers. + */ + bool supports_output_reports = false; + + /** + * @brief Whether the backend can fall back to X11 XTest input. + */ + bool supports_xtest_fallback = false; + + /** + * @brief Whether the backend requires an installed driver package. + */ + bool requires_installed_driver = false; + }; + + /** + * @brief Device categories supported by the public profile model. + */ + enum class DeviceType { + gamepad, ///< Game controller device. + keyboard, ///< Keyboard device. + mouse, ///< Mouse or pointer device. + }; + + /** + * @brief Transport bus identity advertised by a device profile. + */ + enum class BusType { + unknown, ///< Bus is unknown or not meaningful for the backend. + usb, ///< USB-style device identity. + bluetooth, ///< Bluetooth-style device identity. + }; + + /** + * @brief Built-in gamepad profile identifiers. + */ + enum class GamepadProfileKind { + generic, ///< Generic HID gamepad profile. + xbox_360, ///< Xbox 360-compatible profile. + xbox_one, ///< Xbox One-compatible profile. + xbox_series, ///< Xbox Series-compatible profile. + dualsense, ///< PlayStation DualSense-compatible profile. + switch_pro, ///< Nintendo Switch Pro-compatible profile. + }; + /** - * @brief Whether the client reports motion sensor capability. + * @brief Optional behavior advertised by a gamepad profile. */ - bool has_motion_sensors = false; + struct GamepadProfileCapabilities { + /** + * @brief Whether the profile supports rumble output. + */ + bool supports_rumble = false; + + /** + * @brief Whether the profile exposes motion sensors. + */ + bool supports_motion = false; - /** - * @brief Whether the client reports touchpad capability. - */ - bool has_touchpad = false; + /** + * @brief Whether the profile exposes touchpad input. + */ + bool supports_touchpad = false; + + /** + * @brief Whether the profile supports an RGB LED output. + */ + bool supports_rgb_led = false; + + /** + * @brief Whether the profile supports battery state. + */ + bool supports_battery = false; + + /** + * @brief Whether the profile supports adaptive trigger output. + */ + bool supports_adaptive_triggers = false; + }; + + /** + * @brief Descriptor and identity data used to create a virtual device. + */ + struct DeviceProfile { + /** + * @brief Device category for this profile. + */ + DeviceType device_type = DeviceType::gamepad; + + /** + * @brief Built-in gamepad profile identifier. + */ + GamepadProfileKind gamepad_kind = GamepadProfileKind::generic; + + /** + * @brief Transport bus identity advertised by the profile. + */ + BusType bus_type = BusType::usb; + + /** + * @brief USB-style vendor identifier. + */ + std::uint16_t vendor_id = 0; + + /** + * @brief USB-style product identifier. + */ + std::uint16_t product_id = 0; + + /** + * @brief Device version number. + */ + std::uint16_t version = 0; + + /** + * @brief Primary input report identifier. + */ + std::uint8_t report_id = 1; + + /** + * @brief Expected packed input report size in bytes. + */ + std::size_t input_report_size = 0; + + /** + * @brief Human-readable device name. + */ + std::string name; + + /** + * @brief Human-readable device manufacturer. + */ + std::string manufacturer; + + /** + * @brief Profile feature flags. + */ + GamepadProfileCapabilities capabilities; + + /** + * @brief HID report descriptor bytes. + */ + std::vector report_descriptor; + }; + + /** + * @brief Controller family reported by a streaming client. + */ + enum class ClientControllerType { + unknown, ///< Controller family is unknown. + xbox, ///< Xbox-style client controller. + playstation, ///< PlayStation-style client controller. + nintendo, ///< Nintendo-style client controller. + }; + + /** + * @brief Consumer-provided metadata for a gamepad device. + */ + struct GamepadMetadata { + /** + * @brief Stable index across all connected controllers, or `-1` if unset. + */ + int global_index = -1; + + /** + * @brief Stable index within the client session, or `-1` if unset. + */ + int client_relative_index = -1; + + /** + * @brief Controller family reported by the client. + */ + ClientControllerType client_type = ClientControllerType::unknown; + + /** + * @brief Whether the client reports motion sensor capability. + */ + bool has_motion_sensors = false; + + /** + * @brief Whether the client reports touchpad capability. + */ + bool has_touchpad = false; + + /** + * @brief Whether the client reports RGB LED capability. + */ + bool has_rgb_led = false; + + /** + * @brief Whether the client reports battery state capability. + */ + bool has_battery = false; + + /** + * @brief Consumer-defined stable identity string. + */ + std::string stable_id; + }; + + /** + * @brief Full gamepad creation request. + */ + struct CreateGamepadOptions { + /** + * @brief Device profile to instantiate. + */ + DeviceProfile profile; + + /** + * @brief Consumer metadata associated with the device. + */ + GamepadMetadata metadata; + }; + + /** + * @brief Logical gamepad buttons accepted by the common gamepad state model. + */ + enum class GamepadButton : std::uint8_t { + a = 0, ///< South face button. + b, ///< East face button. + x, ///< West face button. + y, ///< North face button. + back, ///< Back, select, or share button. + start, ///< Start or options button. + guide, ///< System guide button. + left_stick, ///< Left stick press. + right_stick, ///< Right stick press. + left_shoulder, ///< Left shoulder button. + right_shoulder, ///< Right shoulder button. + dpad_up, ///< Directional pad up. + dpad_down, ///< Directional pad down. + dpad_left, ///< Directional pad left. + dpad_right, ///< Directional pad right. + misc1, ///< Profile-specific miscellaneous button. + }; + + /** + * @brief Compact set of pressed gamepad buttons. + */ + class ButtonSet { + public: + /** + * @brief Set or clear a button. + * + * @param button Button to update. + * @param pressed Whether the button is pressed. + */ + void set(GamepadButton button, bool pressed = true); + + /** + * @brief Clear a button. + * + * @param button Button to clear. + */ + void reset(GamepadButton button); + + /** + * @brief Clear all buttons. + */ + void clear(); + + /** + * @brief Check whether a button is pressed. + * + * @param button Button to test. + * @return `true` when the button is pressed. + */ + bool test(GamepadButton button) const; + + /** + * @brief Get the raw bitset value. + * + * @return Raw button bits. + */ + std::uint32_t raw_bits() const; + + private: + std::uint32_t bits_ = 0; + }; + + /** + * @brief Normalized two-axis stick state. + */ + struct Stick { + /** + * @brief Horizontal axis in the inclusive range `[-1.0, 1.0]`. + */ + float x = 0.0F; + + /** + * @brief Vertical axis in the inclusive range `[-1.0, 1.0]`. + */ + float y = 0.0F; + }; + + /** + * @brief Common gamepad input state accepted by libvirtualhid. + */ + struct GamepadState { + /** + * @brief Pressed button set. + */ + ButtonSet buttons; + + /** + * @brief Left stick state. + */ + Stick left_stick; + + /** + * @brief Right stick state. + */ + Stick right_stick; + + /** + * @brief Left trigger value in the inclusive range `[0.0, 1.0]`. + */ + float left_trigger = 0.0F; + + /** + * @brief Right trigger value in the inclusive range `[0.0, 1.0]`. + */ + float right_trigger = 0.0F; + }; + + /** + * @brief Output report categories delivered by a gamepad backend. + */ + enum class GamepadOutputKind { + rumble, ///< Rumble motor output. + rgb_led, ///< RGB LED color output. + adaptive_triggers, ///< Adaptive trigger output. + raw_report, ///< Raw output report bytes. + }; + + /** + * @brief Normalized gamepad output event delivered to the consumer. + */ + struct GamepadOutput { + /** + * @brief Output event category. + */ + GamepadOutputKind kind = GamepadOutputKind::raw_report; + + /** + * @brief Low-frequency rumble motor strength. + */ + std::uint16_t low_frequency_rumble = 0; + + /** + * @brief High-frequency rumble motor strength. + */ + std::uint16_t high_frequency_rumble = 0; + + /** + * @brief Red LED channel value. + */ + std::uint8_t red = 0; + + /** + * @brief Green LED channel value. + */ + std::uint8_t green = 0; + + /** + * @brief Blue LED channel value. + */ + std::uint8_t blue = 0; - /** - * @brief Whether the client reports RGB LED capability. - */ - bool has_rgb_led = false; + /** + * @brief Raw output report payload. + */ + std::vector raw_report; + }; /** - * @brief Whether the client reports battery state capability. + * @brief Callback invoked when a gamepad receives output from the backend. */ - bool has_battery = false; - - /** - * @brief Consumer-defined stable identity string. - */ - std::string stable_id; -}; - -/** - * @brief Full gamepad creation request. - */ -struct CreateGamepadOptions { - /** - * @brief Device profile to instantiate. - */ - DeviceProfile profile; - - /** - * @brief Consumer metadata associated with the device. - */ - GamepadMetadata metadata; -}; - -/** - * @brief Logical gamepad buttons accepted by the common gamepad state model. - */ -enum class GamepadButton: std::uint8_t { - a = 0, ///< South face button. - b, ///< East face button. - x, ///< West face button. - y, ///< North face button. - back, ///< Back, select, or share button. - start, ///< Start or options button. - guide, ///< System guide button. - left_stick, ///< Left stick press. - right_stick, ///< Right stick press. - left_shoulder, ///< Left shoulder button. - right_shoulder, ///< Right shoulder button. - dpad_up, ///< Directional pad up. - dpad_down, ///< Directional pad down. - dpad_left, ///< Directional pad left. - dpad_right, ///< Directional pad right. - misc1, ///< Profile-specific miscellaneous button. -}; - -/** - * @brief Compact set of pressed gamepad buttons. - */ -class ButtonSet { -public: - /** - * @brief Set or clear a button. - * - * @param button Button to update. - * @param pressed Whether the button is pressed. - */ - void set(GamepadButton button, bool pressed = true); - - /** - * @brief Clear a button. - * - * @param button Button to clear. - */ - void reset(GamepadButton button); - - /** - * @brief Clear all buttons. - */ - void clear(); - - /** - * @brief Check whether a button is pressed. - * - * @param button Button to test. - * @return `true` when the button is pressed. - */ - bool test(GamepadButton button) const; - - /** - * @brief Get the raw bitset value. - * - * @return Raw button bits. - */ - std::uint32_t raw_bits() const; - -private: - std::uint32_t bits_ = 0; -}; - -/** - * @brief Normalized two-axis stick state. - */ -struct Stick { - /** - * @brief Horizontal axis in the inclusive range `[-1.0, 1.0]`. - */ - float x = 0.0F; - - /** - * @brief Vertical axis in the inclusive range `[-1.0, 1.0]`. - */ - float y = 0.0F; -}; - -/** - * @brief Common gamepad input state accepted by libvirtualhid. - */ -struct GamepadState { - /** - * @brief Pressed button set. - */ - ButtonSet buttons; - - /** - * @brief Left stick state. - */ - Stick left_stick; - - /** - * @brief Right stick state. - */ - Stick right_stick; - - /** - * @brief Left trigger value in the inclusive range `[0.0, 1.0]`. - */ - float left_trigger = 0.0F; - - /** - * @brief Right trigger value in the inclusive range `[0.0, 1.0]`. - */ - float right_trigger = 0.0F; -}; - -/** - * @brief Output report categories delivered by a gamepad backend. - */ -enum class GamepadOutputKind { - rumble, ///< Rumble motor output. - rgb_led, ///< RGB LED color output. - adaptive_triggers, ///< Adaptive trigger output. - raw_report, ///< Raw output report bytes. -}; - -/** - * @brief Normalized gamepad output event delivered to the consumer. - */ -struct GamepadOutput { - /** - * @brief Output event category. - */ - GamepadOutputKind kind = GamepadOutputKind::raw_report; - - /** - * @brief Low-frequency rumble motor strength. - */ - std::uint16_t low_frequency_rumble = 0; - - /** - * @brief High-frequency rumble motor strength. - */ - std::uint16_t high_frequency_rumble = 0; - - /** - * @brief Red LED channel value. - */ - std::uint8_t red = 0; - - /** - * @brief Green LED channel value. - */ - std::uint8_t green = 0; - - /** - * @brief Blue LED channel value. - */ - std::uint8_t blue = 0; - - /** - * @brief Raw output report payload. - */ - std::vector raw_report; -}; - -/** - * @brief Callback invoked when a gamepad receives output from the backend. - */ -using OutputCallback = std::function; + using OutputCallback = std::function; } // namespace lvh diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index eba0b0b..5a0cbac 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -1,4 +1,14 @@ -#include +/** + * @file src/core/profiles.cpp + * @brief Built-in virtual gamepad profile definitions. + */ + +// standard includes +#include +#include +#include + +// local includes #include namespace lvh::profiles { diff --git a/src/core/report.cpp b/src/core/report.cpp index 4f2eab2..00aad60 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -1,5 +1,13 @@ +/** + * @file src/core/report.cpp + * @brief Gamepad report normalization and packing definitions. + */ + +// standard includes #include #include + +// local includes #include namespace lvh::reports { diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index a8e42d9..36fa6fb 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -1,9 +1,17 @@ +/** + * @file src/core/runtime.cpp + * @brief Runtime and virtual gamepad handle definitions. + */ + +// standard includes #include -#include -#include #include #include +// local includes +#include +#include + namespace lvh::detail { struct GamepadDevice { diff --git a/src/core/types.cpp b/src/core/types.cpp index 2e033dd..3157f84 100644 --- a/src/core/types.cpp +++ b/src/core/types.cpp @@ -1,6 +1,14 @@ -#include +/** + * @file src/core/types.cpp + * @brief Core type helper definitions. + */ + +// standard includes #include +// local includes +#include + namespace lvh { Status::Status(): diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index f6ed673..f1aa212 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -1,4 +1,12 @@ +/** + * @file tests/unit/test_profiles.cpp + * @brief Unit tests for built-in gamepad profiles. + */ + +// lib includes #include + +// local includes #include TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index d5534e4..7b99e47 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -1,4 +1,12 @@ +/** + * @file tests/unit/test_report.cpp + * @brief Unit tests for gamepad report packing. + */ + +// lib includes #include + +// local includes #include #include diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 2839eee..97c385a 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -1,4 +1,12 @@ +/** + * @file tests/unit/test_runtime.cpp + * @brief Unit tests for runtime and virtual gamepad handles. + */ + +// lib includes #include + +// local includes #include TEST(RuntimeTest, FakeBackendReportsCapabilities) { diff --git a/tests/unit/test_sunshine_adapter.cpp b/tests/unit/test_sunshine_adapter.cpp index 047f4f8..1dd6459 100644 --- a/tests/unit/test_sunshine_adapter.cpp +++ b/tests/unit/test_sunshine_adapter.cpp @@ -1,4 +1,12 @@ +/** + * @file tests/unit/test_sunshine_adapter.cpp + * @brief Unit tests for the Sunshine-oriented gamepad lifecycle. + */ + +// lib includes #include + +// local includes #include TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { From c366dd62d7d515f063e7dd0b1330589785c2c878 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:39:17 -0400 Subject: [PATCH 10/28] Add shared GoogleTest fixture for tests Introduce a BaseTest fixture to centralize test setup/teardown and capture std::cout for easier debugging. Adds tests/fixtures/include/fixtures/fixtures.hpp and tests/fixtures/fixtures.cpp, redefines TEST to automatically use BaseTest, and updates tests to include the new fixture header instead of pulling gtest directly. Update tests/CMakeLists.txt to compile the fixture source and add its include directory to the test binary. --- tests/CMakeLists.txt | 5 +++ tests/fixtures/fixtures.cpp | 33 +++++++++++++++++ tests/fixtures/include/fixtures/fixtures.hpp | 39 ++++++++++++++++++++ tests/unit/test_profiles.cpp | 4 +- tests/unit/test_report.cpp | 4 +- tests/unit/test_runtime.cpp | 4 +- tests/unit/test_sunshine_adapter.cpp | 4 +- 7 files changed, 81 insertions(+), 12 deletions(-) create mode 100644 tests/fixtures/fixtures.cpp create mode 100644 tests/fixtures/include/fixtures/fixtures.hpp diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f846922..0d5ed91 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -15,11 +15,16 @@ add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/googletest" set(TEST_BINARY test_libvirtualhid) add_executable(${TEST_BINARY} + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/fixtures.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" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_sunshine_adapter.cpp") +target_include_directories(${TEST_BINARY} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/include") + target_link_libraries(${TEST_BINARY} PRIVATE gmock_main diff --git a/tests/fixtures/fixtures.cpp b/tests/fixtures/fixtures.cpp new file mode 100644 index 0000000..f82f8c6 --- /dev/null +++ b/tests/fixtures/fixtures.cpp @@ -0,0 +1,33 @@ +/** + * @file tests/fixtures/fixtures.cpp + * @brief Shared GoogleTest fixture setup definitions. + */ + +// standard includes +#include + +// local includes +#include "fixtures/fixtures.hpp" + +void BaseTest::SetUp() { + cout_buffer_.str({}); + cout_buffer_.clear(); + cout_streambuf_ = std::cout.rdbuf(); + std::cout.rdbuf(cout_buffer_.rdbuf()); +} + +void BaseTest::TearDown() { + if (cout_streambuf_ != nullptr) { + std::cout.rdbuf(cout_streambuf_); + cout_streambuf_ = nullptr; + } + + const auto *test_info = ::testing::UnitTest::GetInstance()->current_test_info(); + if (test_info != nullptr && test_info->result()->Failed()) { + std::cout << std::endl + << "Test failed: " << test_info->name() << std::endl + << std::endl + << "Captured cout:" << std::endl + << cout_buffer_.str() << std::endl; + } +} diff --git a/tests/fixtures/include/fixtures/fixtures.hpp b/tests/fixtures/include/fixtures/fixtures.hpp new file mode 100644 index 0000000..a9154f9 --- /dev/null +++ b/tests/fixtures/include/fixtures/fixtures.hpp @@ -0,0 +1,39 @@ +/** + * @file tests/fixtures/include/fixtures/fixtures.hpp + * @brief Shared GoogleTest fixture setup for libvirtualhid tests. + */ +#pragma once + +// standard includes +#include +#include + +// lib includes +#include + +/** + * @brief Base class used by default for every test. + */ +class BaseTest: public ::testing::Test { +protected: + /** + * @brief Set up the test. + */ + void SetUp() override; + + /** + * @brief Tear down the test. + */ + void TearDown() override; + +private: + std::stringstream cout_buffer_; + std::streambuf *cout_streambuf_ {nullptr}; +}; + +// Undefine the original TEST macro. +#undef TEST // NOSONAR(cpp:S959): Tests intentionally wrap TEST to use BaseTest. + +// Redefine TEST to automatically use the shared BaseTest fixture. +#define TEST(test_case_name, test_name) \ + GTEST_TEST_(test_case_name, test_name, ::BaseTest, ::testing::internal::GetTypeId<::BaseTest>()) diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index f1aa212..ba5400c 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -3,10 +3,8 @@ * @brief Unit tests for built-in gamepad profiles. */ -// lib includes -#include - // local includes +#include "fixtures/fixtures.hpp" #include TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 7b99e47..1b18100 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -3,10 +3,8 @@ * @brief Unit tests for gamepad report packing. */ -// lib includes -#include - // local includes +#include "fixtures/fixtures.hpp" #include #include diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 97c385a..5d44dea 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -3,10 +3,8 @@ * @brief Unit tests for runtime and virtual gamepad handles. */ -// lib includes -#include - // local includes +#include "fixtures/fixtures.hpp" #include TEST(RuntimeTest, FakeBackendReportsCapabilities) { diff --git a/tests/unit/test_sunshine_adapter.cpp b/tests/unit/test_sunshine_adapter.cpp index 1dd6459..7c6731b 100644 --- a/tests/unit/test_sunshine_adapter.cpp +++ b/tests/unit/test_sunshine_adapter.cpp @@ -3,10 +3,8 @@ * @brief Unit tests for the Sunshine-oriented gamepad lifecycle. */ -// lib includes -#include - // local includes +#include "fixtures/fixtures.hpp" #include TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { From 6b55bd2c25f68cca01c337f3350598703889a61e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:45:11 -0400 Subject: [PATCH 11/28] Fix lint errors --- CMakeLists.txt | 66 ++++++++++++++-------------- src/CMakeLists.txt | 4 +- tests/CMakeLists.txt | 4 +- tests/unit/test_profiles.cpp | 1 + tests/unit/test_report.cpp | 1 + tests/unit/test_runtime.cpp | 1 + tests/unit/test_sunshine_adapter.cpp | 1 + 7 files changed, 41 insertions(+), 37 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7b59b3e..883a095 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,13 +14,13 @@ set(CMAKE_CXX_EXTENSIONS OFF) set(LIBVIRTUALHID_IS_TOP_LEVEL OFF) if(CMAKE_CURRENT_SOURCE_DIR STREQUAL CMAKE_SOURCE_DIR) - set(LIBVIRTUALHID_IS_TOP_LEVEL ON) + set(LIBVIRTUALHID_IS_TOP_LEVEL ON) endif() if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - message(STATUS "Setting build type to 'Release' as none was specified.") - set(CMAKE_BUILD_TYPE "Release" CACHE STRING - "Choose the type of build." FORCE) + message(STATUS "Setting build type to 'Release' as none was specified.") + set(CMAKE_BUILD_TYPE "Release" CACHE STRING + "Choose the type of build." FORCE) endif() list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") @@ -40,24 +40,24 @@ include(GNUInstallDirs) # 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") - return() - endif() - - get_filename_component(lvh_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) - foreach(lvh_runtime_dll IN ITEMS - libgcc_s_seh-1.dll - libstdc++-6.dll - libwinpthread-1.dll) - set(lvh_runtime_path "${lvh_compiler_dir}/${lvh_runtime_dll}") - if(EXISTS "${lvh_runtime_path}") - add_custom_command(TARGET "${target_name}" POST_BUILD - COMMAND ${CMAKE_COMMAND} -E copy_if_different - "${lvh_runtime_path}" - "$" - COMMENT "Copying MinGW runtime ${lvh_runtime_dll}") + if(NOT WIN32 OR NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + return() endif() - endforeach() + + get_filename_component(lvh_compiler_dir "${CMAKE_CXX_COMPILER}" DIRECTORY) + foreach(lvh_runtime_dll IN ITEMS + libgcc_s_seh-1.dll + libstdc++-6.dll + libwinpthread-1.dll) + set(lvh_runtime_path "${lvh_compiler_dir}/${lvh_runtime_dll}") + if(EXISTS "${lvh_runtime_path}") + add_custom_command(TARGET "${target_name}" POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${lvh_runtime_path}" + "$" + COMMENT "Copying MinGW runtime ${lvh_runtime_dll}") + endif() + endforeach() endfunction() # @@ -69,18 +69,18 @@ add_subdirectory(src) # Examples, tests, and docs are top-level only # if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) - if(BUILD_DOCS) - add_subdirectory(third-party/doxyconfig docs) - endif() - - if(BUILD_EXAMPLES) - add_subdirectory(examples) - endif() - - if(BUILD_TESTS) - enable_testing() - add_subdirectory(tests) - endif() + if(BUILD_DOCS) + add_subdirectory(third-party/doxyconfig docs) + endif() + + if(BUILD_EXAMPLES) + add_subdirectory(examples) + endif() + + if(BUILD_TESTS) + enable_testing() + add_subdirectory(tests) + endif() endif() # diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5ec279c..0bf6e60 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -19,9 +19,9 @@ set_target_properties(${PROJECT_NAME} PROPERTIES OUTPUT_NAME virtualhid) if(MSVC) - target_compile_options(${PROJECT_NAME} PRIVATE /W4) + target_compile_options(${PROJECT_NAME} PRIVATE /W4) else() - target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) + target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic) endif() install(TARGETS ${PROJECT_NAME} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0d5ed91..90828fe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -4,8 +4,8 @@ set(INSTALL_GTEST OFF) set(INSTALL_GMOCK OFF) if(WIN32) - set(gtest_force_shared_crt ON CACHE BOOL # cmake-lint: disable=C0103 - "Always use msvcrt.dll" FORCE) + set(gtest_force_shared_crt ON CACHE BOOL # cmake-lint: disable=C0103 + "Always use msvcrt.dll" FORCE) endif() include(GoogleTest) diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index ba5400c..473c43e 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -5,6 +5,7 @@ // local includes #include "fixtures/fixtures.hpp" + #include TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 1b18100..6190547 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -5,6 +5,7 @@ // local includes #include "fixtures/fixtures.hpp" + #include #include diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 5d44dea..e02b6bf 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -5,6 +5,7 @@ // local includes #include "fixtures/fixtures.hpp" + #include TEST(RuntimeTest, FakeBackendReportsCapabilities) { diff --git a/tests/unit/test_sunshine_adapter.cpp b/tests/unit/test_sunshine_adapter.cpp index 7c6731b..e3f4f6d 100644 --- a/tests/unit/test_sunshine_adapter.cpp +++ b/tests/unit/test_sunshine_adapter.cpp @@ -5,6 +5,7 @@ // local includes #include "fixtures/fixtures.hpp" + #include TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { From df7babdc0c720e6b52e5fdceb71385e37c04d185 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:49:02 -0400 Subject: [PATCH 12/28] Set project version to 0.0.0 Update CMakeLists.txt to change the project version from 0.1.0 to 0.0.0. This adjusts the package metadata in the build configuration to the desired version. --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 883a095..462bb03 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ # Project configuration # cmake_minimum_required(VERSION 3.24) -project(libvirtualhid VERSION 0.1.0 +project(libvirtualhid VERSION 0.0.0 DESCRIPTION "Cross-platform virtual HID device library." HOMEPAGE_URL "https://app.lizardbyte.dev" LANGUAGES CXX) From bf2cba1c2ad8e01fc00aa46fa96eb676c7bfee37 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:58:48 -0400 Subject: [PATCH 13/28] Use windows-2022 runner in CI Update .github/workflows/ci.yml: change the Windows-MSVC job's runner from `windows-latest` to `windows-2022`. This pins the workflow to a specific Windows image (2022) instead of the moving `latest` tag to ensure more consistent build environments. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4759277..044617f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,7 +52,7 @@ jobs: msystem: ucrt64 toolchain: ucrt-x86_64 - name: Windows-MSVC - os: windows-latest + os: windows-2022 shell: pwsh kind: msvc steps: From a6e62299b282b9529589bd9a56a10f1ea7c3a8be Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:25:23 -0400 Subject: [PATCH 14/28] Add CI coverage collection and upload Enhance CI to collect and upload test coverage and results. Adds Python/uv setup, syncs Python tools, runs tests producing gtest XML, generates a gcovr coverage.xml, and uploads artifacts. Introduces a separate codecov job to download artifacts and push coverage/test results to Codecov (only for repos under LizardByte/ and non-MSVC platforms where applicable). Update CMakeLists to enable -fprofile-arcs/-ftest-coverage flags for non-MSVC test builds and ensure coverage flags are set before adding test sources. --- .github/workflows/ci.yml | 143 ++++++++++++++++++++++++++++++++++++++- CMakeLists.txt | 12 +++- 2 files changed, 152 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 044617f..9c94942 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,9 @@ concurrency: group: "${{ github.workflow }}-${{ github.ref }}" cancel-in-progress: true +env: + PYTHON_VERSION: '3.14' + jobs: build: name: Build (${{ matrix.name }}) @@ -31,18 +34,22 @@ jobs: kind: unix cc: gcc cxx: g++ + gcov_executable: gcov - name: Linux-Clang os: ubuntu-latest shell: bash kind: unix cc: clang cxx: clang++ + # Clang writes LLVM coverage notes, so gcovr needs llvm-cov's gcov compatibility mode. + gcov_executable: llvm-cov gcov - name: macOS os: macos-latest shell: bash kind: unix cc: clang cxx: clang++ + gcov_executable: gcov - name: Windows-MinGW-UCRT64 os: windows-latest shell: msys2 {0} @@ -51,6 +58,7 @@ jobs: cxx: g++ msystem: ucrt64 toolchain: ucrt-x86_64 + gcov_executable: gcov - name: Windows-MSVC os: windows-2022 shell: pwsh @@ -69,6 +77,7 @@ jobs: build-essential \ clang \ cmake \ + llvm \ ninja-build - name: Setup Dependencies macOS @@ -89,6 +98,29 @@ jobs: mingw-w64-${{ matrix.toolchain }}-ninja mingw-w64-${{ matrix.toolchain }}-toolchain + - name: Setup python + id: setup-python + if: matrix.kind != 'msvc' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup uv + if: matrix.kind != 'msvc' + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 + with: + enable-cache: true + + - name: Sync Python tools + if: matrix.kind != 'msvc' + env: + MSYS2_PATH_TYPE: inherit + UV_PYTHON: ${{ steps.setup-python.outputs.python-path }} + run: | + uv sync --project third-party/lizardbyte-common --locked --only-group test-c \ + --no-python-downloads \ + --no-install-project + - name: Configure if: matrix.kind != 'msvc' env: @@ -124,13 +156,40 @@ jobs: if: matrix.kind == 'msvc' run: cmake --build cmake-build-ci --config Debug --parallel 2 + - name: Prepare report directory + run: cmake -E make_directory cmake-build-ci/reports + - name: Run tests + id: test if: matrix.kind != 'msvc' - run: ctest --test-dir cmake-build-ci --output-on-failure + working-directory: cmake-build-ci/tests + run: ./test_libvirtualhid --gtest_color=yes --gtest_output=xml:../reports/junit.xml - name: Run tests MSVC + id: test_msvc if: matrix.kind == 'msvc' - run: ctest --test-dir cmake-build-ci --build-config Debug --output-on-failure + working-directory: cmake-build-ci/tests + run: .\Debug\test_libvirtualhid.exe --gtest_color=yes --gtest_output=xml:..\reports\junit.xml + + - name: Generate gcov report + id: test_report + if: >- + always() && + matrix.kind != 'msvc' && + (steps.test.outcome == 'success' || steps.test.outcome == 'failure') + working-directory: cmake-build-ci + env: + GCOV_EXECUTABLE: ${{ matrix.gcov_executable }} + MSYS2_PATH_TYPE: inherit + run: | + uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r ../src \ + --gcov-executable "${GCOV_EXECUTABLE}" \ + --exclude-noncode-lines \ + --exclude-throw-branches \ + --exclude-unreachable-branches \ + --verbose \ + --xml-pretty \ + -o reports/coverage.xml - name: Run Sunshine adapter example if: matrix.kind != 'msvc' @@ -159,3 +218,83 @@ jobs: name: install-${{ matrix.name }} path: cmake-build-ci/install if-no-files-found: error + + - name: Upload report artifact + if: >- + always() && + ( + steps.test_report.outcome == 'success' || + steps.test_msvc.outcome == 'success' || + steps.test_msvc.outcome == 'failure' + ) + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: reports-${{ matrix.name }} + path: cmake-build-ci/reports + if-no-files-found: error + + codecov: + name: Codecov-${{ matrix.flag }} + if: >- + always() && + (needs.build.result == 'success' || needs.build.result == 'failure') && + startsWith(github.repository, 'LizardByte/') + needs: build + permissions: + contents: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - build_name: Linux-GCC + flag: Linux-GCC + has_coverage: true + - build_name: Linux-Clang + flag: Linux-Clang + has_coverage: true + - build_name: macOS + flag: macOS + has_coverage: true + - build_name: Windows-MinGW-UCRT64 + flag: Windows-MinGW-UCRT64 + has_coverage: true + - build_name: Windows-MSVC + flag: Windows-MSVC + has_coverage: false + steps: + - name: Checkout + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Download report artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: reports-${{ matrix.build_name }} + path: _reports + + - name: Debug coverage file + if: matrix.has_coverage + run: cat _reports/coverage.xml + + - name: Upload test coverage + if: matrix.has_coverage + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./_reports/coverage.xml + report_type: coverage + flags: ${{ matrix.flag }} + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true + + - name: Upload test results + uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 + with: + disable_search: true + fail_ci_if_error: true + files: ./_reports/junit.xml + report_type: test_results + flags: ${{ matrix.flag }} + token: ${{ secrets.CODECOV_TOKEN }} + verbose: true diff --git a/CMakeLists.txt b/CMakeLists.txt index 462bb03..619d2ca 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,15 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) include(CMakePackageConfigHelpers) include(GNUInstallDirs) +# +# Additional setup for coverage +# https://gcovr.com/en/stable/guide/compiling.html#compiler-options +# +if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTS AND NOT CMAKE_CXX_COMPILER_ID STREQUAL "MSVC") + set(CMAKE_CXX_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") + set(CMAKE_C_FLAGS "-fprofile-arcs -ftest-coverage -ggdb -O0") +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") @@ -61,7 +70,8 @@ function(libvirtualhid_copy_mingw_runtime target_name) endfunction() # -# Library +# Library code is located here +# When building tests this must be after the coverage flags are set # add_subdirectory(src) From 90bfa7916f276cf9a77b2a72970399049b48a0b3 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:13:39 -0400 Subject: [PATCH 15/28] Add OpenCppCoverage for MSVC coverage Enable coverage collection for Windows MSVC runs using OpenCppCoverage. Adds OPENCPPCOVERAGE_VERSION env var and installs opencppcoverage via Chocolatey for MSVC matrix jobs. Replaces the direct test invocation with a PowerShell step that runs OpenCppCoverage to export a Cobertura coverage XML and produces the JUnit report. Adds a normalization step to rewrite absolute MSVC file paths to repository-relative paths in the generated coverage.xml (and set sources to '.') so coverage consumers work correctly. Marks the Windows-MSVC job as having coverage in the report matrix. --- .github/workflows/ci.yml | 69 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c94942..09b207c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,7 @@ concurrency: cancel-in-progress: true env: + OPENCPPCOVERAGE_VERSION: '0.9.9.0' PYTHON_VERSION: '3.14' jobs: @@ -98,6 +99,21 @@ jobs: mingw-w64-${{ matrix.toolchain }}-ninja mingw-w64-${{ matrix.toolchain }}-toolchain + - name: Setup Dependencies Windows MSVC + if: matrix.kind == 'msvc' + run: | + choco install opencppcoverage --version=${{ env.OPENCPPCOVERAGE_VERSION }} --yes --no-progress + + $openCppCoverageDir = "${env:ProgramFiles}\OpenCppCoverage" + if (!(Test-Path (Join-Path $openCppCoverageDir "OpenCppCoverage.exe"))) { + $openCppCoverageDir = "${env:ProgramFiles(x86)}\OpenCppCoverage" + } + if (!(Test-Path (Join-Path $openCppCoverageDir "OpenCppCoverage.exe"))) { + throw "OpenCppCoverage.exe was not found after Chocolatey install." + } + + $openCppCoverageDir | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Setup python id: setup-python if: matrix.kind != 'msvc' @@ -168,8 +184,55 @@ jobs: - name: Run tests MSVC id: test_msvc if: matrix.kind == 'msvc' - working-directory: cmake-build-ci/tests - run: .\Debug\test_libvirtualhid.exe --gtest_color=yes --gtest_output=xml:..\reports\junit.xml + run: | + $openCppCoverage = (Get-Command OpenCppCoverage.exe -ErrorAction SilentlyContinue).Source + if (!$openCppCoverage) { + $candidates = @( + "${env:ProgramFiles}\OpenCppCoverage\OpenCppCoverage.exe", + "${env:ProgramFiles(x86)}\OpenCppCoverage\OpenCppCoverage.exe" + ) + $openCppCoverage = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + } + if (!$openCppCoverage) { + throw "OpenCppCoverage.exe was not found." + } + + & $openCppCoverage ` + --sources "$env:GITHUB_WORKSPACE\src" ` + "--export_type=cobertura:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\coverage.xml" ` + --working_dir "$env:GITHUB_WORKSPACE\cmake-build-ci\tests" ` + -- ` + "$env:GITHUB_WORKSPACE\cmake-build-ci\tests\Debug\test_libvirtualhid.exe" ` + --gtest_color=yes ` + "--gtest_output=xml:$env:GITHUB_WORKSPACE\cmake-build-ci\reports\junit.xml" + + - name: Normalize MSVC coverage paths + if: >- + always() && + matrix.kind == 'msvc' && + (steps.test_msvc.outcome == 'success' || steps.test_msvc.outcome == 'failure') + run: | + $coveragePath = Join-Path $env:GITHUB_WORKSPACE "cmake-build-ci\reports\coverage.xml" + if (!(Test-Path $coveragePath)) { + return + } + + [xml] $coverage = Get-Content $coveragePath + $workspace = $env:GITHUB_WORKSPACE.Replace('\', '/') + foreach ($node in $coverage.SelectNodes('//*[@filename]')) { + $filename = $node.GetAttribute('filename').Replace('\', '/') + if ($filename.StartsWith("${workspace}/")) { + $filename = $filename.Substring($workspace.Length + 1) + } + + $node.SetAttribute('filename', $filename) + } + + foreach ($source in $coverage.SelectNodes('//source')) { + $source.InnerText = '.' + } + + $coverage.Save($coveragePath) - name: Generate gcov report id: test_report @@ -261,7 +324,7 @@ jobs: has_coverage: true - build_name: Windows-MSVC flag: Windows-MSVC - has_coverage: false + has_coverage: true steps: - name: Checkout uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 From 1680fa7c68091b0890d5905c9d1a1c449e3bfbdf Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 15:57:06 -0400 Subject: [PATCH 16/28] Add backend abstraction and Linux UHID backend Introduce an internal backend abstraction and platform backends, enabling real virtual HID devices on Linux via UHID. Add core/backend.hpp and core/backend.cpp with fake in-memory backend for tests, and wire backend gamepad lifecycle into runtime (create/submit/close and output callbacks). Implement a Linux UHID backend (src/platform/linux/uhid_backend.cpp) that opens /dev/uhid, handles UHID events, and runs a reader thread; add an unsupported_backend fallback for non-Linux platforms. Update CMake to detect when threads are required, conditionally link/find Threads, and select the appropriate platform source files. Update the exported config to find Threads when used. Update tests to assert platform-default capabilities and add an opt-in UHID integration smoke test gated by an environment variable. Document UHID usage and a sample udev rule in README. Also improve runtime cleanup and error propagation for backend operations. --- CMakeLists.txt | 5 + README.md | 129 ++++++----- cmake/libvirtualhid-config.cmake.in | 6 + src/CMakeLists.txt | 20 +- src/core/backend.cpp | 71 ++++++ src/core/backend.hpp | 132 +++++++++++ src/core/runtime.cpp | 81 ++++--- src/platform/linux/uhid_backend.cpp | 319 +++++++++++++++++++++++++++ src/platform/unsupported_backend.cpp | 42 ++++ tests/unit/test_runtime.cpp | 44 +++- 10 files changed, 766 insertions(+), 83 deletions(-) create mode 100644 src/core/backend.cpp create mode 100644 src/core/backend.hpp create mode 100644 src/platform/linux/uhid_backend.cpp create mode 100644 src/platform/unsupported_backend.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 619d2ca..3617ee6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -38,6 +38,11 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON) include(CMakePackageConfigHelpers) include(GNUInstallDirs) +set(LIBVIRTUALHID_USES_THREADS OFF) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + set(LIBVIRTUALHID_USES_THREADS ON) +endif() + # # Additional setup for coverage # https://gcovr.com/en/stable/guide/compiling.html#compiler-options diff --git a/README.md b/README.md index 6df3490..c96b863 100644 --- a/README.md +++ b/README.md @@ -58,12 +58,12 @@ That means a consuming application can compile the C++ library as part of its own build, but compiling the library alone is not enough to create virtual HID devices on Windows. The project should provide: -- A CMake-built C++ client library for consumers. -- A Windows driver package containing the INF, signed catalog, UMDF driver DLL, +- [x] A CMake-built C++ client library for consumers. +- [ ] A Windows driver package containing the INF, signed catalog, UMDF driver DLL, and any helper/control component needed by the backend. -- Install/uninstall helpers suitable for developer machines and application +- [ ] Install/uninstall helpers suitable for developer machines and application installers. -- A path for projects to either build the driver package themselves with the +- [ ] A path for projects to either build the driver package themselves with the Windows SDK/WDK or redistribute an official prebuilt, signed package. The public API should not expose these details. Consumers should create a @@ -95,6 +95,23 @@ Linux deployment should be documentation and permissions focused: users need access to `/dev/uinput` and/or `/dev/uhid`, usually through udev rules or group membership. No out-of-tree kernel module should be required. +The current Linux MVP uses `uhid` for `BackendKind::platform_default`. When +`/dev/uhid` is readable and writable, the backend reports `linux-uhid` with +gamepad and output-report support. When the node is missing or permission is +denied, the same backend remains selectable but reports the gamepad capability +as unavailable and returns `backend_unavailable` from gamepad creation. + +A minimal udev rule for hosts that grant controller creation to the `input` +group is: + +```udev +KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess" +``` + +The Linux UHID smoke test is opt-in because it creates a real virtual gamepad. +Run it with `LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS=1` on a Linux host +where the current user can open `/dev/uhid`. + The XTest fallback should not be treated as a gamepad backend. It can cover keyboard and mouse injection on X11, but it does not create virtual HID devices, does not help on Wayland, and should not replace `uhid`/`uinput` for gamepads. @@ -158,49 +175,49 @@ Sunshine is the first consumer to design against. The initial implementation should cover Sunshine's active input behavior before optimizing for unrelated consumers: -- CMake consumption must work as a vendored dependency under Sunshine's +- [x] CMake consumption must work as a vendored dependency under Sunshine's `third-party` tree. -- The API must support multiple client-relative and global gamepad indexes so +- [x] The API must support multiple client-relative and global gamepad indexes so Sunshine can preserve stable controller lifecycles across arrival, update, feedback, and removal events. -- Built-in profiles should cover Sunshine's current gamepad choices: automatic +- [x] Built-in profiles should cover Sunshine's current gamepad choices: automatic selection, Xbox One-style, DualSense-style, and Switch Pro-style devices. Xbox 360 can remain useful as a compatibility profile and test target. -- Controller metadata must be rich enough for Sunshine's selection rules: +- [x] Controller metadata must be rich enough for Sunshine's selection rules: client controller type, motion sensor capability, touchpad capability, RGB LED support, battery state, and per-controller identity data. -- Output callbacks must carry rumble first, then RGB LED, adaptive trigger, +- [ ] Output callbacks must carry rumble first, then RGB LED, adaptive trigger, motion activation, and raw output report data where the selected profile supports it. -- Keyboard and mouse APIs should map cleanly to Sunshine's current relative +- [ ] Keyboard and mouse APIs should map cleanly to Sunshine's current relative mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, and Unicode paths. -- Linux fallback behavior should match Sunshine's operational expectation: +- [ ] Linux fallback behavior should match Sunshine's operational expectation: prefer real virtual devices through `uhid`/`uinput`; only use XTest for keyboard/mouse when virtual device creation fails and X11 is available. -- The library must not own Sunshine's network protocol, Moonlight packet +- [x] The library must not own Sunshine's network protocol, Moonlight packet parsing, configuration system, or feedback queue. It should expose the device primitives Sunshine needs to keep that ownership in Sunshine. ## Tooling and Dependency Plan -- Use CMake as the only build system for the core library. -- Follow the LizardByte `tray` and `libdisplaydevice` pattern: top-level-only +- [x] Use CMake as the only build system for the core library. +- [x] Follow the LizardByte `tray` and `libdisplaydevice` pattern: top-level-only `BUILD_TESTS` and `BUILD_DOCS` options, reusable library targets, and tests that do not force themselves on parent projects. -- Put all submodules under `third-party`. -- Add GoogleTest as a submodule at `third-party/googletest`; do not download it +- [x] Put all submodules under `third-party`. +- [x] Add GoogleTest as a submodule at `third-party/googletest`; do not download it during configure. -- Add the LizardByte Doxygen configuration as a submodule at +- [x] Add the LizardByte Doxygen configuration as a submodule at `third-party/doxyconfig` and use it for local docs and Read the Docs builds. -- Expose `libvirtualhid::libvirtualhid` as the main CMake target. -- Keep the public headers under `include/libvirtualhid` and the implementation +- [x] Expose `libvirtualhid::libvirtualhid` as the main CMake target. +- [x] Keep the public headers under `include/libvirtualhid` and the implementation split into shared core code plus platform-specific backends. -- Add Windows CI coverage for the client library with MSVC and MinGW/UCRT64. - Add separate WDK/MSVC validation for the driver package once driver sources - exist. -- Add Linux CI coverage for GCC and Clang, with integration tests gated behind +- [x] Add Windows CI coverage for the client library with MSVC and MinGW/UCRT64. +- [x] Add Linux CI coverage for GCC and Clang, with integration tests gated behind explicit availability of `/dev/uinput`, `/dev/uhid`, or X11/XTest. +- [ ] Add separate WDK/MSVC validation for the driver package once driver sources + exist. ## Repository Plan @@ -226,72 +243,72 @@ third-party/googletest/ GoogleTest submodule ### Phase 1: Project Foundation -- Add CMake project scaffolding and exported target +- [x] Add CMake project scaffolding and exported target `libvirtualhid::libvirtualhid`. -- Define the public C++ API, error model, device lifecycle, and ownership rules. -- Add a fake in-memory backend so API tests can run on every platform. -- Add GoogleTest as a submodule under `third-party/googletest` and wire tests +- [x] Define the public C++ API, error model, device lifecycle, and ownership rules. +- [x] Add a fake in-memory backend so API tests can run on every platform. +- [x] Add GoogleTest as a submodule under `third-party/googletest` and wire tests using the same top-level-only pattern as `tray` and `libdisplaydevice`. -- Add Doxygen documentation wiring with `third-party/doxyconfig`, a project +- [x] Add Doxygen documentation wiring with `third-party/doxyconfig`, a project `docs/Doxyfile`, and Read the Docs configuration. -- Add CI using the `libdisplaydevice` workflow pattern for Linux GCC, Linux +- [x] Add CI using the `libdisplaydevice` workflow pattern for Linux GCC, Linux Clang, macOS, Windows MinGW/UCRT64, and Windows MSVC configure/build/test coverage. -- Add descriptor/profile models for at least Xbox 360, Xbox Series, DualSense, +- [x] Add descriptor/profile models for at least Xbox 360, Xbox Series, DualSense, and a generic HID gamepad. -- Add unit tests for state normalization and HID report packing. -- Add a Sunshine-oriented example or adapter test that exercises controller +- [x] Add unit tests for state normalization and HID report packing. +- [x] Add a Sunshine-oriented example or adapter test that exercises controller arrival, state updates, output feedback, and removal without depending on Sunshine internals. ### Phase 2: Linux MVP -- Implement gamepad creation over `uhid` for descriptor-driven controllers. -- Add `uinput` support for keyboard and mouse once the gamepad path is stable. -- Support output report callbacks for rumble and profile-specific feedback. -- Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's +- [x] Implement gamepad creation over `uhid` for descriptor-driven controllers. +- [ ] Add `uinput` support for keyboard and mouse once the gamepad path is stable. +- [ ] Support output report callbacks for rumble and profile-specific feedback. +- [ ] Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's historical legacy input implementation as the reference point. -- Add examples and integration tests that validate SDL/HIDAPI discovery where +- [ ] Add examples and integration tests that validate SDL/HIDAPI discovery where available. -- Document required Linux permissions and sample udev rules. +- [x] Document required Linux permissions and sample udev rules. ### Phase 3: Windows MVP -- Build a UMDF2 HID minidriver package with CMake/WDK integration. -- Implement the Windows backend and control channel between the C++ library and +- [ ] Build a UMDF2 HID minidriver package with CMake/WDK integration. +- [ ] Implement the Windows backend and control channel between the C++ library and the UMDF driver. -- Keep the client library buildable with MSVC and MinGW/UCRT64. Keep the driver +- [x] Keep the client library buildable with MSVC and MinGW/UCRT64. Keep the driver package on the Microsoft WDK toolchain. -- Add install/uninstall tooling for developer workflows. -- Support hot-plug, multi-controller instances, and output report callbacks. -- Validate visibility through DirectInput, XInput where applicable, SDL/HIDAPI, +- [ ] Add install/uninstall tooling for developer workflows. +- [ ] Support hot-plug, multi-controller instances, and output report callbacks. +- [ ] Validate visibility through DirectInput, XInput where applicable, SDL/HIDAPI, Windows.Gaming.Input/GameInput, and browser Gamepad API. ### Phase 4: API Parity and Packaging -- Keep one API surface across Windows and Linux, with capability queries for +- [ ] Keep one API surface across Windows and Linux, with capability queries for platform limitations instead of platform-specific methods. -- Add installed CMake package support and `FetchContent` documentation. -- Add CI for formatting, static analysis, CMake configure/build, unit tests, and +- [ ] Add installed CMake package support and `FetchContent` documentation. +- [x] Add CI for formatting, static analysis, CMake configure/build, unit tests, and platform smoke tests. -- Decide whether official Windows releases should ship signed driver packages +- [ ] Decide whether official Windows releases should ship signed driver packages in addition to source. ### Phase 5: macOS Research and Backend -- Prototype macOS virtual HID creation and report submission. -- Document signing, entitlement, and installer constraints. -- Add macOS backend behind the existing public API. -- Add macOS discovery and smoke-test coverage. +- [ ] Prototype macOS virtual HID creation and report submission. +- [ ] Document signing, entitlement, and installer constraints. +- [ ] Add macOS backend behind the existing public API. +- [ ] Add macOS discovery and smoke-test coverage. ## Testing Plan -- Unit test descriptor generation, report packing, axis scaling, button mapping, +- [ ] Unit test descriptor generation, report packing, axis scaling, button mapping, and output report parsing. -- Run lifecycle tests for create, submit, output callback, destroy, repeated +- [ ] Run lifecycle tests for create, submit, output callback, destroy, repeated hot-plug, and process shutdown cleanup. -- Validate multi-controller behavior and stable ordering. -- Test against real consumers where practical: SDL, HIDAPI, browser Gamepad API, +- [ ] Validate multi-controller behavior and stable ordering. +- [ ] Test against real consumers where practical: SDL, HIDAPI, browser Gamepad API, DirectInput/XInput/GameInput on Windows, and evdev/libinput tooling on Linux. ## License diff --git a/cmake/libvirtualhid-config.cmake.in b/cmake/libvirtualhid-config.cmake.in index 6964c39..8b67467 100644 --- a/cmake/libvirtualhid-config.cmake.in +++ b/cmake/libvirtualhid-config.cmake.in @@ -1,5 +1,11 @@ @PACKAGE_INIT@ +include(CMakeFindDependencyMacro) + +if(@LIBVIRTUALHID_USES_THREADS@) + find_dependency(Threads) +endif() + include("${CMAKE_CURRENT_LIST_DIR}/libvirtualhid-targets.cmake") check_required_components(libvirtualhid) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0bf6e60..387f8a0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,15 +3,33 @@ add_library(libvirtualhid::libvirtualhid ALIAS ${PROJECT_NAME}) target_sources(${PROJECT_NAME} PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/core/backend.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/runtime.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/core/types.cpp") +if(LIBVIRTUALHID_USES_THREADS) + find_package(Threads REQUIRED) + + target_sources(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/platform/linux/uhid_backend.cpp") + target_link_libraries(${PROJECT_NAME} + PUBLIC + Threads::Threads) +else() + target_sources(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/platform/unsupported_backend.cpp") +endif() + target_include_directories(${PROJECT_NAME} PUBLIC $ - $) + $ + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}") target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_20) set_target_properties(${PROJECT_NAME} PROPERTIES diff --git a/src/core/backend.cpp b/src/core/backend.cpp new file mode 100644 index 0000000..5953707 --- /dev/null +++ b/src/core/backend.cpp @@ -0,0 +1,71 @@ +/** + * @file src/core/backend.cpp + * @brief Internal fake backend and backend selection definitions. + */ + +// standard includes +#include +#include +#include + +// local includes +#include "core/backend.hpp" + +namespace lvh::detail { + namespace { + + /** + * @brief In-memory gamepad backend used for portable tests. + */ + class FakeGamepad final: public BackendGamepad { + public: + Status submit(const std::vector & /*report*/) override { + return Status::success(); + } + + void set_output_callback(OutputCallback callback) override { + output_callback_ = std::move(callback); + } + + Status close() override { + return Status::success(); + } + + private: + OutputCallback output_callback_; + }; + + /** + * @brief In-memory backend used by default for API validation. + */ + class FakeBackend final: public Backend { + public: + FakeBackend() { + capabilities_.backend_name = "fake"; + capabilities_.supports_gamepad = true; + capabilities_.supports_output_reports = true; + } + + const BackendCapabilities &capabilities() const override { + return capabilities_; + } + + BackendGamepadCreationResult create_gamepad(DeviceId /*id*/, const CreateGamepadOptions & /*options*/) override { + return {Status::success(), std::make_unique()}; + } + + private: + BackendCapabilities capabilities_; + }; + + } // namespace + + std::unique_ptr create_backend(BackendKind kind) { + if (kind == BackendKind::fake) { + return std::make_unique(); + } + + return create_platform_backend(); + } + +} // namespace lvh::detail diff --git a/src/core/backend.hpp b/src/core/backend.hpp new file mode 100644 index 0000000..dcbc7c4 --- /dev/null +++ b/src/core/backend.hpp @@ -0,0 +1,132 @@ +/** + * @file src/core/backend.hpp + * @brief Internal backend interfaces for virtual HID implementations. + */ +#pragma once + +// standard includes +#include +#include +#include + +// local includes +#include + +namespace lvh::detail { + + /** + * @brief Backend-owned gamepad device implementation. + */ + class BackendGamepad { + public: + BackendGamepad(const BackendGamepad &) = delete; + BackendGamepad &operator=(const BackendGamepad &) = delete; + BackendGamepad(BackendGamepad &&) noexcept = delete; + BackendGamepad &operator=(BackendGamepad &&) noexcept = delete; + + /** + * @brief Destroy the backend gamepad. + */ + virtual ~BackendGamepad() = default; + + /** + * @brief Submit a packed input report to the backend. + * + * @param report Packed HID input report. + * @return Submit status. + */ + virtual Status submit(const std::vector &report) = 0; + + /** + * @brief Register a callback for backend output reports. + * + * @param callback Output callback. + */ + virtual void set_output_callback(OutputCallback callback) = 0; + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual Status close() = 0; + + protected: + BackendGamepad() = default; + }; + + /** + * @brief Result returned by an internal backend gamepad creation request. + */ + struct BackendGamepadCreationResult { + /** + * @brief Creation status. + */ + Status status; + + /** + * @brief Backend device when creation succeeds. + */ + std::unique_ptr gamepad; + + /** + * @brief Check whether creation succeeded. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && gamepad != nullptr; + } + }; + + /** + * @brief Runtime-selected backend implementation. + */ + class Backend { + public: + Backend(const Backend &) = delete; + Backend &operator=(const Backend &) = delete; + Backend(Backend &&) noexcept = delete; + Backend &operator=(Backend &&) noexcept = delete; + + /** + * @brief Destroy the backend. + */ + virtual ~Backend() = default; + + /** + * @brief Get backend capabilities. + * + * @return Backend capabilities. + */ + virtual const BackendCapabilities &capabilities() const = 0; + + /** + * @brief Create a backend gamepad device. + * + * @param id Runtime-assigned device id. + * @param options Gamepad creation options. + * @return Backend gamepad creation result. + */ + virtual BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) = 0; + + protected: + Backend() = default; + }; + + /** + * @brief Create a backend for the requested backend kind. + * + * @param kind Requested backend kind. + * @return Backend implementation. + */ + std::unique_ptr create_backend(BackendKind kind); + + /** + * @brief Create the platform default backend for the current operating system. + * + * @return Platform default backend implementation. + */ + std::unique_ptr create_platform_backend(); + +} // namespace lvh::detail diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 36fa6fb..20ba4f4 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -5,22 +5,31 @@ // standard includes #include +#include #include #include // local includes +#include "core/backend.hpp" + #include #include namespace lvh::detail { struct GamepadDevice { - explicit GamepadDevice(DeviceId device_id, CreateGamepadOptions create_options): + explicit GamepadDevice( + DeviceId device_id, + CreateGamepadOptions create_options, + std::unique_ptr backend_gamepad + ): id {device_id}, - options {std::move(create_options)} {} + options {std::move(create_options)}, + backend {std::move(backend_gamepad)} {} DeviceId id; CreateGamepadOptions options; + std::unique_ptr backend; bool open = true; GamepadState last_state; std::vector last_report; @@ -33,21 +42,11 @@ namespace lvh::detail { public: explicit RuntimeState(RuntimeOptions runtime_options): options {runtime_options}, - caps {make_capabilities(runtime_options.backend)} {} - - static BackendCapabilities make_capabilities(BackendKind kind) { - BackendCapabilities capabilities; - if (kind == BackendKind::fake) { - capabilities.backend_name = "fake"; - capabilities.supports_gamepad = true; - capabilities.supports_output_reports = true; - } else { - capabilities.backend_name = "platform-default-unimplemented"; - } - return capabilities; - } + backend {create_backend(runtime_options.backend)}, + caps {backend->capabilities()} {} RuntimeOptions options; + std::unique_ptr backend; BackendCapabilities caps; DeviceId next_device_id = 1; std::vector> gamepads; @@ -117,8 +116,14 @@ namespace lvh { if (!device.open) { return Status::success(); } + + auto status = Status::success(); + if (device.backend) { + status = device.backend->close(); + } + device.open = false; - return Status::success(); + return status; }); } @@ -133,6 +138,12 @@ namespace lvh { return Status::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); } + if (device.backend) { + if (const auto status = device.backend->submit(report); !status.ok()) { + return status; + } + } + device.last_state = reports::normalize_state(state); device.last_report = std::move(report); ++device.submitted_reports; @@ -143,6 +154,9 @@ namespace lvh { void Gamepad::set_output_callback(OutputCallback callback) { with_device(device_, [&callback](auto &device) { device.output_callback = std::move(callback); + if (device.backend) { + device.backend->set_output_callback(device.output_callback); + } return 0; }); } @@ -189,7 +203,12 @@ namespace lvh { Runtime::Runtime(Runtime &&) noexcept = default; Runtime &Runtime::operator=(Runtime &&) noexcept = default; - Runtime::~Runtime() = default; + + Runtime::~Runtime() { + if (state_) { + close_all(); + } + } std::unique_ptr Runtime::create(RuntimeOptions options) { return std::unique_ptr {new Runtime {options}}; @@ -210,18 +229,27 @@ namespace lvh { } GamepadCreationResult Runtime::create_gamepad(const CreateGamepadOptions &options) { - if (state_->options.backend != BackendKind::fake) { - return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; - } - if (const auto validation = validate_gamepad_options(options); !validation.ok()) { return {validation, nullptr}; } - std::lock_guard lock {state_->mutex}; - const auto id = state_->next_device_id++; - auto device = std::make_shared(id, options); - state_->gamepads.emplace_back(device); + DeviceId id; + { + std::lock_guard lock {state_->mutex}; + id = state_->next_device_id++; + } + + auto backend_result = state_->backend->create_gamepad(id, options); + if (!backend_result) { + return {std::move(backend_result.status), nullptr}; + } + + auto device = std::make_shared(id, options, std::move(backend_result.gamepad)); + { + std::lock_guard lock {state_->mutex}; + state_->gamepads.emplace_back(device); + } + return {Status::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; } @@ -243,6 +271,9 @@ namespace lvh { for (const auto &weak_device : state_->gamepads) { if (const auto device = weak_device.lock()) { std::lock_guard device_lock {device->mutex}; + if (device->backend) { + static_cast(device->backend->close()); + } device->open = false; } } diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp new file mode 100644 index 0000000..552df43 --- /dev/null +++ b/src/platform/linux/uhid_backend.cpp @@ -0,0 +1,319 @@ +/** + * @file src/platform/linux/uhid_backend.cpp + * @brief Linux UHID backend definitions. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include +#ifndef __user + #define __user +#endif +#include +#include +#include + +// local includes +#include "core/backend.hpp" + +namespace lvh::detail { + namespace { + + constexpr auto uhid_path = "/dev/uhid"; + constexpr auto poll_timeout_ms = 100; + + std::string errno_message(int error) { + return std::error_code(error, std::generic_category()).message(); + } + + Status system_error_status(ErrorCode code, const std::string &operation, int error) { + return Status::failure(code, operation + ": " + errno_message(error)); + } + + bool can_access_uhid() { + return ::access(uhid_path, R_OK | W_OK) == 0; + } + + std::uint16_t to_uhid_bus(BusType bus_type) { + if (bus_type == BusType::bluetooth) { + return BUS_BLUETOOTH; + } + return BUS_USB; + } + + template + void copy_string(__u8 (&destination)[Size], const std::string &source) { + const auto length = std::min(source.size(), Size - 1); + std::memcpy(destination, source.data(), length); + destination[length] = 0; + } + + /** + * @brief Backend gamepad backed by one Linux UHID file descriptor. + */ + class UhidGamepad final: public BackendGamepad { + public: + explicit UhidGamepad(int file_descriptor): + fd_ {file_descriptor} {} + + UhidGamepad(const UhidGamepad &) = delete; + UhidGamepad &operator=(const UhidGamepad &) = delete; + UhidGamepad(UhidGamepad &&) noexcept = delete; + UhidGamepad &operator=(UhidGamepad &&) noexcept = delete; + + ~UhidGamepad() override { + static_cast(close()); + } + + Status create(DeviceId id, const CreateGamepadOptions &options) { + uhid_event event {}; + auto &request = event.u.create2; + + if (options.profile.report_descriptor.size() > sizeof(request.rd_data)) { + return Status::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID"); + } + + event.type = UHID_CREATE2; + copy_string(request.name, options.profile.name); + copy_string(request.phys, "libvirtualhid/uhid/" + std::to_string(id)); + copy_string(request.uniq, options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id); + request.rd_size = static_cast(options.profile.report_descriptor.size()); + request.bus = to_uhid_bus(options.profile.bus_type); + request.vendor = options.profile.vendor_id; + request.product = options.profile.product_id; + request.version = options.profile.version; + std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size()); + + if (const auto status = write_event(event); !status.ok()) { + return status; + } + + running_ = true; + reader_ = std::thread {[this]() { + read_loop(); + }}; + return Status::success(); + } + + Status submit(const std::vector &report) override { + if (!open_) { + return Status::failure(ErrorCode::device_closed, "UHID gamepad is closed"); + } + + uhid_event event {}; + if (report.size() > sizeof(event.u.input2.data)) { + return Status::failure(ErrorCode::invalid_argument, "HID input report is too large for UHID"); + } + + event.type = UHID_INPUT2; + event.u.input2.size = static_cast(report.size()); + std::memcpy(event.u.input2.data, report.data(), report.size()); + return write_event(event); + } + + void set_output_callback(OutputCallback callback) override { + std::lock_guard lock {callback_mutex_}; + output_callback_ = std::move(callback); + } + + Status close() override { + if (!open_.exchange(false)) { + return Status::success(); + } + + running_ = false; + + auto status = Status::success(); + if (fd_ >= 0) { + uhid_event event {}; + event.type = UHID_DESTROY; + status = write_event(event); + } + + if (reader_.joinable()) { + reader_.join(); + } + + if (fd_ >= 0) { + if (::close(fd_) != 0 && status.ok()) { + status = system_error_status(ErrorCode::backend_failure, "failed to close /dev/uhid", errno); + } + fd_ = -1; + } + + return status; + } + + private: + Status write_event(const uhid_event &event) { + std::lock_guard lock {write_mutex_}; + if (fd_ < 0) { + return Status::failure(ErrorCode::device_closed, "UHID file descriptor is closed"); + } + + const auto result = ::write(fd_, &event, sizeof(event)); + if (result < 0) { + return system_error_status(ErrorCode::backend_failure, "failed to write UHID event", errno); + } + if (static_cast(result) != sizeof(event)) { + return Status::failure(ErrorCode::backend_failure, "short write while sending UHID event"); + } + + return Status::success(); + } + + void read_loop() { + while (running_) { + pollfd descriptor {}; + descriptor.fd = fd_; + descriptor.events = POLLIN; + + const auto result = ::poll(&descriptor, 1, poll_timeout_ms); + if (result < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (result == 0) { + continue; + } + if ((descriptor.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) { + break; + } + if ((descriptor.revents & POLLIN) == 0) { + continue; + } + + uhid_event event {}; + const auto read_result = ::read(fd_, &event, sizeof(event)); + if (read_result < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { + continue; + } + break; + } + if (read_result == 0) { + break; + } + + handle_event(event); + } + } + + void handle_event(const uhid_event &event) { + switch (event.type) { + case UHID_OUTPUT: + dispatch_output(event.u.output); + break; + case UHID_GET_REPORT: + send_get_report_reply(event.u.get_report.id); + break; + case UHID_SET_REPORT: + send_set_report_reply(event.u.set_report.id); + break; + default: + break; + } + } + + void dispatch_output(const uhid_output_req &request) { + OutputCallback callback; + { + std::lock_guard lock {callback_mutex_}; + callback = output_callback_; + } + + if (!callback) { + return; + } + + GamepadOutput output; + output.kind = GamepadOutputKind::raw_report; + const auto size = std::min(request.size, sizeof(request.data)); + output.raw_report.assign(request.data, request.data + size); + callback(output); + } + + void send_get_report_reply(std::uint32_t id) { + uhid_event event {}; + event.type = UHID_GET_REPORT_REPLY; + event.u.get_report_reply.id = id; + event.u.get_report_reply.err = EIO; + static_cast(write_event(event)); + } + + void send_set_report_reply(std::uint32_t id) { + uhid_event event {}; + event.type = UHID_SET_REPORT_REPLY; + event.u.set_report_reply.id = id; + event.u.set_report_reply.err = EIO; + static_cast(write_event(event)); + } + + int fd_ = -1; + std::atomic_bool open_ = true; + std::atomic_bool running_ = false; + std::thread reader_; + std::mutex write_mutex_; + std::mutex callback_mutex_; + OutputCallback output_callback_; + }; + + /** + * @brief Linux platform backend backed by UHID. + */ + class LinuxUhidBackend final: public Backend { + public: + LinuxUhidBackend() { + const auto uhid_accessible = can_access_uhid(); + capabilities_.backend_name = "linux-uhid"; + capabilities_.supports_virtual_hid = uhid_accessible; + capabilities_.supports_gamepad = uhid_accessible; + capabilities_.supports_output_reports = uhid_accessible; + } + + const BackendCapabilities &capabilities() const override { + return capabilities_; + } + + BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { + const auto fd = ::open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; + } + + auto gamepad = std::make_unique(fd); + if (const auto status = gamepad->create(id, options); !status.ok()) { + static_cast(gamepad->close()); + return {status, nullptr}; + } + + return {Status::success(), std::move(gamepad)}; + } + + private: + BackendCapabilities capabilities_; + }; + + } // namespace + + std::unique_ptr create_platform_backend() { + return std::make_unique(); + } + +} // namespace lvh::detail diff --git a/src/platform/unsupported_backend.cpp b/src/platform/unsupported_backend.cpp new file mode 100644 index 0000000..a8f12ca --- /dev/null +++ b/src/platform/unsupported_backend.cpp @@ -0,0 +1,42 @@ +/** + * @file src/platform/unsupported_backend.cpp + * @brief Unsupported platform backend definitions. + */ + +// standard includes +#include + +// local includes +#include "core/backend.hpp" + +namespace lvh::detail { + namespace { + + /** + * @brief Platform backend used when no native implementation exists. + */ + class UnsupportedBackend final: public Backend { + public: + UnsupportedBackend() { + capabilities_.backend_name = "platform-default-unimplemented"; + } + + const BackendCapabilities &capabilities() const override { + return capabilities_; + } + + BackendGamepadCreationResult create_gamepad(DeviceId /*id*/, const CreateGamepadOptions & /*options*/) override { + return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + private: + BackendCapabilities capabilities_; + }; + + } // namespace + + std::unique_ptr create_platform_backend() { + return std::make_unique(); + } + +} // namespace lvh::detail diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index e02b6bf..7e80c24 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -3,6 +3,10 @@ * @brief Unit tests for runtime and virtual gamepad handles. */ +// standard includes +#include +#include + // local includes #include "fixtures/fixtures.hpp" @@ -18,18 +22,27 @@ TEST(RuntimeTest, FakeBackendReportsCapabilities) { EXPECT_FALSE(runtime->capabilities().requires_installed_driver); } -TEST(RuntimeTest, PlatformDefaultIsUnavailableInPhaseOne) { +TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { lvh::RuntimeOptions options; options.backend = lvh::BackendKind::platform_default; auto runtime = lvh::Runtime::create(options); EXPECT_EQ(runtime->backend_kind(), lvh::BackendKind::platform_default); + +#if defined(__linux__) + EXPECT_EQ(runtime->capabilities().backend_name, "linux-uhid"); + EXPECT_FALSE(runtime->capabilities().supports_keyboard); + EXPECT_FALSE(runtime->capabilities().supports_mouse); + EXPECT_FALSE(runtime->capabilities().supports_xtest_fallback); + EXPECT_FALSE(runtime->capabilities().requires_installed_driver); +#else EXPECT_EQ(runtime->capabilities().backend_name, "platform-default-unimplemented"); EXPECT_FALSE(runtime->capabilities().supports_gamepad); auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); EXPECT_FALSE(created); EXPECT_EQ(created.status.code(), lvh::ErrorCode::backend_unavailable); +#endif } TEST(RuntimeTest, CreatesSubmitsAndClosesGamepad) { @@ -81,3 +94,32 @@ TEST(RuntimeTest, DispatchesOutputCallback) { EXPECT_EQ(received.low_frequency_rumble, 123); EXPECT_EQ(received.high_frequency_rumble, 456); } + +TEST(RuntimeTest, LinuxUhidSmokeTestWhenExplicitlyEnabled) { +#if defined(__linux__) + const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS"); + if (enabled == nullptr || std::string_view {enabled} != "1") { + GTEST_SKIP() << "set LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS=1 to exercise /dev/uhid"; + } + + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(options); + if (!runtime->capabilities().supports_gamepad) { + GTEST_SKIP() << "/dev/uhid is not accessible"; + } + + auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); + ASSERT_TRUE(created) << created.status.message(); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.left_stick.x = 0.25F; + state.right_trigger = 1.0F; + + EXPECT_TRUE(created.gamepad->submit(state).ok()); + EXPECT_TRUE(created.gamepad->close().ok()); +#else + GTEST_SKIP() << "UHID is only available on Linux"; +#endif +} From 8339b480d3eff59759b2912990f5c4c907442dfe Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:08:44 -0400 Subject: [PATCH 17/28] Add output report (rumble) parsing and support Add support for backend output reports and rumble feedback. Introduces DeviceProfile::output_report_size and a parse_output_report declaration in report.hpp. Implements parse_output_report (and a read_u16 helper) to translate raw HID output reports into the profile-neutral GamepadOutput (rumble parsing when report_id and size match). Updates profiles to generate a vendor-defined output region in the HID descriptor when supports_rumble is true and set an output_report_size for those profiles. Updates the UHID backend to store the active profile, route UHID output/SET_REPORT data to the parser and callback, and return success for SET_REPORT replies. Adds unit tests validating profile output report exposure and parsing behavior, and marks the README checklist item as completed. --- README.md | 2 +- include/libvirtualhid/report.hpp | 11 +++++++- include/libvirtualhid/types.hpp | 5 ++++ src/core/profiles.cpp | 39 ++++++++++++++++++++++++++--- src/core/report.cpp | 24 ++++++++++++++++++ src/platform/linux/uhid_backend.cpp | 22 +++++++++------- tests/unit/test_profiles.cpp | 15 +++++++++++ tests/unit/test_report.cpp | 38 ++++++++++++++++++++++++++++ 8 files changed, 141 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c96b863..95f5f8f 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ third-party/googletest/ GoogleTest submodule - [x] Implement gamepad creation over `uhid` for descriptor-driven controllers. - [ ] Add `uinput` support for keyboard and mouse once the gamepad path is stable. -- [ ] Support output report callbacks for rumble and profile-specific feedback. +- [x] Support output report callbacks for rumble and profile-specific feedback. - [ ] Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's historical legacy input implementation as the reference point. - [ ] Add examples and integration tests that validate SDL/HIDAPI discovery where diff --git a/include/libvirtualhid/report.hpp b/include/libvirtualhid/report.hpp index 5ad4905..56ce466 100644 --- a/include/libvirtualhid/report.hpp +++ b/include/libvirtualhid/report.hpp @@ -1,6 +1,6 @@ /** * @file include/libvirtualhid/report.hpp - * @brief Gamepad state normalization and report packing declarations. + * @brief Gamepad state normalization, report packing, and output parsing declarations. */ #pragma once @@ -70,4 +70,13 @@ namespace lvh::reports { */ std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state); + /** + * @brief Parse a backend output report into the profile-neutral output model. + * + * @param profile Device profile used for report identity and capabilities. + * @param report Raw HID output report bytes. + * @return Parsed gamepad output. Unrecognized reports are returned as raw reports. + */ + GamepadOutput parse_output_report(const DeviceProfile &profile, const std::vector &report); + } // namespace lvh::reports diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index 81c76fe..f0837d9 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -265,6 +265,11 @@ namespace lvh { */ std::size_t input_report_size = 0; + /** + * @brief Expected packed output report size in bytes, or `0` when none is defined. + */ + std::size_t output_report_size = 0; + /** * @brief Human-readable device name. */ diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 5a0cbac..a9c8c7a 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -16,8 +16,10 @@ namespace lvh::profiles { constexpr std::size_t common_report_size = 14; - std::vector make_gamepad_report_descriptor(std::uint8_t report_id) { - return { + constexpr std::size_t common_output_report_size = 5; + + std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) { + std::vector descriptor { 0x05, 0x01, // Usage Page (Generic Desktop) 0x09, @@ -110,8 +112,34 @@ namespace lvh::profiles { 0x35, // Usage (Rz) 0x81, 0x02, // Input (Data,Var,Abs) - 0xC0, // End Collection }; + + if (supports_rumble) { + descriptor.insert( + descriptor.end(), + { + 0x06, + 0x00, + 0xFF, // Usage Page (Vendor Defined) + 0x09, + 0x01, // Usage (Vendor Usage 1) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x04, // Report Count (4) + 0x91, + 0x02, // Output (Data,Var,Abs) + } + ); + } + + descriptor.push_back(0xC0); // End Collection + return descriptor; } DeviceProfile make_gamepad_profile( @@ -131,10 +159,13 @@ namespace lvh::profiles { profile.version = version; profile.report_id = 1; profile.input_report_size = common_report_size; + if (capabilities.supports_rumble) { + profile.output_report_size = common_output_report_size; + } profile.name = std::move(name); profile.manufacturer = "LizardByte"; profile.capabilities = capabilities; - profile.report_descriptor = make_gamepad_report_descriptor(profile.report_id); + profile.report_descriptor = make_gamepad_report_descriptor(profile.report_id, profile.capabilities.supports_rumble); return profile; } diff --git a/src/core/report.cpp b/src/core/report.cpp index 00aad60..fa20ab7 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -6,6 +6,8 @@ // standard includes #include #include +#include +#include // local includes #include @@ -24,6 +26,12 @@ namespace lvh::reports { append_u16(report, static_cast(value)); } + std::uint16_t read_u16(const std::vector &report, std::size_t offset) { + const auto low = static_cast(report[offset]); + const auto high = static_cast(report[offset + 1U]); + return static_cast(low | static_cast(high << 8U)); + } + std::uint16_t report_button_bits(const ButtonSet &buttons) { std::uint16_t bits = 0; @@ -163,4 +171,20 @@ namespace lvh::reports { return report; } + GamepadOutput parse_output_report(const DeviceProfile &profile, const std::vector &report) { + GamepadOutput output; + output.raw_report = report; + + if ( + profile.capabilities.supports_rumble && profile.output_report_size >= 5U && + report.size() >= profile.output_report_size && report[0] == profile.report_id + ) { + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = read_u16(report, 1U); + output.high_frequency_rumble = read_u16(report, 3U); + } + + return output; + } + } // namespace lvh::reports diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 552df43..2e9ed05 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -30,6 +30,8 @@ // local includes #include "core/backend.hpp" +#include + namespace lvh::detail { namespace { @@ -97,6 +99,7 @@ namespace lvh::detail { request.product = options.profile.product_id; request.version = options.profile.version; std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size()); + profile_ = options.profile; if (const auto status = write_event(event); !status.ok()) { return status; @@ -218,20 +221,21 @@ namespace lvh::detail { void handle_event(const uhid_event &event) { switch (event.type) { case UHID_OUTPUT: - dispatch_output(event.u.output); + dispatch_output_report(event.u.output.data, event.u.output.size); break; case UHID_GET_REPORT: send_get_report_reply(event.u.get_report.id); break; case UHID_SET_REPORT: - send_set_report_reply(event.u.set_report.id); + dispatch_output_report(event.u.set_report.data, event.u.set_report.size); + send_set_report_reply(event.u.set_report.id, 0); break; default: break; } } - void dispatch_output(const uhid_output_req &request) { + void dispatch_output_report(const __u8 *data, std::size_t report_size) { OutputCallback callback; { std::lock_guard lock {callback_mutex_}; @@ -242,10 +246,9 @@ namespace lvh::detail { return; } - GamepadOutput output; - output.kind = GamepadOutputKind::raw_report; - const auto size = std::min(request.size, sizeof(request.data)); - output.raw_report.assign(request.data, request.data + size); + const auto size = std::min(report_size, UHID_DATA_MAX); + std::vector report(data, data + size); + auto output = reports::parse_output_report(profile_, report); callback(output); } @@ -257,15 +260,16 @@ namespace lvh::detail { static_cast(write_event(event)); } - void send_set_report_reply(std::uint32_t id) { + void send_set_report_reply(std::uint32_t id, std::uint16_t error) { uhid_event event {}; event.type = UHID_SET_REPORT_REPLY; event.u.set_report_reply.id = id; - event.u.set_report_reply.err = EIO; + event.u.set_report_reply.err = error; static_cast(write_event(event)); } int fd_ = -1; + DeviceProfile profile_; std::atomic_bool open_ = true; std::atomic_bool running_ = false; std::thread reader_; diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 473c43e..39ea444 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -42,6 +42,21 @@ TEST(ProfileTest, SunshineProfilesArePresent) { EXPECT_EQ(switch_pro.product_id, 0x2009); } +TEST(ProfileTest, RumbleProfilesExposeOutputReports) { + const auto generic = lvh::profiles::generic_gamepad(); + const auto xbox_360 = lvh::profiles::xbox_360(); + + EXPECT_FALSE(generic.capabilities.supports_rumble); + EXPECT_EQ(generic.output_report_size, 0U); + + EXPECT_TRUE(xbox_360.capabilities.supports_rumble); + EXPECT_EQ(xbox_360.output_report_size, 5U); + ASSERT_GE(xbox_360.report_descriptor.size(), 3U); + EXPECT_EQ(xbox_360.report_descriptor[xbox_360.report_descriptor.size() - 3U], 0x91); + EXPECT_EQ(xbox_360.report_descriptor[xbox_360.report_descriptor.size() - 2U], 0x02); + EXPECT_EQ(xbox_360.report_descriptor.back(), 0xC0); +} + TEST(ProfileTest, CanFindProfileByKind) { const auto profile = lvh::profiles::gamepad_profile(lvh::GamepadProfileKind::xbox_series); diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 6190547..db6b05e 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -3,6 +3,10 @@ * @brief Unit tests for gamepad report packing. */ +// standard includes +#include +#include + // local includes #include "fixtures/fixtures.hpp" @@ -62,3 +66,37 @@ TEST(ReportTest, PacksCommonGamepadReport) { EXPECT_EQ(report[12], 64); EXPECT_EQ(report[13], 255); } + +TEST(ReportTest, ParsesRumbleOutputReport) { + const auto profile = lvh::profiles::xbox_360(); + const std::vector report {profile.report_id, 0x34, 0x12, 0xCD, 0xAB}; + + const auto output = lvh::reports::parse_output_report(profile, report); + + EXPECT_EQ(output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(output.low_frequency_rumble, 0x1234); + EXPECT_EQ(output.high_frequency_rumble, 0xABCD); + EXPECT_EQ(output.raw_report, report); +} + +TEST(ReportTest, KeepsUnrecognizedOutputReportsRaw) { + const auto rumble_profile = lvh::profiles::xbox_360(); + const std::vector wrong_report_id {0x7F, 0x34, 0x12, 0xCD, 0xAB}; + + const auto wrong_id_output = lvh::reports::parse_output_report(rumble_profile, wrong_report_id); + + EXPECT_EQ(wrong_id_output.kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(wrong_id_output.low_frequency_rumble, 0U); + EXPECT_EQ(wrong_id_output.high_frequency_rumble, 0U); + EXPECT_EQ(wrong_id_output.raw_report, wrong_report_id); + + const auto generic_profile = lvh::profiles::generic_gamepad(); + const std::vector generic_report {generic_profile.report_id, 0x34, 0x12, 0xCD, 0xAB}; + + const auto generic_output = lvh::reports::parse_output_report(generic_profile, generic_report); + + EXPECT_EQ(generic_output.kind, lvh::GamepadOutputKind::raw_report); + EXPECT_EQ(generic_output.low_frequency_rumble, 0U); + EXPECT_EQ(generic_output.high_frequency_rumble, 0U); + EXPECT_EQ(generic_output.raw_report, generic_report); +} From b8c7e7c7727d8a3d4dad8e4002c053230817cbd7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:07:12 -0400 Subject: [PATCH 18/28] Add keyboard and mouse device support Introduce first-class Keyboard and Mouse device types and full runtime/backend plumbing: new types and events, CreateKeyboard/CreateMouse options and results, Keyboard/Mouse handles, validation, lifecycle and submit APIs. Extend core backend interfaces and fake backend to support backend-side keyboard/mouse implementations. Add Linux uinput support and optional X11/XTest fallback (CMake option LIBVIRTUALHID_ENABLE_XTEST), including translation helpers, UTF-8 decoding, uinput device wrapper and ioctl handling in the UHID backend. Add built-in keyboard/mouse profiles, an example adapter, CMake targets, and README documentation updates describing behavior, tests, and integration guidance. --- CMakeLists.txt | 1 + README.md | 31 +- examples/CMakeLists.txt | 8 + examples/sunshine_keyboard_mouse_adapter.cpp | 67 ++ include/libvirtualhid/profiles.hpp | 16 +- include/libvirtualhid/runtime.hpp | 323 ++++++ include/libvirtualhid/types.hpp | 131 +++ src/CMakeLists.txt | 16 + src/core/backend.cpp | 45 + src/core/backend.hpp | 142 +++ src/core/profiles.cpp | 20 + src/core/runtime.cpp | 388 ++++++- src/platform/linux/uhid_backend.cpp | 1024 +++++++++++++++++- src/platform/unsupported_backend.cpp | 11 + tests/unit/test_profiles.cpp | 15 + tests/unit/test_runtime.cpp | 90 +- 16 files changed, 2291 insertions(+), 37 deletions(-) create mode 100644 examples/sunshine_keyboard_mouse_adapter.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3617ee6..396889c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -31,6 +31,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") option(BUILD_DOCS "Build documentation" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_TESTS "Build tests" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(BUILD_EXAMPLES "Build examples" ${LIBVIRTUALHID_IS_TOP_LEVEL}) +option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on Linux" ON) set(CMAKE_COLOR_MAKEFILE ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) diff --git a/README.md b/README.md index 95f5f8f..fae79b9 100644 --- a/README.md +++ b/README.md @@ -95,26 +95,35 @@ Linux deployment should be documentation and permissions focused: users need access to `/dev/uinput` and/or `/dev/uhid`, usually through udev rules or group membership. No out-of-tree kernel module should be required. -The current Linux MVP uses `uhid` for `BackendKind::platform_default`. When -`/dev/uhid` is readable and writable, the backend reports `linux-uhid` with -gamepad and output-report support. When the node is missing or permission is -denied, the same backend remains selectable but reports the gamepad capability -as unavailable and returns `backend_unavailable` from gamepad creation. +The current Linux MVP uses `uhid` and `uinput` for +`BackendKind::platform_default`. When `/dev/uhid` is readable and writable, the +backend reports gamepad and output-report support. When `/dev/uinput` is +readable and writable, it reports keyboard and mouse support. When a required +node is missing or permission is denied, the same backend remains selectable +but reports the affected capability as unavailable and returns +`backend_unavailable` from that device creation path. A minimal udev rule for hosts that grant controller creation to the `input` group is: ```udev KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess" +KERNEL=="uinput", GROUP="input", MODE="0660", TAG+="uaccess" ``` The Linux UHID smoke test is opt-in because it creates a real virtual gamepad. Run it with `LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS=1` on a Linux host where the current user can open `/dev/uhid`. +The Linux uinput smoke test is opt-in because it creates real keyboard and +mouse devices. Run it with `LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS=1` on +a Linux host where the current user can open `/dev/uinput`. + The XTest fallback should not be treated as a gamepad backend. It can cover keyboard and mouse injection on X11, but it does not create virtual HID devices, does not help on Wayland, and should not replace `uhid`/`uinput` for gamepads. +It is enabled automatically when `LIBVIRTUALHID_ENABLE_XTEST` is `ON` and CMake +finds X11/XTest development files. Sunshine's removed legacy implementation is the first reference for this path: commit `8227e8f8` added the XTest input fallback, and commit `f57aee90` removed `src/platform/linux/input/legacy_input.cpp` when Sunshine moved fully to @@ -157,7 +166,9 @@ Expected core types: - `Runtime`: owns platform backend discovery, initialization, and shutdown. - `VirtualDevice`: common lifecycle for create, destroy, and hot-plug. - `Gamepad`: gamepad-specific state submission and output callbacks. -- `Keyboard` and `Mouse`: later secondary device types. +- `Keyboard`: key press/release and UTF-8 text submission. +- `Mouse`: relative motion, absolute motion, button, vertical scroll, and + horizontal scroll submission. - `DeviceProfile`: VID/PID, product strings, bus type, HID descriptor, report layout, and platform capability metadata. - `GamepadState`: normalized buttons, axes, triggers, hats, motion sensors, and @@ -189,10 +200,10 @@ consumers: - [ ] Output callbacks must carry rumble first, then RGB LED, adaptive trigger, motion activation, and raw output report data where the selected profile supports it. -- [ ] Keyboard and mouse APIs should map cleanly to Sunshine's current relative +- [x] Keyboard and mouse APIs should map cleanly to Sunshine's current relative mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, and Unicode paths. -- [ ] Linux fallback behavior should match Sunshine's operational expectation: +- [x] Linux fallback behavior should match Sunshine's operational expectation: prefer real virtual devices through `uhid`/`uinput`; only use XTest for keyboard/mouse when virtual device creation fails and X11 is available. - [x] The library must not own Sunshine's network protocol, Moonlight packet @@ -264,9 +275,9 @@ third-party/googletest/ GoogleTest submodule ### Phase 2: Linux MVP - [x] Implement gamepad creation over `uhid` for descriptor-driven controllers. -- [ ] Add `uinput` support for keyboard and mouse once the gamepad path is stable. +- [x] Add `uinput` support for keyboard and mouse once the gamepad path is stable. - [x] Support output report callbacks for rumble and profile-specific feedback. -- [ ] Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's +- [x] Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's historical legacy input implementation as the reference point. - [ ] Add examples and integration tests that validate SDL/HIDAPI discovery where available. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 34199cb..0c3aad4 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,8 +1,16 @@ add_executable(sunshine_gamepad_adapter "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_gamepad_adapter.cpp") +add_executable(sunshine_keyboard_mouse_adapter + "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_keyboard_mouse_adapter.cpp") + target_link_libraries(sunshine_gamepad_adapter PRIVATE libvirtualhid::libvirtualhid) +target_link_libraries(sunshine_keyboard_mouse_adapter + PRIVATE + libvirtualhid::libvirtualhid) + libvirtualhid_copy_mingw_runtime(sunshine_gamepad_adapter) +libvirtualhid_copy_mingw_runtime(sunshine_keyboard_mouse_adapter) diff --git a/examples/sunshine_keyboard_mouse_adapter.cpp b/examples/sunshine_keyboard_mouse_adapter.cpp new file mode 100644 index 0000000..789944f --- /dev/null +++ b/examples/sunshine_keyboard_mouse_adapter.cpp @@ -0,0 +1,67 @@ +/** + * @file examples/sunshine_keyboard_mouse_adapter.cpp + * @brief Minimal Sunshine-style keyboard and mouse input example. + */ + +// standard includes +#include + +// local includes +#include + +int main() { + auto runtime = lvh::Runtime::create(); + + auto keyboard = runtime->create_keyboard(); + if (!keyboard) { + std::cerr << keyboard.status.message() << '\n'; + return 1; + } + + auto mouse = runtime->create_mouse(); + if (!mouse) { + std::cerr << mouse.status.message() << '\n'; + return 1; + } + + if (const auto status = keyboard.keyboard->press(0x41); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = keyboard.keyboard->release(0x41); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = keyboard.keyboard->type_text({.text = "Hi"}); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + + if (const auto status = mouse.mouse->move_relative(25, -10); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = mouse.mouse->move_absolute(960, 540, 1920, 1080); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = mouse.mouse->button(lvh::MouseButton::left, true); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = mouse.mouse->button(lvh::MouseButton::left, false); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = mouse.mouse->vertical_scroll(120); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + if (const auto status = mouse.mouse->horizontal_scroll(-120); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + + std::cout << "keyboard " << keyboard.keyboard->submit_count() << " mouse " << mouse.mouse->submit_count() << '\n'; + return 0; +} diff --git a/include/libvirtualhid/profiles.hpp b/include/libvirtualhid/profiles.hpp index b18ebe4..a9c6712 100644 --- a/include/libvirtualhid/profiles.hpp +++ b/include/libvirtualhid/profiles.hpp @@ -1,6 +1,6 @@ /** * @file include/libvirtualhid/profiles.hpp - * @brief Built-in virtual gamepad profile declarations. + * @brief Built-in virtual device profile declarations. */ #pragma once @@ -55,6 +55,20 @@ namespace lvh::profiles { */ DeviceProfile switch_pro(); + /** + * @brief Create the generic keyboard profile. + * + * @return Generic keyboard device profile. + */ + DeviceProfile keyboard(); + + /** + * @brief Create the generic mouse profile. + * + * @return Generic mouse device profile. + */ + DeviceProfile mouse(); + /** * @brief Look up a built-in gamepad profile by kind. * diff --git a/include/libvirtualhid/runtime.hpp b/include/libvirtualhid/runtime.hpp index 8526f91..0f6c615 100644 --- a/include/libvirtualhid/runtime.hpp +++ b/include/libvirtualhid/runtime.hpp @@ -16,6 +16,8 @@ namespace lvh { namespace detail { struct GamepadDevice; + struct KeyboardDevice; + struct MouseDevice; class RuntimeState; } // namespace detail @@ -174,6 +176,249 @@ namespace lvh { std::shared_ptr device_; }; + /** + * @brief Virtual keyboard device handle. + */ + class Keyboard final: public VirtualDevice { + public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ + Keyboard(const Keyboard &) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This keyboard handle. + */ + Keyboard &operator=(const Keyboard &) = delete; + + /** + * @brief Move construct a keyboard handle. + * + * @param other Handle to move from. + */ + Keyboard(Keyboard &&other) noexcept; + + /** + * @brief Move assign a keyboard handle. + * + * @param other Handle to move from. + * @return This keyboard handle. + */ + Keyboard &operator=(Keyboard &&other) noexcept; + + /** + * @brief Destroy the keyboard handle. + */ + ~Keyboard() override; + + /** + * @copydoc VirtualDevice::device_id + */ + DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ + const DeviceProfile &profile() const override; + + /** + * @copydoc VirtualDevice::is_open + */ + bool is_open() const override; + + /** + * @copydoc VirtualDevice::close + */ + Status close() override; + + /** + * @brief Submit a keyboard key transition. + * + * @param event Keyboard event. + * @return Submit operation status. + */ + Status submit(const KeyboardEvent &event); + + /** + * @brief Press a keyboard key. + * + * @param key_code Portable key code. + * @return Submit operation status. + */ + Status press(KeyboardKeyCode key_code); + + /** + * @brief Release a keyboard key. + * + * @param key_code Portable key code. + * @return Submit operation status. + */ + Status release(KeyboardKeyCode key_code); + + /** + * @brief Type UTF-8 text. + * + * @param event Text event. + * @return Submit operation status. + */ + Status type_text(const KeyboardTextEvent &event); + + /** + * @brief Get the most recently submitted keyboard event. + * + * @return Last submitted keyboard event. + */ + KeyboardEvent last_submitted_event() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ + std::size_t submit_count() const; + + private: + friend class Runtime; + + explicit Keyboard(std::shared_ptr device); + + std::shared_ptr device_; + }; + + /** + * @brief Virtual mouse device handle. + */ + class Mouse final: public VirtualDevice { + public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ + Mouse(const Mouse &) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This mouse handle. + */ + Mouse &operator=(const Mouse &) = delete; + + /** + * @brief Move construct a mouse handle. + * + * @param other Handle to move from. + */ + Mouse(Mouse &&other) noexcept; + + /** + * @brief Move assign a mouse handle. + * + * @param other Handle to move from. + * @return This mouse handle. + */ + Mouse &operator=(Mouse &&other) noexcept; + + /** + * @brief Destroy the mouse handle. + */ + ~Mouse() override; + + /** + * @copydoc VirtualDevice::device_id + */ + DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ + const DeviceProfile &profile() const override; + + /** + * @copydoc VirtualDevice::is_open + */ + bool is_open() const override; + + /** + * @copydoc VirtualDevice::close + */ + Status close() override; + + /** + * @brief Submit a mouse event. + * + * @param event Mouse event. + * @return Submit operation status. + */ + Status submit(const MouseEvent &event); + + /** + * @brief Submit relative pointer movement. + * + * @param delta_x Horizontal delta. + * @param delta_y Vertical delta. + * @return Submit operation status. + */ + Status move_relative(std::int32_t delta_x, std::int32_t delta_y); + + /** + * @brief Submit absolute pointer movement. + * + * @param x Absolute X coordinate. + * @param y Absolute Y coordinate. + * @param width Width of the absolute coordinate space. + * @param height Height of the absolute coordinate space. + * @return Submit operation status. + */ + Status move_absolute(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height); + + /** + * @brief Submit a mouse button transition. + * + * @param button Mouse button. + * @param pressed Whether the button is pressed. + * @return Submit operation status. + */ + Status button(MouseButton button, bool pressed); + + /** + * @brief Submit high-resolution vertical scroll. + * + * @param distance High-resolution scroll distance. + * @return Submit operation status. + */ + Status vertical_scroll(std::int32_t distance); + + /** + * @brief Submit high-resolution horizontal scroll. + * + * @param distance High-resolution scroll distance. + * @return Submit operation status. + */ + Status horizontal_scroll(std::int32_t distance); + + /** + * @brief Get the most recently submitted mouse event. + * + * @return Last submitted mouse event. + */ + MouseEvent last_submitted_event() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ + std::size_t submit_count() const; + + private: + friend class Runtime; + + explicit Mouse(std::shared_ptr device); + + std::shared_ptr device_; + }; + /** * @brief Result returned by gamepad creation. */ @@ -198,6 +443,54 @@ namespace lvh { } }; + /** + * @brief Result returned by keyboard creation. + */ + struct KeyboardCreationResult { + /** + * @brief Creation status. + */ + Status status; + + /** + * @brief Created keyboard handle when creation succeeds. + */ + std::unique_ptr keyboard; + + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && keyboard != nullptr; + } + }; + + /** + * @brief Result returned by mouse creation. + */ + struct MouseCreationResult { + /** + * @brief Creation status. + */ + Status status; + + /** + * @brief Created mouse handle when creation succeeds. + */ + std::unique_ptr mouse; + + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && mouse != nullptr; + } + }; + /** * @brief Runtime that owns backend state and creates virtual devices. */ @@ -273,6 +566,36 @@ namespace lvh { */ GamepadCreationResult create_gamepad(const CreateGamepadOptions &options); + /** + * @brief Create a keyboard with the built-in keyboard profile. + * + * @return Keyboard creation result. + */ + KeyboardCreationResult create_keyboard(); + + /** + * @brief Create a keyboard from full creation options. + * + * @param options Keyboard creation options. + * @return Keyboard creation result. + */ + KeyboardCreationResult create_keyboard(const CreateKeyboardOptions &options); + + /** + * @brief Create a mouse with the built-in mouse profile. + * + * @return Mouse creation result. + */ + MouseCreationResult create_mouse(); + + /** + * @brief Create a mouse from full creation options. + * + * @param options Mouse creation options. + * @return Mouse creation result. + */ + MouseCreationResult create_mouse(const CreateMouseOptions &options); + /** * @brief Get the number of open devices owned by the runtime. * diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index f0837d9..29ab677 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -361,6 +361,36 @@ namespace lvh { GamepadMetadata metadata; }; + /** + * @brief Full keyboard creation request. + */ + struct CreateKeyboardOptions { + /** + * @brief Device profile to instantiate. + */ + DeviceProfile profile; + + /** + * @brief Consumer-defined stable identity string. + */ + std::string stable_id; + }; + + /** + * @brief Full mouse creation request. + */ + struct CreateMouseOptions { + /** + * @brief Device profile to instantiate. + */ + DeviceProfile profile; + + /** + * @brief Consumer-defined stable identity string. + */ + std::string stable_id; + }; + /** * @brief Logical gamepad buttons accepted by the common gamepad state model. */ @@ -472,6 +502,107 @@ namespace lvh { float right_trigger = 0.0F; }; + /** + * @brief Keyboard key code accepted by the keyboard event model. + * + * The initial Linux backend treats this as a Moonlight/Windows virtual-key code + * because that matches Sunshine's current input path. Backends translate this + * value to their native key representation. + */ + using KeyboardKeyCode = std::uint16_t; + + /** + * @brief Keyboard key transition. + */ + struct KeyboardEvent { + /** + * @brief Portable key code. + */ + KeyboardKeyCode key_code = 0; + + /** + * @brief Whether the key is pressed. + */ + bool pressed = false; + }; + + /** + * @brief UTF-8 text input request. + */ + struct KeyboardTextEvent { + /** + * @brief UTF-8 text to type. + */ + std::string text; + }; + + /** + * @brief Mouse buttons accepted by the mouse event model. + */ + enum class MouseButton : std::uint8_t { + left = 0, ///< Primary mouse button. + middle, ///< Middle mouse button. + right, ///< Secondary mouse button. + side, ///< First auxiliary mouse button. + extra, ///< Second auxiliary mouse button. + }; + + /** + * @brief Mouse event categories accepted by the mouse event model. + */ + enum class MouseEventKind { + relative_motion, ///< Relative pointer movement. + absolute_motion, ///< Absolute pointer movement inside a target area. + button, ///< Mouse button transition. + vertical_scroll, ///< High-resolution vertical scroll event. + horizontal_scroll, ///< High-resolution horizontal scroll event. + }; + + /** + * @brief Mouse input event. + */ + struct MouseEvent { + /** + * @brief Event category. + */ + MouseEventKind kind = MouseEventKind::relative_motion; + + /** + * @brief Relative delta or absolute X coordinate. + */ + std::int32_t x = 0; + + /** + * @brief Relative delta or absolute Y coordinate. + */ + std::int32_t y = 0; + + /** + * @brief Width of the absolute coordinate space. + */ + std::int32_t width = 0; + + /** + * @brief Height of the absolute coordinate space. + */ + std::int32_t height = 0; + + /** + * @brief Button used by `MouseEventKind::button`. + */ + MouseButton button = MouseButton::left; + + /** + * @brief Whether the button is pressed. + */ + bool pressed = false; + + /** + * @brief High-resolution scroll distance. + */ + std::int32_t high_resolution_scroll = 0; + }; + /** * @brief Output report categories delivered by a gamepad backend. */ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 387f8a0..70fd7c0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -11,6 +11,9 @@ target_sources(${PROJECT_NAME} if(LIBVIRTUALHID_USES_THREADS) find_package(Threads REQUIRED) + if(LIBVIRTUALHID_ENABLE_XTEST) + find_package(X11 QUIET) + endif() target_sources(${PROJECT_NAME} PRIVATE @@ -18,6 +21,19 @@ if(LIBVIRTUALHID_USES_THREADS) target_link_libraries(${PROJECT_NAME} PUBLIC Threads::Threads) + if(LIBVIRTUALHID_ENABLE_XTEST AND X11_FOUND AND X11_XTest_FOUND) + target_compile_definitions(${PROJECT_NAME} + PRIVATE + LIBVIRTUALHID_HAVE_XTEST=1) + target_include_directories(${PROJECT_NAME} + PRIVATE + ${X11_INCLUDE_DIR} + ${X11_XTest_INCLUDE_PATH}) + target_link_libraries(${PROJECT_NAME} + PRIVATE + ${X11_LIBRARIES} + ${X11_XTest_LIB}) + endif() else() target_sources(${PROJECT_NAME} PRIVATE diff --git a/src/core/backend.cpp b/src/core/backend.cpp index 5953707..19e2b78 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -35,6 +35,38 @@ namespace lvh::detail { OutputCallback output_callback_; }; + /** + * @brief In-memory keyboard backend used for portable tests. + */ + class FakeKeyboard final: public BackendKeyboard { + public: + Status submit(const KeyboardEvent & /*event*/) override { + return Status::success(); + } + + Status type_text(const KeyboardTextEvent & /*event*/) override { + return Status::success(); + } + + Status close() override { + return Status::success(); + } + }; + + /** + * @brief In-memory mouse backend used for portable tests. + */ + class FakeMouse final: public BackendMouse { + public: + Status submit(const MouseEvent & /*event*/) override { + return Status::success(); + } + + Status close() override { + return Status::success(); + } + }; + /** * @brief In-memory backend used by default for API validation. */ @@ -43,6 +75,8 @@ namespace lvh::detail { FakeBackend() { capabilities_.backend_name = "fake"; capabilities_.supports_gamepad = true; + capabilities_.supports_keyboard = true; + capabilities_.supports_mouse = true; capabilities_.supports_output_reports = true; } @@ -54,6 +88,17 @@ namespace lvh::detail { return {Status::success(), std::make_unique()}; } + BackendKeyboardCreationResult create_keyboard( + DeviceId /*id*/, + const CreateKeyboardOptions & /*options*/ + ) override { + return {Status::success(), std::make_unique()}; + } + + BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions & /*options*/) override { + return {Status::success(), std::make_unique()}; + } + private: BackendCapabilities capabilities_; }; diff --git a/src/core/backend.hpp b/src/core/backend.hpp index dcbc7c4..44f4d60 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -55,6 +55,82 @@ namespace lvh::detail { BackendGamepad() = default; }; + /** + * @brief Backend-owned keyboard device implementation. + */ + class BackendKeyboard { + public: + BackendKeyboard(const BackendKeyboard &) = delete; + BackendKeyboard &operator=(const BackendKeyboard &) = delete; + BackendKeyboard(BackendKeyboard &&) noexcept = delete; + BackendKeyboard &operator=(BackendKeyboard &&) noexcept = delete; + + /** + * @brief Destroy the backend keyboard. + */ + virtual ~BackendKeyboard() = default; + + /** + * @brief Submit a keyboard key transition to the backend. + * + * @param event Keyboard event. + * @return Submit status. + */ + virtual Status submit(const KeyboardEvent &event) = 0; + + /** + * @brief Submit UTF-8 text to the backend. + * + * @param event Text event. + * @return Submit status. + */ + virtual Status type_text(const KeyboardTextEvent &event) = 0; + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual Status close() = 0; + + protected: + BackendKeyboard() = default; + }; + + /** + * @brief Backend-owned mouse device implementation. + */ + class BackendMouse { + public: + BackendMouse(const BackendMouse &) = delete; + BackendMouse &operator=(const BackendMouse &) = delete; + BackendMouse(BackendMouse &&) noexcept = delete; + BackendMouse &operator=(BackendMouse &&) noexcept = delete; + + /** + * @brief Destroy the backend mouse. + */ + virtual ~BackendMouse() = default; + + /** + * @brief Submit a mouse event to the backend. + * + * @param event Mouse event. + * @return Submit status. + */ + virtual Status submit(const MouseEvent &event) = 0; + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual Status close() = 0; + + protected: + BackendMouse() = default; + }; + /** * @brief Result returned by an internal backend gamepad creation request. */ @@ -79,6 +155,54 @@ namespace lvh::detail { } }; + /** + * @brief Result returned by an internal backend keyboard creation request. + */ + struct BackendKeyboardCreationResult { + /** + * @brief Creation status. + */ + Status status; + + /** + * @brief Backend device when creation succeeds. + */ + std::unique_ptr keyboard; + + /** + * @brief Check whether creation succeeded. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && keyboard != nullptr; + } + }; + + /** + * @brief Result returned by an internal backend mouse creation request. + */ + struct BackendMouseCreationResult { + /** + * @brief Creation status. + */ + Status status; + + /** + * @brief Backend device when creation succeeds. + */ + std::unique_ptr mouse; + + /** + * @brief Check whether creation succeeded. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && mouse != nullptr; + } + }; + /** * @brief Runtime-selected backend implementation. */ @@ -110,6 +234,24 @@ namespace lvh::detail { */ virtual BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) = 0; + /** + * @brief Create a backend keyboard device. + * + * @param id Runtime-assigned device id. + * @param options Keyboard creation options. + * @return Backend keyboard creation result. + */ + virtual BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) = 0; + + /** + * @brief Create a backend mouse device. + * + * @param id Runtime-assigned device id. + * @param options Mouse creation options. + * @return Backend mouse creation result. + */ + virtual BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) = 0; + protected: Backend() = default; }; diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index a9c8c7a..faf38fc 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -169,6 +169,18 @@ namespace lvh::profiles { return profile; } + DeviceProfile make_simple_profile(DeviceType device_type, std::string name, std::uint16_t product_id) { + DeviceProfile profile; + profile.device_type = device_type; + profile.bus_type = BusType::usb; + profile.vendor_id = 0x1209; + profile.product_id = product_id; + profile.version = 0x0001; + profile.name = std::move(name); + profile.manufacturer = "LizardByte"; + return profile; + } + } // namespace DeviceProfile generic_gamepad() { @@ -244,6 +256,14 @@ namespace lvh::profiles { ); } + DeviceProfile keyboard() { + return make_simple_profile(DeviceType::keyboard, "libvirtualhid Keyboard", 0x0002); + } + + DeviceProfile mouse() { + return make_simple_profile(DeviceType::mouse, "libvirtualhid Mouse", 0x0003); + } + std::optional gamepad_profile(GamepadProfileKind kind) { switch (kind) { case GamepadProfileKind::generic: diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 20ba4f4..e9bee8d 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -1,6 +1,6 @@ /** * @file src/core/runtime.cpp - * @brief Runtime and virtual gamepad handle definitions. + * @brief Runtime and virtual device handle definitions. */ // standard includes @@ -12,6 +12,7 @@ // local includes #include "core/backend.hpp" +#include #include #include @@ -38,6 +39,41 @@ namespace lvh::detail { mutable std::mutex mutex; }; + struct KeyboardDevice { + explicit KeyboardDevice( + DeviceId device_id, + CreateKeyboardOptions create_options, + std::unique_ptr backend_keyboard + ): + id {device_id}, + options {std::move(create_options)}, + backend {std::move(backend_keyboard)} {} + + DeviceId id; + CreateKeyboardOptions options; + std::unique_ptr backend; + bool open = true; + KeyboardEvent last_event; + KeyboardTextEvent last_text_event; + std::size_t submitted_events = 0; + mutable std::mutex mutex; + }; + + struct MouseDevice { + explicit MouseDevice(DeviceId device_id, CreateMouseOptions create_options, std::unique_ptr backend_mouse): + id {device_id}, + options {std::move(create_options)}, + backend {std::move(backend_mouse)} {} + + DeviceId id; + CreateMouseOptions options; + std::unique_ptr backend; + bool open = true; + MouseEvent last_event; + std::size_t submitted_events = 0; + mutable std::mutex mutex; + }; + class RuntimeState { public: explicit RuntimeState(RuntimeOptions runtime_options): @@ -50,6 +86,8 @@ namespace lvh::detail { BackendCapabilities caps; DeviceId next_device_id = 1; std::vector> gamepads; + std::vector> keyboards; + std::vector> mice; mutable std::mutex mutex; }; @@ -78,12 +116,76 @@ namespace lvh { return Status::success(); } + Status validate_keyboard_options(const CreateKeyboardOptions &options) { + if (options.profile.device_type != DeviceType::keyboard) { + return Status::failure(ErrorCode::unsupported_profile, "device profile is not a keyboard"); + } + if (options.profile.name.empty()) { + return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + return Status::success(); + } + + Status validate_mouse_options(const CreateMouseOptions &options) { + if (options.profile.device_type != DeviceType::mouse) { + return Status::failure(ErrorCode::unsupported_profile, "device profile is not a mouse"); + } + if (options.profile.name.empty()) { + return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + return Status::success(); + } + + Status validate_keyboard_event(const KeyboardEvent &event) { + if (event.key_code == 0) { + return Status::failure(ErrorCode::invalid_argument, "keyboard key code must not be zero"); + } + + return Status::success(); + } + + Status validate_mouse_event(const MouseEvent &event) { + if (event.kind == MouseEventKind::absolute_motion && (event.width <= 0 || event.height <= 0)) { + return Status::failure(ErrorCode::invalid_argument, "absolute mouse movement requires positive dimensions"); + } + + return Status::success(); + } + template - auto with_device(const std::shared_ptr &device, Func &&func) { + auto with_device(const auto &device, Func &&func) { std::lock_guard lock {device->mutex}; return func(*device); } + template + std::size_t count_open_devices(const DeviceList &devices) { + std::size_t count = 0; + for (const auto &weak_device : devices) { + if (const auto device = weak_device.lock()) { + if (device->open) { + ++count; + } + } + } + return count; + } + + template + void close_devices(DeviceList &devices) { + for (const auto &weak_device : devices) { + if (const auto device = weak_device.lock()) { + std::lock_guard device_lock {device->mutex}; + if (device->backend) { + static_cast(device->backend->close()); + } + device->open = false; + } + } + } + } // namespace Gamepad::Gamepad(std::shared_ptr device): @@ -198,6 +300,204 @@ namespace lvh { }); } + Keyboard::Keyboard(std::shared_ptr device): + device_ {std::move(device)} {} + + Keyboard::Keyboard(Keyboard &&) noexcept = default; + Keyboard &Keyboard::operator=(Keyboard &&) noexcept = default; + Keyboard::~Keyboard() = default; + + DeviceId Keyboard::device_id() const { + return device_->id; + } + + const DeviceProfile &Keyboard::profile() const { + return device_->options.profile; + } + + bool Keyboard::is_open() const { + return with_device(device_, [](const auto &device) { + return device.open; + }); + } + + Status Keyboard::close() { + return with_device(device_, [](auto &device) { + if (!device.open) { + return Status::success(); + } + + auto status = Status::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + Status Keyboard::submit(const KeyboardEvent &event) { + if (const auto validation = validate_keyboard_event(event); !validation.ok()) { + return validation; + } + + return with_device(device_, [&event](auto &device) { + if (!device.open) { + return Status::failure(ErrorCode::device_closed, "keyboard is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->submit(event); !status.ok()) { + return status; + } + } + + device.last_event = event; + ++device.submitted_events; + return Status::success(); + }); + } + + Status Keyboard::press(KeyboardKeyCode key_code) { + return submit({.key_code = key_code, .pressed = true}); + } + + Status Keyboard::release(KeyboardKeyCode key_code) { + return submit({.key_code = key_code, .pressed = false}); + } + + Status Keyboard::type_text(const KeyboardTextEvent &event) { + return with_device(device_, [&event](auto &device) { + if (!device.open) { + return Status::failure(ErrorCode::device_closed, "keyboard is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->type_text(event); !status.ok()) { + return status; + } + } + + device.last_text_event = event; + ++device.submitted_events; + return Status::success(); + }); + } + + KeyboardEvent Keyboard::last_submitted_event() const { + return with_device(device_, [](const auto &device) { + return device.last_event; + }); + } + + std::size_t Keyboard::submit_count() const { + return with_device(device_, [](const auto &device) { + return device.submitted_events; + }); + } + + Mouse::Mouse(std::shared_ptr device): + device_ {std::move(device)} {} + + Mouse::Mouse(Mouse &&) noexcept = default; + Mouse &Mouse::operator=(Mouse &&) noexcept = default; + Mouse::~Mouse() = default; + + DeviceId Mouse::device_id() const { + return device_->id; + } + + const DeviceProfile &Mouse::profile() const { + return device_->options.profile; + } + + bool Mouse::is_open() const { + return with_device(device_, [](const auto &device) { + return device.open; + }); + } + + Status Mouse::close() { + return with_device(device_, [](auto &device) { + if (!device.open) { + return Status::success(); + } + + auto status = Status::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + Status Mouse::submit(const MouseEvent &event) { + if (const auto validation = validate_mouse_event(event); !validation.ok()) { + return validation; + } + + return with_device(device_, [&event](auto &device) { + if (!device.open) { + return Status::failure(ErrorCode::device_closed, "mouse is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->submit(event); !status.ok()) { + return status; + } + } + + device.last_event = event; + ++device.submitted_events; + return Status::success(); + }); + } + + Status Mouse::move_relative(std::int32_t delta_x, std::int32_t delta_y) { + return submit({.kind = MouseEventKind::relative_motion, .x = delta_x, .y = delta_y}); + } + + Status Mouse::move_absolute(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) { + return submit({.kind = MouseEventKind::absolute_motion, .x = x, .y = y, .width = width, .height = height}); + } + + Status Mouse::button(MouseButton button, bool pressed) { + MouseEvent event; + event.kind = MouseEventKind::button; + event.button = button; + event.pressed = pressed; + return submit(event); + } + + Status Mouse::vertical_scroll(std::int32_t distance) { + MouseEvent event; + event.kind = MouseEventKind::vertical_scroll; + event.high_resolution_scroll = distance; + return submit(event); + } + + Status Mouse::horizontal_scroll(std::int32_t distance) { + MouseEvent event; + event.kind = MouseEventKind::horizontal_scroll; + event.high_resolution_scroll = distance; + return submit(event); + } + + MouseEvent Mouse::last_submitted_event() const { + return with_device(device_, [](const auto &device) { + return device.last_event; + }); + } + + std::size_t Mouse::submit_count() const { + return with_device(device_, [](const auto &device) { + return device.submitted_events; + }); + } + Runtime::Runtime(RuntimeOptions options): state_ {std::make_shared(options)} {} @@ -253,30 +553,78 @@ namespace lvh { return {Status::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; } + KeyboardCreationResult Runtime::create_keyboard() { + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + return create_keyboard(options); + } + + KeyboardCreationResult Runtime::create_keyboard(const CreateKeyboardOptions &options) { + if (const auto validation = validate_keyboard_options(options); !validation.ok()) { + return {validation, nullptr}; + } + + DeviceId id; + { + std::lock_guard lock {state_->mutex}; + id = state_->next_device_id++; + } + + auto backend_result = state_->backend->create_keyboard(id, options); + if (!backend_result) { + return {std::move(backend_result.status), nullptr}; + } + + auto device = std::make_shared(id, options, std::move(backend_result.keyboard)); + { + std::lock_guard lock {state_->mutex}; + state_->keyboards.emplace_back(device); + } + + return {Status::success(), std::unique_ptr {new Keyboard {std::move(device)}}}; + } + + MouseCreationResult Runtime::create_mouse() { + CreateMouseOptions options; + options.profile = profiles::mouse(); + return create_mouse(options); + } + + MouseCreationResult Runtime::create_mouse(const CreateMouseOptions &options) { + if (const auto validation = validate_mouse_options(options); !validation.ok()) { + return {validation, nullptr}; + } + + DeviceId id; + { + std::lock_guard lock {state_->mutex}; + id = state_->next_device_id++; + } + + auto backend_result = state_->backend->create_mouse(id, options); + if (!backend_result) { + return {std::move(backend_result.status), nullptr}; + } + + auto device = std::make_shared(id, options, std::move(backend_result.mouse)); + { + std::lock_guard lock {state_->mutex}; + state_->mice.emplace_back(device); + } + + return {Status::success(), std::unique_ptr {new Mouse {std::move(device)}}}; + } + std::size_t Runtime::active_device_count() const { std::lock_guard lock {state_->mutex}; - std::size_t count = 0; - for (const auto &weak_device : state_->gamepads) { - if (const auto device = weak_device.lock()) { - if (device->open) { - ++count; - } - } - } - return count; + return count_open_devices(state_->gamepads) + count_open_devices(state_->keyboards) + count_open_devices(state_->mice); } void Runtime::close_all() { std::lock_guard lock {state_->mutex}; - for (const auto &weak_device : state_->gamepads) { - if (const auto device = weak_device.lock()) { - std::lock_guard device_lock {device->mutex}; - if (device->backend) { - static_cast(device->backend->close()); - } - device->open = false; - } - } + close_devices(state_->gamepads); + close_devices(state_->keyboards); + close_devices(state_->mice); } } // namespace lvh diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 2e9ed05..e94a6ec 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -7,11 +7,14 @@ #include #include #include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -24,9 +27,18 @@ #define __user #endif #include +#include #include +#include #include +#if defined(LIBVIRTUALHID_HAVE_XTEST) + #include + #include + #include + #include +#endif + // local includes #include "core/backend.hpp" @@ -36,6 +48,8 @@ namespace lvh::detail { namespace { constexpr auto uhid_path = "/dev/uhid"; + constexpr auto uinput_path = "/dev/uinput"; + constexpr auto absolute_axis_max = 65535; constexpr auto poll_timeout_ms = 100; std::string errno_message(int error) { @@ -50,6 +64,10 @@ namespace lvh::detail { return ::access(uhid_path, R_OK | W_OK) == 0; } + bool can_access_uinput() { + return ::access(uinput_path, R_OK | W_OK) == 0; + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -57,6 +75,10 @@ namespace lvh::detail { return BUS_USB; } + std::uint16_t to_uinput_bus(BusType bus_type) { + return to_uhid_bus(bus_type); + } + template void copy_string(__u8 (&destination)[Size], const std::string &source) { const auto length = std::min(source.size(), Size - 1); @@ -64,6 +86,937 @@ namespace lvh::detail { destination[length] = 0; } + template + void copy_string(char (&destination)[Size], const std::string &source) { + const auto length = std::min(source.size(), Size - 1); + std::memcpy(destination, source.data(), length); + destination[length] = 0; + } + + Status ioctl_status(const std::string &operation) { + return system_error_status(ErrorCode::backend_failure, operation, errno); + } + + int key_code_to_linux(KeyboardKeyCode key_code) { + switch (key_code) { + case 0x08: + return KEY_BACKSPACE; + case 0x09: + return KEY_TAB; + case 0x0D: + return KEY_ENTER; + case 0x10: + case 0xA0: + return KEY_LEFTSHIFT; + case 0x11: + case 0xA2: + return KEY_LEFTCTRL; + case 0x12: + case 0xA4: + return KEY_LEFTALT; + case 0x14: + return KEY_CAPSLOCK; + case 0x1B: + return KEY_ESC; + case 0x20: + return KEY_SPACE; + case 0x21: + return KEY_PAGEUP; + case 0x22: + return KEY_PAGEDOWN; + case 0x23: + return KEY_END; + case 0x24: + return KEY_HOME; + case 0x25: + return KEY_LEFT; + case 0x26: + return KEY_UP; + case 0x27: + return KEY_RIGHT; + case 0x28: + return KEY_DOWN; + case 0x2C: + return KEY_SYSRQ; + case 0x2D: + return KEY_INSERT; + case 0x2E: + return KEY_DELETE; + case 0x5B: + return KEY_LEFTMETA; + case 0x5C: + return KEY_RIGHTMETA; + case 0x90: + return KEY_NUMLOCK; + case 0x91: + return KEY_SCROLLLOCK; + case 0xA1: + return KEY_RIGHTSHIFT; + case 0xA3: + return KEY_RIGHTCTRL; + case 0xA5: + return KEY_RIGHTALT; + case 0xBA: + return KEY_SEMICOLON; + case 0xBB: + return KEY_EQUAL; + case 0xBC: + return KEY_COMMA; + case 0xBD: + return KEY_MINUS; + case 0xBE: + return KEY_DOT; + case 0xBF: + return KEY_SLASH; + case 0xC0: + return KEY_GRAVE; + case 0xDB: + return KEY_LEFTBRACE; + case 0xDC: + return KEY_BACKSLASH; + case 0xDD: + return KEY_RIGHTBRACE; + case 0xDE: + return KEY_APOSTROPHE; + case 0xE2: + return KEY_102ND; + default: + break; + } + + if (key_code >= 0x30 && key_code <= 0x39) { + return KEY_0 + static_cast(key_code - 0x30); + } + if (key_code >= 0x41 && key_code <= 0x5A) { + return KEY_A + static_cast(key_code - 0x41); + } + if (key_code >= 0x60 && key_code <= 0x69) { + return KEY_KP0 + static_cast(key_code - 0x60); + } + if (key_code == 0x6A) { + return KEY_KPASTERISK; + } + if (key_code == 0x6B) { + return KEY_KPPLUS; + } + if (key_code == 0x6D) { + return KEY_KPMINUS; + } + if (key_code == 0x6E) { + return KEY_KPDOT; + } + if (key_code == 0x6F) { + return KEY_KPSLASH; + } + if (key_code >= 0x70 && key_code <= 0x87) { + return KEY_F1 + static_cast(key_code - 0x70); + } + + return -1; + } + + int mouse_button_to_linux(MouseButton button) { + switch (button) { + case MouseButton::left: + return BTN_LEFT; + case MouseButton::middle: + return BTN_MIDDLE; + case MouseButton::right: + return BTN_RIGHT; + case MouseButton::side: + return BTN_SIDE; + case MouseButton::extra: + return BTN_EXTRA; + } + + return BTN_LEFT; + } + + int scale_absolute_axis(std::int32_t value, std::int32_t limit) { + if (limit <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0, limit); + const auto numerator = static_cast(clamped) * absolute_axis_max; + return static_cast(numerator / limit); + } + + std::vector decode_utf8(const std::string &text) { + std::vector codepoints; + for (std::size_t i = 0; i < text.size();) { + const auto first = static_cast(text[i]); + std::uint32_t codepoint = 0; + std::size_t length = 0; + + if (first <= 0x7F) { + codepoint = first; + length = 1; + } else if ((first & 0xE0U) == 0xC0U) { + codepoint = first & 0x1FU; + length = 2; + } else if ((first & 0xF0U) == 0xE0U) { + codepoint = first & 0x0FU; + length = 3; + } else if ((first & 0xF8U) == 0xF0U) { + codepoint = first & 0x07U; + length = 4; + } else { + ++i; + continue; + } + + if (i + length > text.size()) { + break; + } + + bool valid = true; + for (std::size_t offset = 1; offset < length; ++offset) { + const auto next = static_cast(text[i + offset]); + if ((next & 0xC0U) != 0x80U) { + valid = false; + break; + } + codepoint = (codepoint << 6U) | (next & 0x3FU); + } + + if (valid) { + codepoints.push_back(codepoint); + i += length; + } else { + ++i; + } + } + + return codepoints; + } + + std::string uppercase_hex(std::uint32_t codepoint) { + std::ostringstream stream; + stream << std::uppercase << std::hex << codepoint; + return stream.str(); + } + + KeyboardKeyCode hex_digit_key_code(char digit) { + if (digit >= '0' && digit <= '9') { + return static_cast(0x30 + (digit - '0')); + } + return static_cast(0x41 + (digit - 'A')); + } + + int legacy_scroll_steps(std::int32_t distance) { + if (distance == 0) { + return 0; + } + + const auto steps = distance / 120; + if (steps != 0) { + return steps; + } + return distance > 0 ? 1 : -1; + } + + /** + * @brief Shared Linux uinput device wrapper. + */ + class UinputDevice { + public: + explicit UinputDevice(int file_descriptor): + fd_ {file_descriptor} {} + + UinputDevice(const UinputDevice &) = delete; + UinputDevice &operator=(const UinputDevice &) = delete; + UinputDevice(UinputDevice &&) noexcept = delete; + UinputDevice &operator=(UinputDevice &&) noexcept = delete; + + virtual ~UinputDevice() { + static_cast(close_uinput("uinput device")); + } + + protected: + Status emit_event(std::uint16_t type, std::uint16_t code, std::int32_t value) { + std::lock_guard lock {write_mutex_}; + return emit_event_locked(type, code, value); + } + + Status sync() { + return emit_event(EV_SYN, SYN_REPORT, 0); + } + + Status close_uinput(const std::string &description) { + if (!open_.exchange(false)) { + return Status::success(); + } + + auto status = Status::success(); + if (fd_ >= 0) { + if (::ioctl(fd_, UI_DEV_DESTROY) < 0) { + status = ioctl_status("failed to destroy " + description); + } + if (::close(fd_) != 0 && status.ok()) { + status = system_error_status(ErrorCode::backend_failure, "failed to close /dev/uinput", errno); + } + fd_ = -1; + } + + return status; + } + + bool is_open() const { + return open_; + } + + int file_descriptor() const { + return fd_; + } + + private: + Status emit_event_locked(std::uint16_t type, std::uint16_t code, std::int32_t value) { + if (fd_ < 0) { + return Status::failure(ErrorCode::device_closed, "uinput device is closed"); + } + + input_event event {}; + event.type = type; + event.code = code; + event.value = value; + + const auto result = ::write(fd_, &event, sizeof(event)); + if (result < 0) { + return system_error_status(ErrorCode::backend_failure, "failed to write uinput event", errno); + } + if (static_cast(result) != sizeof(event)) { + return Status::failure(ErrorCode::backend_failure, "short write while sending uinput event"); + } + + return Status::success(); + } + + int fd_ = -1; + std::atomic_bool open_ = true; + std::mutex write_mutex_; + }; + + Status write_uinput_user_device(int fd, const DeviceProfile &profile, DeviceId id) { + uinput_user_dev device {}; + copy_string(device.name, profile.name); + device.id.bustype = to_uinput_bus(profile.bus_type); + device.id.vendor = profile.vendor_id; + device.id.product = profile.product_id; + device.id.version = profile.version; + device.absmin[ABS_X] = 0; + device.absmax[ABS_X] = absolute_axis_max; + device.absmin[ABS_Y] = 0; + device.absmax[ABS_Y] = absolute_axis_max; + device.absfuzz[ABS_X] = 0; + device.absfuzz[ABS_Y] = 0; + device.absflat[ABS_X] = 0; + device.absflat[ABS_Y] = 0; + + const auto result = ::write(fd, &device, sizeof(device)); + if (result < 0) { + return system_error_status(ErrorCode::backend_failure, "failed to write uinput device definition", errno); + } + if (static_cast(result) != sizeof(device)) { + return Status::failure(ErrorCode::backend_failure, "short write while creating uinput device"); + } + + if (::ioctl(fd, UI_DEV_CREATE) < 0) { + return ioctl_status("failed to create uinput device " + std::to_string(id)); + } + + return Status::success(); + } + + Status enable_uinput_keyboard(int fd) { + if (::ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { + return ioctl_status("failed to enable uinput keyboard key events"); + } + + for (auto code = 1; code < KEY_MAX; ++code) { + if (::ioctl(fd, UI_SET_KEYBIT, code) < 0) { + return ioctl_status("failed to enable uinput keyboard key " + std::to_string(code)); + } + } + + return Status::success(); + } + + Status enable_uinput_mouse(int fd) { + if (::ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { + return ioctl_status("failed to enable uinput mouse button events"); + } + if (::ioctl(fd, UI_SET_EVBIT, EV_REL) < 0) { + return ioctl_status("failed to enable uinput relative mouse events"); + } + if (::ioctl(fd, UI_SET_EVBIT, EV_ABS) < 0) { + return ioctl_status("failed to enable uinput absolute mouse events"); + } + + for (const auto button : {BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA}) { + if (::ioctl(fd, UI_SET_KEYBIT, button) < 0) { + return ioctl_status("failed to enable uinput mouse button " + std::to_string(button)); + } + } + + for (const auto code : {REL_X, REL_Y}) { + if (::ioctl(fd, UI_SET_RELBIT, code) < 0) { + return ioctl_status("failed to enable uinput relative axis " + std::to_string(code)); + } + } + +#if defined(REL_WHEEL_HI_RES) + if (::ioctl(fd, UI_SET_RELBIT, REL_WHEEL_HI_RES) < 0) { + return ioctl_status("failed to enable uinput high-resolution vertical scroll"); + } +#else + if (::ioctl(fd, UI_SET_RELBIT, REL_WHEEL) < 0) { + return ioctl_status("failed to enable uinput vertical scroll"); + } +#endif + +#if defined(REL_HWHEEL_HI_RES) + if (::ioctl(fd, UI_SET_RELBIT, REL_HWHEEL_HI_RES) < 0) { + return ioctl_status("failed to enable uinput high-resolution horizontal scroll"); + } +#else + if (::ioctl(fd, UI_SET_RELBIT, REL_HWHEEL) < 0) { + return ioctl_status("failed to enable uinput horizontal scroll"); + } +#endif + + if (::ioctl(fd, UI_SET_ABSBIT, ABS_X) < 0) { + return ioctl_status("failed to enable uinput absolute X axis"); + } + if (::ioctl(fd, UI_SET_ABSBIT, ABS_Y) < 0) { + return ioctl_status("failed to enable uinput absolute Y axis"); + } + + return Status::success(); + } + + /** + * @brief Backend keyboard backed by one Linux uinput file descriptor. + */ + class UinputKeyboard final: public BackendKeyboard, private UinputDevice { + public: + explicit UinputKeyboard(int file_descriptor): + UinputDevice {file_descriptor} {} + + ~UinputKeyboard() override { + static_cast(close()); + } + + Status create(DeviceId id, const CreateKeyboardOptions &options) { + if (const auto status = enable_uinput_keyboard(file_descriptor()); !status.ok()) { + return status; + } + return write_uinput_user_device(file_descriptor(), options.profile, id); + } + + Status submit(const KeyboardEvent &event) override { + if (!is_open()) { + return Status::failure(ErrorCode::device_closed, "uinput keyboard is closed"); + } + + const auto linux_key = key_code_to_linux(event.key_code); + if (linux_key < 0) { + return Status::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by the Linux backend"); + } + + if (const auto status = emit_event(EV_KEY, static_cast(linux_key), event.pressed ? 1 : 0); !status.ok()) { + return status; + } + return sync(); + } + + Status type_text(const KeyboardTextEvent &event) override { + for (const auto codepoint : decode_utf8(event.text)) { + const auto hex = uppercase_hex(codepoint); + + if (const auto status = submit({.key_code = 0xA2, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0xA0, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0x55, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0x55, .pressed = false}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0xA0, .pressed = false}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0xA2, .pressed = false}); !status.ok()) { + return status; + } + + for (const auto digit : hex) { + const auto key_code = hex_digit_key_code(digit); + if (const auto status = submit({.key_code = key_code, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = key_code, .pressed = false}); !status.ok()) { + return status; + } + } + + if (const auto status = submit({.key_code = 0x0D, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0x0D, .pressed = false}); !status.ok()) { + return status; + } + } + + return Status::success(); + } + + Status close() override { + return close_uinput("uinput keyboard"); + } + }; + + /** + * @brief Backend mouse backed by one Linux uinput file descriptor. + */ + class UinputMouse final: public BackendMouse, private UinputDevice { + public: + explicit UinputMouse(int file_descriptor): + UinputDevice {file_descriptor} {} + + ~UinputMouse() override { + static_cast(close()); + } + + Status create(DeviceId id, const CreateMouseOptions &options) { + if (const auto status = enable_uinput_mouse(file_descriptor()); !status.ok()) { + return status; + } + return write_uinput_user_device(file_descriptor(), options.profile, id); + } + + Status submit(const MouseEvent &event) override { + if (!is_open()) { + return Status::failure(ErrorCode::device_closed, "uinput mouse is closed"); + } + + switch (event.kind) { + case MouseEventKind::relative_motion: + return submit_relative_motion(event); + case MouseEventKind::absolute_motion: + return submit_absolute_motion(event); + case MouseEventKind::button: + return submit_button(event); + case MouseEventKind::vertical_scroll: + return submit_vertical_scroll(event.high_resolution_scroll); + case MouseEventKind::horizontal_scroll: + return submit_horizontal_scroll(event.high_resolution_scroll); + } + + return Status::failure(ErrorCode::invalid_argument, "unsupported mouse event kind"); + } + + Status close() override { + return close_uinput("uinput mouse"); + } + + private: + Status submit_relative_motion(const MouseEvent &event) { + if (event.x != 0) { + if (const auto status = emit_event(EV_REL, REL_X, event.x); !status.ok()) { + return status; + } + } + if (event.y != 0) { + if (const auto status = emit_event(EV_REL, REL_Y, event.y); !status.ok()) { + return status; + } + } + return sync(); + } + + Status submit_absolute_motion(const MouseEvent &event) { + if (const auto status = emit_event(EV_ABS, ABS_X, scale_absolute_axis(event.x, event.width)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_Y, scale_absolute_axis(event.y, event.height)); !status.ok()) { + return status; + } + return sync(); + } + + Status submit_button(const MouseEvent &event) { + if (const auto status = emit_event(EV_KEY, static_cast(mouse_button_to_linux(event.button)), event.pressed ? 1 : 0); !status.ok()) { + return status; + } + return sync(); + } + + Status submit_vertical_scroll(std::int32_t distance) { +#if defined(REL_WHEEL_HI_RES) + if (const auto status = emit_event(EV_REL, REL_WHEEL_HI_RES, distance); !status.ok()) { + return status; + } +#else + if (const auto status = emit_event(EV_REL, REL_WHEEL, legacy_scroll_steps(distance)); !status.ok()) { + return status; + } +#endif + return sync(); + } + + Status submit_horizontal_scroll(std::int32_t distance) { +#if defined(REL_HWHEEL_HI_RES) + if (const auto status = emit_event(EV_REL, REL_HWHEEL_HI_RES, distance); !status.ok()) { + return status; + } +#else + if (const auto status = emit_event(EV_REL, REL_HWHEEL, legacy_scroll_steps(distance)); !status.ok()) { + return status; + } +#endif + return sync(); + } + }; + +#if defined(LIBVIRTUALHID_HAVE_XTEST) + KeySym key_code_to_keysym(KeyboardKeyCode key_code) { + switch (key_code) { + case 0x08: + return XK_BackSpace; + case 0x09: + return XK_Tab; + case 0x0D: + return XK_Return; + case 0x10: + case 0xA0: + return XK_Shift_L; + case 0x11: + case 0xA2: + return XK_Control_L; + case 0x12: + case 0xA4: + return XK_Alt_L; + case 0x14: + return XK_Caps_Lock; + case 0x1B: + return XK_Escape; + case 0x20: + return XK_space; + case 0x21: + return XK_Page_Up; + case 0x22: + return XK_Page_Down; + case 0x23: + return XK_End; + case 0x24: + return XK_Home; + case 0x25: + return XK_Left; + case 0x26: + return XK_Up; + case 0x27: + return XK_Right; + case 0x28: + return XK_Down; + case 0x2D: + return XK_Insert; + case 0x2E: + return XK_Delete; + case 0x5B: + return XK_Super_L; + case 0x5C: + return XK_Super_R; + case 0x90: + return XK_Num_Lock; + case 0x91: + return XK_Scroll_Lock; + case 0xA1: + return XK_Shift_R; + case 0xA3: + return XK_Control_R; + case 0xA5: + return XK_Alt_R; + case 0xBA: + return XK_semicolon; + case 0xBB: + return XK_equal; + case 0xBC: + return XK_comma; + case 0xBD: + return XK_minus; + case 0xBE: + return XK_period; + case 0xBF: + return XK_slash; + case 0xC0: + return XK_grave; + case 0xDB: + return XK_bracketleft; + case 0xDC: + return XK_backslash; + case 0xDD: + return XK_bracketright; + case 0xDE: + return XK_apostrophe; + default: + break; + } + + if (key_code >= 0x30 && key_code <= 0x39) { + return XK_0 + static_cast(key_code - 0x30); + } + if (key_code >= 0x41 && key_code <= 0x5A) { + return XK_a + static_cast(key_code - 0x41); + } + if (key_code >= 0x60 && key_code <= 0x69) { + return XK_KP_0 + static_cast(key_code - 0x60); + } + if (key_code == 0x6A) { + return XK_KP_Multiply; + } + if (key_code == 0x6B) { + return XK_KP_Add; + } + if (key_code == 0x6D) { + return XK_KP_Subtract; + } + if (key_code == 0x6E) { + return XK_KP_Decimal; + } + if (key_code == 0x6F) { + return XK_KP_Divide; + } + if (key_code >= 0x70 && key_code <= 0x87) { + return XK_F1 + static_cast(key_code - 0x70); + } + + return NoSymbol; + } + + int mouse_button_to_xtest(MouseButton button) { + switch (button) { + case MouseButton::left: + return 1; + case MouseButton::middle: + return 2; + case MouseButton::right: + return 3; + case MouseButton::side: + return 8; + case MouseButton::extra: + return 9; + } + + return 1; + } + + bool query_xtest(Display *display) { + int event_base = 0; + int error_base = 0; + int major = 0; + int minor = 0; + return XTestQueryExtension(display, &event_base, &error_base, &major, &minor) == True; + } + + bool can_use_xtest() { + Display *display = XOpenDisplay(nullptr); + if (display == nullptr) { + return false; + } + + const auto available = query_xtest(display); + XCloseDisplay(display); + return available; + } + + /** + * @brief Backend keyboard backed by X11 XTest fallback events. + */ + class XTestKeyboard final: public BackendKeyboard { + public: + XTestKeyboard() = default; + + ~XTestKeyboard() override { + static_cast(close()); + } + + Status create() { + display_ = XOpenDisplay(nullptr); + if (display_ == nullptr) { + return Status::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest keyboard fallback"); + } + if (!query_xtest(display_)) { + return Status::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); + } + + return Status::success(); + } + + Status submit(const KeyboardEvent &event) override { + if (display_ == nullptr) { + return Status::failure(ErrorCode::device_closed, "XTest keyboard is closed"); + } + + const auto keysym = key_code_to_keysym(event.key_code); + if (keysym == NoSymbol) { + return Status::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by XTest fallback"); + } + + const auto keycode = XKeysymToKeycode(display_, keysym); + if (keycode == 0) { + return Status::failure(ErrorCode::invalid_argument, "keyboard key code has no X11 keycode"); + } + + XTestFakeKeyEvent(display_, keycode, event.pressed ? True : False, CurrentTime); + XFlush(display_); + return Status::success(); + } + + Status type_text(const KeyboardTextEvent &event) override { + for (const auto codepoint : decode_utf8(event.text)) { + const auto hex = uppercase_hex(codepoint); + + if (const auto status = submit({.key_code = 0xA2, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0xA0, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0x55, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0x55, .pressed = false}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0xA0, .pressed = false}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0xA2, .pressed = false}); !status.ok()) { + return status; + } + + for (const auto digit : hex) { + const auto key_code = hex_digit_key_code(digit); + if (const auto status = submit({.key_code = key_code, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = key_code, .pressed = false}); !status.ok()) { + return status; + } + } + + if (const auto status = submit({.key_code = 0x0D, .pressed = true}); !status.ok()) { + return status; + } + if (const auto status = submit({.key_code = 0x0D, .pressed = false}); !status.ok()) { + return status; + } + } + + return Status::success(); + } + + Status close() override { + if (display_ != nullptr) { + XCloseDisplay(display_); + display_ = nullptr; + } + return Status::success(); + } + + private: + Display *display_ = nullptr; + }; + + /** + * @brief Backend mouse backed by X11 XTest fallback events. + */ + class XTestMouse final: public BackendMouse { + public: + XTestMouse() = default; + + ~XTestMouse() override { + static_cast(close()); + } + + Status create() { + display_ = XOpenDisplay(nullptr); + if (display_ == nullptr) { + return Status::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest mouse fallback"); + } + if (!query_xtest(display_)) { + return Status::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); + } + + return Status::success(); + } + + Status submit(const MouseEvent &event) override { + if (display_ == nullptr) { + return Status::failure(ErrorCode::device_closed, "XTest mouse is closed"); + } + + switch (event.kind) { + case MouseEventKind::relative_motion: + XTestFakeRelativeMotionEvent(display_, event.x, event.y, CurrentTime); + break; + case MouseEventKind::absolute_motion: + submit_absolute_motion(event); + break; + case MouseEventKind::button: + XTestFakeButtonEvent(display_, mouse_button_to_xtest(event.button), event.pressed ? True : False, CurrentTime); + break; + case MouseEventKind::vertical_scroll: + submit_scroll(event.high_resolution_scroll, 4, 5); + break; + case MouseEventKind::horizontal_scroll: + submit_scroll(event.high_resolution_scroll, 6, 7); + break; + } + + XFlush(display_); + return Status::success(); + } + + Status close() override { + if (display_ != nullptr) { + XCloseDisplay(display_); + display_ = nullptr; + } + return Status::success(); + } + + private: + void submit_absolute_motion(const MouseEvent &event) { + const auto screen = DefaultScreen(display_); + const auto screen_width = DisplayWidth(display_, screen); + const auto screen_height = DisplayHeight(display_, screen); + const auto x = scale_absolute_axis(event.x, event.width) * std::max(screen_width - 1, 0) / absolute_axis_max; + const auto y = scale_absolute_axis(event.y, event.height) * std::max(screen_height - 1, 0) / absolute_axis_max; + XTestFakeMotionEvent(display_, screen, x, y, CurrentTime); + } + + void submit_scroll(std::int32_t distance, int positive_button, int negative_button) { + const auto steps = std::abs(legacy_scroll_steps(distance)); + const auto button = distance >= 0 ? positive_button : negative_button; + for (auto i = 0; i < steps; ++i) { + XTestFakeButtonEvent(display_, button, True, CurrentTime); + XTestFakeButtonEvent(display_, button, False, CurrentTime); + } + } + + Display *display_ = nullptr; + }; +#else + bool can_use_xtest() { + return false; + } +#endif + /** * @brief Backend gamepad backed by one Linux UHID file descriptor. */ @@ -285,10 +1238,15 @@ namespace lvh::detail { public: LinuxUhidBackend() { const auto uhid_accessible = can_access_uhid(); - capabilities_.backend_name = "linux-uhid"; - capabilities_.supports_virtual_hid = uhid_accessible; + const auto uinput_accessible = can_access_uinput(); + const auto xtest_accessible = can_use_xtest(); + capabilities_.backend_name = "linux-uhid-uinput"; + capabilities_.supports_virtual_hid = uhid_accessible || uinput_accessible; capabilities_.supports_gamepad = uhid_accessible; + capabilities_.supports_keyboard = uinput_accessible || xtest_accessible; + capabilities_.supports_mouse = uinput_accessible || xtest_accessible; capabilities_.supports_output_reports = uhid_accessible; + capabilities_.supports_xtest_fallback = xtest_accessible; } const BackendCapabilities &capabilities() const override { @@ -310,7 +1268,69 @@ namespace lvh::detail { return {Status::success(), std::move(gamepad)}; } + BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) override { + const auto fd = ::open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return create_xtest_keyboard(); + } + + auto keyboard = std::make_unique(fd); + if (const auto status = keyboard->create(id, options); !status.ok()) { + static_cast(keyboard->close()); + auto fallback = create_xtest_keyboard(); + if (fallback) { + return fallback; + } + return {status, nullptr}; + } + + return {Status::success(), std::move(keyboard)}; + } + + BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { + const auto fd = ::open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return create_xtest_mouse(); + } + + auto mouse = std::make_unique(fd); + if (const auto status = mouse->create(id, options); !status.ok()) { + static_cast(mouse->close()); + auto fallback = create_xtest_mouse(); + if (fallback) { + return fallback; + } + return {status, nullptr}; + } + + return {Status::success(), std::move(mouse)}; + } + private: + BackendKeyboardCreationResult create_xtest_keyboard() { +#if defined(LIBVIRTUALHID_HAVE_XTEST) + auto keyboard = std::make_unique(); + if (const auto status = keyboard->create(); !status.ok()) { + return {status, nullptr}; + } + return {Status::success(), std::move(keyboard)}; +#else + return {Status::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; +#endif + } + + BackendMouseCreationResult create_xtest_mouse() { +#if defined(LIBVIRTUALHID_HAVE_XTEST) + auto mouse = std::make_unique(); + if (const auto status = mouse->create(); !status.ok()) { + return {status, nullptr}; + } + return {Status::success(), std::move(mouse)}; +#else + return {Status::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; +#endif + } + BackendCapabilities capabilities_; }; diff --git a/src/platform/unsupported_backend.cpp b/src/platform/unsupported_backend.cpp index a8f12ca..43b27a2 100644 --- a/src/platform/unsupported_backend.cpp +++ b/src/platform/unsupported_backend.cpp @@ -29,6 +29,17 @@ namespace lvh::detail { return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; } + BackendKeyboardCreationResult create_keyboard( + DeviceId /*id*/, + const CreateKeyboardOptions & /*options*/ + ) override { + return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions & /*options*/) override { + return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + private: BackendCapabilities capabilities_; }; diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 39ea444..aeed481 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -63,3 +63,18 @@ TEST(ProfileTest, CanFindProfileByKind) { ASSERT_TRUE(profile.has_value()); EXPECT_EQ(profile->gamepad_kind, lvh::GamepadProfileKind::xbox_series); } + +TEST(ProfileTest, KeyboardAndMouseProfilesArePresent) { + const auto keyboard = lvh::profiles::keyboard(); + const auto mouse = lvh::profiles::mouse(); + + EXPECT_EQ(keyboard.device_type, lvh::DeviceType::keyboard); + EXPECT_FALSE(keyboard.name.empty()); + EXPECT_NE(keyboard.vendor_id, 0); + EXPECT_NE(keyboard.product_id, 0); + + EXPECT_EQ(mouse.device_type, lvh::DeviceType::mouse); + EXPECT_FALSE(mouse.name.empty()); + EXPECT_NE(mouse.vendor_id, 0); + EXPECT_NE(mouse.product_id, 0); +} diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 7e80c24..2dd23af 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -18,6 +18,8 @@ TEST(RuntimeTest, FakeBackendReportsCapabilities) { EXPECT_EQ(runtime->backend_kind(), lvh::BackendKind::fake); EXPECT_EQ(runtime->capabilities().backend_name, "fake"); EXPECT_TRUE(runtime->capabilities().supports_gamepad); + EXPECT_TRUE(runtime->capabilities().supports_keyboard); + EXPECT_TRUE(runtime->capabilities().supports_mouse); EXPECT_TRUE(runtime->capabilities().supports_output_reports); EXPECT_FALSE(runtime->capabilities().requires_installed_driver); } @@ -30,10 +32,7 @@ TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { EXPECT_EQ(runtime->backend_kind(), lvh::BackendKind::platform_default); #if defined(__linux__) - EXPECT_EQ(runtime->capabilities().backend_name, "linux-uhid"); - EXPECT_FALSE(runtime->capabilities().supports_keyboard); - EXPECT_FALSE(runtime->capabilities().supports_mouse); - EXPECT_FALSE(runtime->capabilities().supports_xtest_fallback); + EXPECT_EQ(runtime->capabilities().backend_name, "linux-uhid-uinput"); EXPECT_FALSE(runtime->capabilities().requires_installed_driver); #else EXPECT_EQ(runtime->capabilities().backend_name, "platform-default-unimplemented"); @@ -95,6 +94,61 @@ TEST(RuntimeTest, DispatchesOutputCallback) { EXPECT_EQ(received.high_frequency_rumble, 456); } +TEST(RuntimeTest, CreatesSubmitsAndClosesKeyboard) { + auto runtime = lvh::Runtime::create(); + auto created = runtime->create_keyboard(); + + ASSERT_TRUE(created); + ASSERT_NE(created.keyboard, nullptr); + EXPECT_TRUE(created.keyboard->is_open()); + EXPECT_EQ(created.keyboard->profile().device_type, lvh::DeviceType::keyboard); + EXPECT_EQ(runtime->active_device_count(), 1U); + + EXPECT_TRUE(created.keyboard->press(0x41).ok()); + EXPECT_EQ(created.keyboard->submit_count(), 1U); + EXPECT_EQ(created.keyboard->last_submitted_event().key_code, 0x41); + EXPECT_TRUE(created.keyboard->last_submitted_event().pressed); + + EXPECT_TRUE(created.keyboard->release(0x41).ok()); + EXPECT_TRUE(created.keyboard->type_text({.text = "A"}).ok()); + EXPECT_EQ(created.keyboard->submit_count(), 3U); + + EXPECT_EQ(created.keyboard->press(0).code(), lvh::ErrorCode::invalid_argument); + EXPECT_TRUE(created.keyboard->close().ok()); + EXPECT_FALSE(created.keyboard->is_open()); + EXPECT_EQ(runtime->active_device_count(), 0U); + EXPECT_EQ(created.keyboard->press(0x41).code(), lvh::ErrorCode::device_closed); +} + +TEST(RuntimeTest, CreatesSubmitsAndClosesMouse) { + auto runtime = lvh::Runtime::create(); + auto created = runtime->create_mouse(); + + ASSERT_TRUE(created); + ASSERT_NE(created.mouse, nullptr); + EXPECT_TRUE(created.mouse->is_open()); + EXPECT_EQ(created.mouse->profile().device_type, lvh::DeviceType::mouse); + EXPECT_EQ(runtime->active_device_count(), 1U); + + EXPECT_TRUE(created.mouse->move_relative(10, -5).ok()); + EXPECT_EQ(created.mouse->submit_count(), 1U); + EXPECT_EQ(created.mouse->last_submitted_event().kind, lvh::MouseEventKind::relative_motion); + EXPECT_EQ(created.mouse->last_submitted_event().x, 10); + EXPECT_EQ(created.mouse->last_submitted_event().y, -5); + + EXPECT_TRUE(created.mouse->move_absolute(100, 200, 1920, 1080).ok()); + EXPECT_TRUE(created.mouse->button(lvh::MouseButton::right, true).ok()); + EXPECT_TRUE(created.mouse->vertical_scroll(120).ok()); + EXPECT_TRUE(created.mouse->horizontal_scroll(-120).ok()); + EXPECT_EQ(created.mouse->submit_count(), 5U); + + EXPECT_EQ(created.mouse->move_absolute(1, 1, 0, 0).code(), lvh::ErrorCode::invalid_argument); + EXPECT_TRUE(created.mouse->close().ok()); + EXPECT_FALSE(created.mouse->is_open()); + EXPECT_EQ(runtime->active_device_count(), 0U); + EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); +} + TEST(RuntimeTest, LinuxUhidSmokeTestWhenExplicitlyEnabled) { #if defined(__linux__) const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS"); @@ -123,3 +177,31 @@ TEST(RuntimeTest, LinuxUhidSmokeTestWhenExplicitlyEnabled) { GTEST_SKIP() << "UHID is only available on Linux"; #endif } + +TEST(RuntimeTest, LinuxUinputSmokeTestWhenExplicitlyEnabled) { +#if defined(__linux__) + const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS"); + if (enabled == nullptr || std::string_view {enabled} != "1") { + GTEST_SKIP() << "set LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS=1 to exercise /dev/uinput"; + } + + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(options); + if (!runtime->capabilities().supports_keyboard || !runtime->capabilities().supports_mouse) { + GTEST_SKIP() << "/dev/uinput or XTest fallback is not accessible"; + } + + auto keyboard = runtime->create_keyboard(); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + EXPECT_TRUE(keyboard.keyboard->press(0x41).ok()); + EXPECT_TRUE(keyboard.keyboard->release(0x41).ok()); + + auto mouse = runtime->create_mouse(); + ASSERT_TRUE(mouse) << mouse.status.message(); + EXPECT_TRUE(mouse.mouse->move_relative(1, 1).ok()); + EXPECT_TRUE(mouse.mouse->vertical_scroll(120).ok()); +#else + GTEST_SKIP() << "uinput is only available on Linux"; +#endif +} From 3a5cf39616db7cbbcc147e840b002e9510f4fa89 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:20:52 -0400 Subject: [PATCH 19/28] Generalize Sunshine adapters, tests, and docs Replace Sunshine-specific naming and wording with more generic/streaming-host-oriented terms. Renamed example sources and targets (sunshine_gamepad_adapter -> gamepad_adapter, sunshine_keyboard_mouse_adapter -> keyboard_mouse_adapter) and updated CMakeLists and CI workflow to run the new binaries. Renamed unit test and test fixture (test_sunshine_adapter -> test_gamepad_lifecycle) and updated stable_id values used in examples/tests. Documentation updates in AGENTS.md and README.md shift wording from Sunshine to remote/streaming hosts, clarify Linux uinput/uhid rules and setup, and adjust preferred backend guidance. Also refined a types.hpp comment to clarify the KeyboardKeyCode interpretation for backends. --- .github/workflows/ci.yml | 10 ++-- AGENTS.md | 8 +-- README.md | 58 ++++++++++++++----- examples/CMakeLists.txt | 16 ++--- ...amepad_adapter.cpp => gamepad_adapter.cpp} | 6 +- ...adapter.cpp => keyboard_mouse_adapter.cpp} | 4 +- include/libvirtualhid/types.hpp | 6 +- tests/CMakeLists.txt | 2 +- ...adapter.cpp => test_gamepad_lifecycle.cpp} | 8 +-- tests/unit/test_profiles.cpp | 2 +- 10 files changed, 74 insertions(+), 46 deletions(-) rename examples/{sunshine_gamepad_adapter.cpp => gamepad_adapter.cpp} (90%) rename examples/{sunshine_keyboard_mouse_adapter.cpp => keyboard_mouse_adapter.cpp} (93%) rename tests/unit/{test_sunshine_adapter.cpp => test_gamepad_lifecycle.cpp} (89%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09b207c..61c1f62 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -254,18 +254,18 @@ jobs: --xml-pretty \ -o reports/coverage.xml - - name: Run Sunshine adapter example + - name: Run gamepad adapter example if: matrix.kind != 'msvc' run: | if [[ "${RUNNER_OS}" == "Windows" ]]; then - ./cmake-build-ci/examples/sunshine_gamepad_adapter.exe + ./cmake-build-ci/examples/gamepad_adapter.exe else - ./cmake-build-ci/examples/sunshine_gamepad_adapter + ./cmake-build-ci/examples/gamepad_adapter fi - - name: Run Sunshine adapter example MSVC + - name: Run gamepad adapter example MSVC if: matrix.kind == 'msvc' - run: .\cmake-build-ci\examples\Debug\sunshine_gamepad_adapter.exe + run: .\cmake-build-ci\examples\Debug\gamepad_adapter.exe - name: Install if: matrix.kind != 'msvc' diff --git a/AGENTS.md b/AGENTS.md index b6a4e26..9575465 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,15 +11,15 @@ The project uses gtest as a test framework. GoogleTest is vendored as a submodul Keep the public c++ API platform-neutral. Platform-specific virtual HID details belong behind backend implementations and should not leak into consumer code. -Gamepad support is the primary target. Sunshine is the first target consumer, so validate API and behavior -changes against the Sunshine-oriented adapter example and tests. +Gamepad support is the primary target. Remote streaming hosts are the first consumer class, so validate API +and behavior changes against the adapter examples and lifecycle tests. Windows support must remain user-mode. Do not add a custom kernel-mode driver. The normal c++ library should remain buildable with both MSVC and MinGW/UCRT64; any future UMDF driver package is a separate WDK/MSVC build artifact. -Linux gamepad support should prefer uinput, evdev, and uhid. X11 XTest fallbacks are only for secondary -keyboard and mouse goals. +Linux gamepad support should prefer uhid for descriptor-driven controllers. Keyboard and mouse support should +prefer uinput, with X11 XTest only as a fallback. Always update public documentation when changing headers, backends, or consumer-facing behavior. diff --git a/README.md b/README.md index fae79b9..d3a63aa 100644 --- a/README.md +++ b/README.md @@ -103,12 +103,48 @@ node is missing or permission is denied, the same backend remains selectable but reports the affected capability as unavailable and returns `backend_unavailable` from that device creation path. -A minimal udev rule for hosts that grant controller creation to the `input` -group is: +The Linux packaging model needs `/dev/uinput` and `/dev/uhid` access. Install a udev rules file such +as `/etc/udev/rules.d/60-libvirtualhid.rules` with: ```udev +# Allows libvirtualhid consumers to access /dev/uinput +KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess" + +# Allows libvirtualhid consumers to access /dev/uhid KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess" -KERNEL=="uinput", GROUP="input", MODE="0660", TAG+="uaccess" +``` + +Consuming applications may also install name-matched rules for their stable +virtual device names when generated `hidraw` or `input` nodes must be +accessible to the session user: + +```udev +KERNEL=="hidraw*", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="0660", TAG+="uaccess" +SUBSYSTEMS=="input", ATTRS{name}=="Your App Controller*", GROUP="input", MODE="0660", TAG+="uaccess" +``` + +For `uhid` gamepad support, install a modules-load entry such as +`/etc/modules-load.d/60-libvirtualhid.conf` containing: + +```text +uhid +``` + +After installing the rules, load `uhid`, reload udev, and trigger the device +nodes: + +```bash +sudo modprobe uhid +sudo udevadm control --reload-rules +sudo udevadm trigger --property-match=DEVNAME=/dev/uinput +sudo udevadm trigger --property-match=DEVNAME=/dev/uhid +``` + +If input still does not work, add the user running the consuming application to +the `input` group, then log out and back in: + +```bash +sudo usermod -aG input $USER ``` The Linux UHID smoke test is opt-in because it creates a real virtual gamepad. @@ -124,7 +160,6 @@ keyboard and mouse injection on X11, but it does not create virtual HID devices, does not help on Wayland, and should not replace `uhid`/`uinput` for gamepads. It is enabled automatically when `LIBVIRTUALHID_ENABLE_XTEST` is `ON` and CMake finds X11/XTest development files. -Sunshine's removed legacy implementation is the first reference for this path: commit `8227e8f8` added the XTest input fallback, and commit `f57aee90` removed `src/platform/linux/input/legacy_input.cpp` when Sunshine moved fully to inputtino. @@ -180,16 +215,12 @@ Expected core types: `supports_keyboard`, `supports_mouse`, `supports_xtest_fallback`, and `requires_installed_driver`. -## Sunshine Integration Requirements -Sunshine is the first consumer to design against. The initial implementation should cover Sunshine's active input behavior before optimizing for unrelated consumers: -- [x] CMake consumption must work as a vendored dependency under Sunshine's `third-party` tree. - [x] The API must support multiple client-relative and global gamepad indexes so - Sunshine can preserve stable controller lifecycles across arrival, update, feedback, and removal events. - [x] Built-in profiles should cover Sunshine's current gamepad choices: automatic selection, Xbox One-style, DualSense-style, and Switch Pro-style devices. Xbox @@ -200,15 +231,12 @@ consumers: - [ ] Output callbacks must carry rumble first, then RGB LED, adaptive trigger, motion activation, and raw output report data where the selected profile supports it. -- [x] Keyboard and mouse APIs should map cleanly to Sunshine's current relative mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, and Unicode paths. - [x] Linux fallback behavior should match Sunshine's operational expectation: prefer real virtual devices through `uhid`/`uinput`; only use XTest for keyboard/mouse when virtual device creation fails and X11 is available. -- [x] The library must not own Sunshine's network protocol, Moonlight packet parsing, configuration system, or feedback queue. It should expose the device - primitives Sunshine needs to keep that ownership in Sunshine. ## Tooling and Dependency Plan @@ -268,17 +296,17 @@ third-party/googletest/ GoogleTest submodule - [x] Add descriptor/profile models for at least Xbox 360, Xbox Series, DualSense, and a generic HID gamepad. - [x] Add unit tests for state normalization and HID report packing. -- [x] Add a Sunshine-oriented example or adapter test that exercises controller - arrival, state updates, output feedback, and removal without depending on - Sunshine internals. +- [x] Add a streaming-host-oriented example or adapter test that exercises + controller arrival, state updates, output feedback, and removal without + depending on consumer internals. ### Phase 2: Linux MVP - [x] Implement gamepad creation over `uhid` for descriptor-driven controllers. - [x] Add `uinput` support for keyboard and mouse once the gamepad path is stable. - [x] Support output report callbacks for rumble and profile-specific feedback. -- [x] Add X11/XTest fallback support for keyboard and mouse only, using Sunshine's historical legacy input implementation as the reference point. +- [x] Add X11/XTest fallback support for keyboard and mouse only. - [ ] Add examples and integration tests that validate SDL/HIDAPI discovery where available. - [x] Document required Linux permissions and sample udev rules. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 0c3aad4..b560a9f 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -1,16 +1,16 @@ -add_executable(sunshine_gamepad_adapter - "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_gamepad_adapter.cpp") +add_executable(gamepad_adapter + "${CMAKE_CURRENT_SOURCE_DIR}/gamepad_adapter.cpp") -add_executable(sunshine_keyboard_mouse_adapter - "${CMAKE_CURRENT_SOURCE_DIR}/sunshine_keyboard_mouse_adapter.cpp") +add_executable(keyboard_mouse_adapter + "${CMAKE_CURRENT_SOURCE_DIR}/keyboard_mouse_adapter.cpp") -target_link_libraries(sunshine_gamepad_adapter +target_link_libraries(gamepad_adapter PRIVATE libvirtualhid::libvirtualhid) -target_link_libraries(sunshine_keyboard_mouse_adapter +target_link_libraries(keyboard_mouse_adapter PRIVATE libvirtualhid::libvirtualhid) -libvirtualhid_copy_mingw_runtime(sunshine_gamepad_adapter) -libvirtualhid_copy_mingw_runtime(sunshine_keyboard_mouse_adapter) +libvirtualhid_copy_mingw_runtime(gamepad_adapter) +libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) diff --git a/examples/sunshine_gamepad_adapter.cpp b/examples/gamepad_adapter.cpp similarity index 90% rename from examples/sunshine_gamepad_adapter.cpp rename to examples/gamepad_adapter.cpp index 015578c..962188c 100644 --- a/examples/sunshine_gamepad_adapter.cpp +++ b/examples/gamepad_adapter.cpp @@ -1,6 +1,6 @@ /** - * @file examples/sunshine_gamepad_adapter.cpp - * @brief Minimal Sunshine-oriented gamepad adapter example. + * @file examples/gamepad_adapter.cpp + * @brief Minimal gamepad adapter example. */ // standard includes @@ -21,7 +21,7 @@ int main() { options.metadata.has_touchpad = true; options.metadata.has_rgb_led = true; options.metadata.has_battery = true; - options.metadata.stable_id = "sunshine-client-0"; + options.metadata.stable_id = "remote-client-0"; auto created = runtime->create_gamepad(options); if (!created) { diff --git a/examples/sunshine_keyboard_mouse_adapter.cpp b/examples/keyboard_mouse_adapter.cpp similarity index 93% rename from examples/sunshine_keyboard_mouse_adapter.cpp rename to examples/keyboard_mouse_adapter.cpp index 789944f..c52780c 100644 --- a/examples/sunshine_keyboard_mouse_adapter.cpp +++ b/examples/keyboard_mouse_adapter.cpp @@ -1,6 +1,6 @@ /** - * @file examples/sunshine_keyboard_mouse_adapter.cpp - * @brief Minimal Sunshine-style keyboard and mouse input example. + * @file examples/keyboard_mouse_adapter.cpp + * @brief Minimal keyboard and mouse input example. */ // standard includes diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index 29ab677..06e6321 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -505,9 +505,9 @@ namespace lvh { /** * @brief Keyboard key code accepted by the keyboard event model. * - * The initial Linux backend treats this as a Moonlight/Windows virtual-key code - * because that matches Sunshine's current input path. Backends translate this - * value to their native key representation. + * The initial Linux backend treats this as a Windows virtual-key code so + * streaming hosts can pass common client key codes without exposing platform + * backends. Backends translate this value to their native key representation. */ using KeyboardKeyCode = std::uint16_t; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 90828fe..fbcddfc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -19,7 +19,7 @@ add_executable(${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_sunshine_adapter.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp") target_include_directories(${TEST_BINARY} PRIVATE diff --git a/tests/unit/test_sunshine_adapter.cpp b/tests/unit/test_gamepad_lifecycle.cpp similarity index 89% rename from tests/unit/test_sunshine_adapter.cpp rename to tests/unit/test_gamepad_lifecycle.cpp index e3f4f6d..b362656 100644 --- a/tests/unit/test_sunshine_adapter.cpp +++ b/tests/unit/test_gamepad_lifecycle.cpp @@ -1,6 +1,6 @@ /** - * @file tests/unit/test_sunshine_adapter.cpp - * @brief Unit tests for the Sunshine-oriented gamepad lifecycle. + * @file tests/unit/test_gamepad_lifecycle.cpp + * @brief Unit tests for the gamepad lifecycle. */ // local includes @@ -8,7 +8,7 @@ #include -TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { +TEST(GamepadLifecycleTest, ExercisesArrivalUpdateFeedbackAndRemoval) { auto runtime = lvh::Runtime::create(); lvh::CreateGamepadOptions options; @@ -20,7 +20,7 @@ TEST(SunshineAdapterTest, ExercisesArrivalUpdateFeedbackAndRemoval) { options.metadata.has_touchpad = true; options.metadata.has_rgb_led = true; options.metadata.has_battery = true; - options.metadata.stable_id = "moonlight-client-0"; + options.metadata.stable_id = "remote-client-0"; auto created = runtime->create_gamepad(options); ASSERT_TRUE(created); diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index aeed481..78461a2 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -23,7 +23,7 @@ TEST(ProfileTest, BuiltInProfilesHaveDescriptors) { } } -TEST(ProfileTest, SunshineProfilesArePresent) { +TEST(ProfileTest, StreamingControllerProfilesArePresent) { const auto xbox_one = lvh::profiles::xbox_one(); const auto dualsense = lvh::profiles::dualsense(); const auto switch_pro = lvh::profiles::switch_pro(); From 4f28b953673e4c9c8f683b4b002096db9e95df3a Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:38:00 -0400 Subject: [PATCH 20/28] Add Linux discovery probe example and tests Add an opt-in Linux discovery integration: new example (examples/linux_discovery_probe.cpp) and test (tests/unit/test_linux_discovery.cpp) that create a UHID gamepad and probe external discovery tools (sdl2-jstest, hidapitester). README updated to document the LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS env var, example behavior, and related streaming-host requirements; several checklist items were adjusted. CMakeLists updated to build the linux_discovery_probe example on Linux and include the new test in the test binary. Tests skip cleanly when UHID or probe tools are unavailable. --- README.md | 62 ++++++---- examples/CMakeLists.txt | 9 ++ examples/linux_discovery_probe.cpp | 168 +++++++++++++++++++++++++++ tests/CMakeLists.txt | 5 +- tests/unit/test_linux_discovery.cpp | 169 ++++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 21 deletions(-) create mode 100644 examples/linux_discovery_probe.cpp create mode 100644 tests/unit/test_linux_discovery.cpp diff --git a/README.md b/README.md index d3a63aa..eeeda59 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,17 @@ The Linux uinput smoke test is opt-in because it creates real keyboard and mouse devices. Run it with `LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS=1` on a Linux host where the current user can open `/dev/uinput`. +The Linux discovery integration test is opt-in because it creates a real UHID +gamepad and probes external input discovery tools. Run it with +`LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS=1` on a Linux host where the +current user can open `/dev/uhid`. The test uses `sdl2-jstest` and +`hidapitester` when either tool is installed, and skips cleanly when no +discovery tool is available. + +When `BUILD_EXAMPLES` is enabled on Linux, the `linux_discovery_probe` example +creates a generic UHID gamepad and performs the same SDL/HIDAPI discovery probe +outside the test runner. + The XTest fallback should not be treated as a gamepad backend. It can cover keyboard and mouse injection on X11, but it does not create virtual HID devices, does not help on Wayland, and should not replace `uhid`/`uinput` for gamepads. @@ -182,18 +193,22 @@ portable concepts instead of platform concepts: auto runtime = lvh::Runtime::create(); -auto gamepad = runtime->create_gamepad(lvh::profiles::xbox_360()); +auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); +if (!created) { + return; +} -gamepad->set_output_callback([](const lvh::GamepadOutput& output) { +auto &gamepad = *created.gamepad; +gamepad.set_output_callback([](const lvh::GamepadOutput &output) { // Route rumble, LED, or trigger feedback back to the physical controller. }); lvh::GamepadState state; -state.buttons.set(lvh::GamepadButton::A, true); +state.buttons.set(lvh::GamepadButton::a, true); state.left_stick = {0.25f, -0.5f}; state.right_trigger = 1.0f; -gamepad->submit(state); +gamepad.submit(state); ``` Expected core types: @@ -215,28 +230,37 @@ Expected core types: `supports_keyboard`, `supports_mouse`, `supports_xtest_fallback`, and `requires_installed_driver`. +## Streaming Host Integration Requirements -should cover Sunshine's active input behavior before optimizing for unrelated -consumers: +Streaming hosts are the first consumer class to design against. The initial +implementation should cover the behavior Sunshine needs first, while keeping +the requirements expressed in terms that apply to other consumers: - `third-party` tree. +- [x] CMake consumption must work as a vendored dependency under a consuming + project's `third-party` tree. - [x] The API must support multiple client-relative and global gamepad indexes so - feedback, and removal events. -- [x] Built-in profiles should cover Sunshine's current gamepad choices: automatic - selection, Xbox One-style, DualSense-style, and Switch Pro-style devices. Xbox - 360 can remain useful as a compatibility profile and test target. -- [x] Controller metadata must be rich enough for Sunshine's selection rules: + streaming hosts can preserve stable controller lifecycles across arrival, + update, feedback, and removal events. +- [x] Built-in profiles should cover common streaming controller choices: + automatic selection, Xbox One-style, DualSense-style, and Switch Pro-style + devices. Xbox 360 can remain useful as a compatibility profile and test + target. +- [x] Controller metadata must be rich enough for streaming-host selection rules: client controller type, motion sensor capability, touchpad capability, RGB LED support, battery state, and per-controller identity data. - [ ] Output callbacks must carry rumble first, then RGB LED, adaptive trigger, motion activation, and raw output report data where the selected profile supports it. - mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, - and Unicode paths. -- [x] Linux fallback behavior should match Sunshine's operational expectation: +- [x] Keyboard and mouse APIs should map cleanly to common relative mouse, + absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, and + Unicode paths. +- [x] Linux fallback behavior should match streaming-host operational + expectations: prefer real virtual devices through `uhid`/`uinput`; only use XTest for keyboard/mouse when virtual device creation fails and X11 is available. +- [x] The library must not own a consumer's network protocol, client packet parsing, configuration system, or feedback queue. It should expose the device + primitives consumers need to keep that ownership in their applications. ## Tooling and Dependency Plan @@ -305,9 +329,8 @@ third-party/googletest/ GoogleTest submodule - [x] Implement gamepad creation over `uhid` for descriptor-driven controllers. - [x] Add `uinput` support for keyboard and mouse once the gamepad path is stable. - [x] Support output report callbacks for rumble and profile-specific feedback. - historical legacy input implementation as the reference point. - [x] Add X11/XTest fallback support for keyboard and mouse only. -- [ ] Add examples and integration tests that validate SDL/HIDAPI discovery where +- [x] Add examples and integration tests that validate SDL/HIDAPI discovery where available. - [x] Document required Linux permissions and sample udev rules. @@ -347,8 +370,9 @@ third-party/googletest/ GoogleTest submodule - [ ] Run lifecycle tests for create, submit, output callback, destroy, repeated hot-plug, and process shutdown cleanup. - [ ] Validate multi-controller behavior and stable ordering. -- [ ] Test against real consumers where practical: SDL, HIDAPI, browser Gamepad API, - DirectInput/XInput/GameInput on Windows, and evdev/libinput tooling on Linux. +- [ ] Test against real consumers where practical: Sunshine, SDL, HIDAPI, browser + Gamepad API, DirectInput/XInput/GameInput on Windows, and evdev/libinput + tooling on Linux. ## License diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index b560a9f..2319a86 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -14,3 +14,12 @@ target_link_libraries(keyboard_mouse_adapter libvirtualhid_copy_mingw_runtime(gamepad_adapter) libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(linux_discovery_probe + "${CMAKE_CURRENT_SOURCE_DIR}/linux_discovery_probe.cpp") + + target_link_libraries(linux_discovery_probe + PRIVATE + libvirtualhid::libvirtualhid) +endif() diff --git a/examples/linux_discovery_probe.cpp b/examples/linux_discovery_probe.cpp new file mode 100644 index 0000000..36641ef --- /dev/null +++ b/examples/linux_discovery_probe.cpp @@ -0,0 +1,168 @@ +/** + * @file examples/linux_discovery_probe.cpp + * @brief Linux virtual gamepad discovery probe for external input libraries. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include + +// local includes +#include + +namespace { + + struct Probe { + std::string name; + std::string executable; + std::string command; + }; + + struct CommandResult { + int exit_code = 1; + std::string output; + }; + + std::vector discovery_probes() { + return { + {"SDL2 joystick list", "sdl2-jstest", "sdl2-jstest --list"}, + {"HIDAPI device list", "hidapitester", "hidapitester --list"}, + }; + } + + bool command_available(std::string_view executable) { + const auto command = "command -v " + std::string {executable} + " >/dev/null 2>&1"; + return std::system(command.c_str()) == 0; + } + + std::string read_file(const std::filesystem::path &path) { + std::ifstream file {path}; + std::ostringstream stream; + stream << file.rdbuf(); + return stream.str(); + } + + CommandResult run_command(const Probe &probe, std::size_t index) { + const auto output_path = std::filesystem::temp_directory_path() / + ("libvirtualhid-discovery-" + std::to_string(::getpid()) + "-" + + std::to_string(index) + ".txt"); + const auto command = probe.command + " > \"" + output_path.string() + "\" 2>&1"; + CommandResult result; + result.exit_code = std::system(command.c_str()); + result.output = read_file(output_path); + std::error_code error; + std::filesystem::remove(output_path, error); + return result; + } + + std::string lowercase(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; + } + + std::string hex4(std::uint16_t value) { + std::ostringstream stream; + stream << std::hex << std::nouppercase << std::setw(4) << std::setfill('0') << value; + return stream.str(); + } + + bool output_matches_profile(std::string_view output, const lvh::DeviceProfile &profile) { + const auto lower_output = lowercase(std::string {output}); + const auto lower_name = lowercase(profile.name); + const auto vendor = hex4(profile.vendor_id); + const auto product = hex4(profile.product_id); + + return lower_output.find(lower_name) != std::string::npos || + lower_output.find(vendor + ":" + product) != std::string::npos || + lower_output.find(vendor + "/" + product) != std::string::npos; + } + + bool wait_for_probe(const Probe &probe, const lvh::DeviceProfile &profile, std::size_t index) { + CommandResult result; + for (auto attempt = 0; attempt < 12; ++attempt) { + result = run_command(probe, index); + if (result.exit_code == 0 && output_matches_profile(result.output, profile)) { + std::cout << probe.name << " discovered " << profile.name << '\n'; + return true; + } + + std::this_thread::sleep_for(std::chrono::milliseconds {250}); + } + + std::cerr << probe.name << " did not discover " << profile.name << '\n' + << "Command output:" << '\n' + << result.output << '\n'; + return false; + } + +} // namespace + +int main() { + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + if (!runtime->capabilities().supports_gamepad) { + std::cerr << "/dev/uhid is not accessible; install udev rules or run with sufficient permissions" << '\n'; + return 1; + } + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::generic_gamepad(); + options.metadata.stable_id = "libvirtualhid-discovery-probe"; + + auto created = runtime->create_gamepad(options); + if (!created) { + std::cerr << created.status.message() << '\n'; + return 1; + } + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.left_stick = {0.25F, -0.25F}; + if (const auto status = created.gamepad->submit(state); !status.ok()) { + std::cerr << status.message() << '\n'; + return 1; + } + + std::size_t available_probe_count = 0; + std::size_t discovered_probe_count = 0; + const auto probes = discovery_probes(); + for (std::size_t index = 0; index < probes.size(); ++index) { + const auto &probe = probes[index]; + if (!command_available(probe.executable)) { + std::cout << probe.executable << " is not installed; skipping " << probe.name << '\n'; + continue; + } + + ++available_probe_count; + if (wait_for_probe(probe, options.profile, index)) { + ++discovered_probe_count; + } + } + + if (available_probe_count == 0) { + std::cout << "Install sdl2-jstest or hidapitester to validate external discovery." << '\n'; + return 0; + } + + return discovered_probe_count == available_probe_count ? 0 : 1; +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index fbcddfc..f6b7971 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,10 +16,11 @@ set(TEST_BINARY test_libvirtualhid) add_executable(${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/fixtures.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_discovery.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" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp") + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp") target_include_directories(${TEST_BINARY} PRIVATE diff --git a/tests/unit/test_linux_discovery.cpp b/tests/unit/test_linux_discovery.cpp new file mode 100644 index 0000000..8904d0a --- /dev/null +++ b/tests/unit/test_linux_discovery.cpp @@ -0,0 +1,169 @@ +/** + * @file tests/unit/test_linux_discovery.cpp + * @brief Linux integration tests for external discovery of virtual devices. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#if defined(__linux__) + #include +#endif + +// local includes +#include "fixtures/fixtures.hpp" + +#include + +namespace { + +#if defined(__linux__) + struct DiscoveryProbe { + std::string name; + std::string executable; + std::string command; + }; + + struct CommandResult { + int exit_code = 1; + std::string output; + }; + + std::vector discovery_probes() { + return { + {"SDL2 joystick list", "sdl2-jstest", "sdl2-jstest --list"}, + {"HIDAPI device list", "hidapitester", "hidapitester --list"}, + }; + } + + bool command_available(std::string_view executable) { + const auto command = "command -v " + std::string {executable} + " >/dev/null 2>&1"; + return std::system(command.c_str()) == 0; + } + + std::string read_file(const std::filesystem::path &path) { + std::ifstream file {path}; + std::ostringstream stream; + stream << file.rdbuf(); + return stream.str(); + } + + CommandResult run_command(const DiscoveryProbe &probe, std::size_t index) { + const auto output_path = std::filesystem::temp_directory_path() / + ("libvirtualhid-discovery-test-" + std::to_string(::getpid()) + "-" + + std::to_string(index) + ".txt"); + const auto command = probe.command + " > \"" + output_path.string() + "\" 2>&1"; + CommandResult result; + result.exit_code = std::system(command.c_str()); + result.output = read_file(output_path); + std::error_code error; + std::filesystem::remove(output_path, error); + return result; + } + + std::string lowercase(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { + return static_cast(std::tolower(character)); + }); + return value; + } + + std::string hex4(std::uint16_t value) { + std::ostringstream stream; + stream << std::hex << std::nouppercase << std::setw(4) << std::setfill('0') << value; + return stream.str(); + } + + bool output_matches_profile(std::string_view output, const lvh::DeviceProfile &profile) { + const auto lower_output = lowercase(std::string {output}); + const auto lower_name = lowercase(profile.name); + const auto vendor = hex4(profile.vendor_id); + const auto product = hex4(profile.product_id); + + return lower_output.find(lower_name) != std::string::npos || + lower_output.find(vendor + ":" + product) != std::string::npos || + lower_output.find(vendor + "/" + product) != std::string::npos; + } + + bool wait_for_probe(const DiscoveryProbe &probe, const lvh::DeviceProfile &profile, std::size_t index) { + CommandResult result; + for (auto attempt = 0; attempt < 12; ++attempt) { + result = run_command(probe, index); + if (result.exit_code == 0 && output_matches_profile(result.output, profile)) { + std::cout << probe.name << " discovered " << profile.name << '\n'; + return true; + } + + std::this_thread::sleep_for(std::chrono::milliseconds {250}); + } + + std::cout << probe.name << " output:" << '\n' + << result.output << '\n'; + return false; + } +#endif + +} // namespace + +TEST(LinuxDiscoveryTest, SdlOrHidapiCanDiscoverUhidGamepadWhenAvailable) { +#if defined(__linux__) + const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS"); + if (enabled == nullptr || std::string_view {enabled} != "1") { + GTEST_SKIP() << "set LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS=1 to validate SDL/HIDAPI discovery"; + } + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + if (!runtime->capabilities().supports_gamepad) { + GTEST_SKIP() << "/dev/uhid is not accessible"; + } + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::generic_gamepad(); + options.metadata.stable_id = "libvirtualhid-discovery-test"; + + auto created = runtime->create_gamepad(options); + ASSERT_TRUE(created) << created.status.message(); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.left_stick = {0.25F, -0.25F}; + ASSERT_TRUE(created.gamepad->submit(state).ok()); + + std::size_t available_probe_count = 0; + const auto probes = discovery_probes(); + for (std::size_t index = 0; index < probes.size(); ++index) { + const auto &probe = probes[index]; + if (!command_available(probe.executable)) { + std::cout << probe.executable << " is not installed; skipping " << probe.name << '\n'; + continue; + } + + ++available_probe_count; + EXPECT_TRUE(wait_for_probe(probe, options.profile, index)); + } + + if (available_probe_count == 0) { + GTEST_SKIP() << "install sdl2-jstest or hidapitester to validate external discovery"; + } +#else + GTEST_SKIP() << "SDL/HIDAPI discovery validation requires Linux UHID"; +#endif +} From 296c9e7aa2c61c43e8581c8e00b2acd3c631dab8 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 19:52:38 -0400 Subject: [PATCH 21/28] Add Linux UHID backend test hooks Expose test-only hooks for the Linux UHID/uinput backend and add fake-syscall support for unit tests. Introduces LIBVIRTUALHID_ENABLE_TEST_HOOKS when BUILD_TESTS is enabled in CMake, adds system_* wrappers (open/close/read/write/ioctl/poll/access) and ScopedLinuxTestSyscalls to allow scripted syscall overrides. Adds extensive test helpers and fake backend behaviors in uhid_backend.cpp and a new header uhid_backend_test_hooks.hpp with public test APIs, plus related test CMake and unit test additions, enabling backend testing without real device nodes. --- src/CMakeLists.txt | 6 + src/platform/linux/uhid_backend.cpp | 1049 ++++++++++++++++- .../linux/uhid_backend_test_hooks.hpp | 555 +++++++++ tests/CMakeLists.txt | 4 +- tests/unit/test_linux_backend.cpp | 489 ++++++++ 5 files changed, 2071 insertions(+), 32 deletions(-) create mode 100644 src/platform/linux/uhid_backend_test_hooks.hpp create mode 100644 tests/unit/test_linux_backend.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 70fd7c0..eb4ca78 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,6 +18,12 @@ if(LIBVIRTUALHID_USES_THREADS) target_sources(${PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/platform/linux/uhid_backend.cpp") + if(BUILD_TESTS) + # Expose private Linux backend helpers only to tests so coverage does not depend on real device nodes. + target_compile_definitions(${PROJECT_NAME} + PRIVATE + LIBVIRTUALHID_ENABLE_TEST_HOOKS=1) + endif() target_link_libraries(${PROJECT_NAME} PUBLIC Threads::Threads) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index e94a6ec..d99d395 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +31,7 @@ #include #include #include +#include #include #if defined(LIBVIRTUALHID_HAVE_XTEST) @@ -42,8 +44,13 @@ // local includes #include "core/backend.hpp" +#include #include +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + #include "platform/linux/uhid_backend_test_hooks.hpp" +#endif + namespace lvh::detail { namespace { @@ -52,6 +59,177 @@ namespace lvh::detail { constexpr auto absolute_axis_max = 65535; constexpr auto poll_timeout_ms = 100; +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + struct LinuxTestSyscalls { + bool override_access = false; + int access_result = 0; + bool override_open = false; + int open_result = 100000; + bool override_write = false; + std::atomic_int write_call_count = 0; + int fail_write_call = -1; + int short_write_call = -1; + std::size_t short_write_size = 1; + bool override_ioctl = false; + std::atomic_int ioctl_call_count = 0; + int fail_ioctl_call = -1; + bool override_close = false; + int close_result = 0; + bool override_poll = false; + std::atomic_int poll_call_count = 0; + std::vector poll_results; + std::vector poll_revents; + std::vector poll_errors; + bool override_read = false; + std::atomic_int read_call_count = 0; + std::vector read_results; + std::vector read_errors; + uhid_event read_event {}; + bool fake_xtest_keyboard = false; + bool fake_xtest_mouse = false; + }; + + LinuxTestSyscalls *active_test_syscalls = nullptr; + + class ScopedLinuxTestSyscalls { + public: + explicit ScopedLinuxTestSyscalls(LinuxTestSyscalls &syscalls): + previous_ {active_test_syscalls} { + active_test_syscalls = &syscalls; + } + + ScopedLinuxTestSyscalls(const ScopedLinuxTestSyscalls &) = delete; + ScopedLinuxTestSyscalls &operator=(const ScopedLinuxTestSyscalls &) = delete; + ScopedLinuxTestSyscalls(ScopedLinuxTestSyscalls &&) noexcept = delete; + ScopedLinuxTestSyscalls &operator=(ScopedLinuxTestSyscalls &&) noexcept = delete; + + ~ScopedLinuxTestSyscalls() { + active_test_syscalls = previous_; + } + + private: + LinuxTestSyscalls *previous_ = nullptr; + }; +#endif + + int system_access(const char *path, int mode) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_access) { + if (active_test_syscalls->access_result < 0) { + errno = EACCES; + } + return active_test_syscalls->access_result; + } +#endif + return ::access(path, mode); + } + + int system_open(const char *path, int flags) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_open) { + if (active_test_syscalls->open_result < 0) { + errno = ENOENT; + } + return active_test_syscalls->open_result; + } +#endif + return ::open(path, flags); + } + + int system_close(int fd) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_close) { + if (active_test_syscalls->close_result < 0) { + errno = EIO; + } + return active_test_syscalls->close_result; + } +#endif + return ::close(fd); + } + + std::ptrdiff_t system_write(int fd, const void *buffer, std::size_t size) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_write) { + const auto call_count = ++active_test_syscalls->write_call_count; + if (active_test_syscalls->fail_write_call == call_count) { + errno = EIO; + return -1; + } + if (active_test_syscalls->short_write_call == call_count) { + return static_cast(active_test_syscalls->short_write_size); + } + return static_cast(size); + } +#endif + return static_cast(::write(fd, buffer, size)); + } + + int system_ioctl(int fd, unsigned long request, unsigned long argument = 0) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_ioctl) { + const auto call_count = ++active_test_syscalls->ioctl_call_count; + if (active_test_syscalls->fail_ioctl_call == call_count) { + errno = EINVAL; + return -1; + } + return 0; + } +#endif + return ::ioctl(fd, request, argument); + } + + int system_poll(pollfd *descriptors, nfds_t descriptor_count, int timeout) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_poll) { + const auto call_index = static_cast(active_test_syscalls->poll_call_count++); + auto result = 0; + if (call_index < active_test_syscalls->poll_results.size()) { + result = active_test_syscalls->poll_results[call_index]; + } + + if (descriptor_count > 0) { + descriptors[0].revents = 0; + if (result > 0 && call_index < active_test_syscalls->poll_revents.size()) { + descriptors[0].revents = active_test_syscalls->poll_revents[call_index]; + } + } + + if (result < 0) { + errno = call_index < active_test_syscalls->poll_errors.size() ? active_test_syscalls->poll_errors[call_index] : EIO; + } + return result; + } +#endif + return ::poll(descriptors, descriptor_count, timeout); + } + + std::ptrdiff_t system_read(int fd, void *buffer, std::size_t size) { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->override_read) { + const auto call_index = static_cast(active_test_syscalls->read_call_count++); + auto result = std::ptrdiff_t {0}; + if (call_index < active_test_syscalls->read_results.size()) { + result = active_test_syscalls->read_results[call_index]; + } + + if (result < 0) { + errno = call_index < active_test_syscalls->read_errors.size() ? active_test_syscalls->read_errors[call_index] : EIO; + return result; + } + + if (result > 0) { + const auto bytes = std::min(static_cast(result), std::min(size, sizeof(uhid_event))); + std::memcpy(buffer, &active_test_syscalls->read_event, bytes); + return static_cast(bytes); + } + + return result; + } +#endif + return static_cast(::read(fd, buffer, size)); + } + std::string errno_message(int error) { return std::error_code(error, std::generic_category()).message(); } @@ -61,11 +239,11 @@ namespace lvh::detail { } bool can_access_uhid() { - return ::access(uhid_path, R_OK | W_OK) == 0; + return system_access(uhid_path, R_OK | W_OK) == 0; } bool can_access_uinput() { - return ::access(uinput_path, R_OK | W_OK) == 0; + return system_access(uinput_path, R_OK | W_OK) == 0; } std::uint16_t to_uhid_bus(BusType bus_type) { @@ -185,13 +363,65 @@ namespace lvh::detail { } if (key_code >= 0x30 && key_code <= 0x39) { - return KEY_0 + static_cast(key_code - 0x30); + static constexpr int digit_keys[] { + KEY_0, + KEY_1, + KEY_2, + KEY_3, + KEY_4, + KEY_5, + KEY_6, + KEY_7, + KEY_8, + KEY_9, + }; + return digit_keys[key_code - 0x30]; } if (key_code >= 0x41 && key_code <= 0x5A) { - return KEY_A + static_cast(key_code - 0x41); + static constexpr int letter_keys[] { + KEY_A, + KEY_B, + KEY_C, + KEY_D, + KEY_E, + KEY_F, + KEY_G, + KEY_H, + KEY_I, + KEY_J, + KEY_K, + KEY_L, + KEY_M, + KEY_N, + KEY_O, + KEY_P, + KEY_Q, + KEY_R, + KEY_S, + KEY_T, + KEY_U, + KEY_V, + KEY_W, + KEY_X, + KEY_Y, + KEY_Z, + }; + return letter_keys[key_code - 0x41]; } if (key_code >= 0x60 && key_code <= 0x69) { - return KEY_KP0 + static_cast(key_code - 0x60); + static constexpr int keypad_digit_keys[] { + KEY_KP0, + KEY_KP1, + KEY_KP2, + KEY_KP3, + KEY_KP4, + KEY_KP5, + KEY_KP6, + KEY_KP7, + KEY_KP8, + KEY_KP9, + }; + return keypad_digit_keys[key_code - 0x60]; } if (key_code == 0x6A) { return KEY_KPASTERISK; @@ -209,7 +439,33 @@ namespace lvh::detail { return KEY_KPSLASH; } if (key_code >= 0x70 && key_code <= 0x87) { - return KEY_F1 + static_cast(key_code - 0x70); + static constexpr int function_keys[] { + KEY_F1, + KEY_F2, + KEY_F3, + KEY_F4, + KEY_F5, + KEY_F6, + KEY_F7, + KEY_F8, + KEY_F9, + KEY_F10, + KEY_F11, + KEY_F12, + KEY_F13, + KEY_F14, + KEY_F15, + KEY_F16, + KEY_F17, + KEY_F18, + KEY_F19, + KEY_F20, + KEY_F21, + KEY_F22, + KEY_F23, + KEY_F24, + }; + return function_keys[key_code - 0x70]; } return -1; @@ -350,10 +606,10 @@ namespace lvh::detail { auto status = Status::success(); if (fd_ >= 0) { - if (::ioctl(fd_, UI_DEV_DESTROY) < 0) { + if (system_ioctl(fd_, UI_DEV_DESTROY) < 0) { status = ioctl_status("failed to destroy " + description); } - if (::close(fd_) != 0 && status.ok()) { + if (system_close(fd_) != 0 && status.ok()) { status = system_error_status(ErrorCode::backend_failure, "failed to close /dev/uinput", errno); } fd_ = -1; @@ -381,7 +637,7 @@ namespace lvh::detail { event.code = code; event.value = value; - const auto result = ::write(fd_, &event, sizeof(event)); + const auto result = system_write(fd_, &event, sizeof(event)); if (result < 0) { return system_error_status(ErrorCode::backend_failure, "failed to write uinput event", errno); } @@ -413,7 +669,7 @@ namespace lvh::detail { device.absflat[ABS_X] = 0; device.absflat[ABS_Y] = 0; - const auto result = ::write(fd, &device, sizeof(device)); + const auto result = system_write(fd, &device, sizeof(device)); if (result < 0) { return system_error_status(ErrorCode::backend_failure, "failed to write uinput device definition", errno); } @@ -421,7 +677,7 @@ namespace lvh::detail { return Status::failure(ErrorCode::backend_failure, "short write while creating uinput device"); } - if (::ioctl(fd, UI_DEV_CREATE) < 0) { + if (system_ioctl(fd, UI_DEV_CREATE) < 0) { return ioctl_status("failed to create uinput device " + std::to_string(id)); } @@ -429,12 +685,12 @@ namespace lvh::detail { } Status enable_uinput_keyboard(int fd) { - if (::ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { + if (system_ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { return ioctl_status("failed to enable uinput keyboard key events"); } for (auto code = 1; code < KEY_MAX; ++code) { - if (::ioctl(fd, UI_SET_KEYBIT, code) < 0) { + if (system_ioctl(fd, UI_SET_KEYBIT, code) < 0) { return ioctl_status("failed to enable uinput keyboard key " + std::to_string(code)); } } @@ -443,52 +699,52 @@ namespace lvh::detail { } Status enable_uinput_mouse(int fd) { - if (::ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { + if (system_ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { return ioctl_status("failed to enable uinput mouse button events"); } - if (::ioctl(fd, UI_SET_EVBIT, EV_REL) < 0) { + if (system_ioctl(fd, UI_SET_EVBIT, EV_REL) < 0) { return ioctl_status("failed to enable uinput relative mouse events"); } - if (::ioctl(fd, UI_SET_EVBIT, EV_ABS) < 0) { + if (system_ioctl(fd, UI_SET_EVBIT, EV_ABS) < 0) { return ioctl_status("failed to enable uinput absolute mouse events"); } for (const auto button : {BTN_LEFT, BTN_RIGHT, BTN_MIDDLE, BTN_SIDE, BTN_EXTRA}) { - if (::ioctl(fd, UI_SET_KEYBIT, button) < 0) { + if (system_ioctl(fd, UI_SET_KEYBIT, button) < 0) { return ioctl_status("failed to enable uinput mouse button " + std::to_string(button)); } } for (const auto code : {REL_X, REL_Y}) { - if (::ioctl(fd, UI_SET_RELBIT, code) < 0) { + if (system_ioctl(fd, UI_SET_RELBIT, code) < 0) { return ioctl_status("failed to enable uinput relative axis " + std::to_string(code)); } } #if defined(REL_WHEEL_HI_RES) - if (::ioctl(fd, UI_SET_RELBIT, REL_WHEEL_HI_RES) < 0) { + if (system_ioctl(fd, UI_SET_RELBIT, REL_WHEEL_HI_RES) < 0) { return ioctl_status("failed to enable uinput high-resolution vertical scroll"); } #else - if (::ioctl(fd, UI_SET_RELBIT, REL_WHEEL) < 0) { + if (system_ioctl(fd, UI_SET_RELBIT, REL_WHEEL) < 0) { return ioctl_status("failed to enable uinput vertical scroll"); } #endif #if defined(REL_HWHEEL_HI_RES) - if (::ioctl(fd, UI_SET_RELBIT, REL_HWHEEL_HI_RES) < 0) { + if (system_ioctl(fd, UI_SET_RELBIT, REL_HWHEEL_HI_RES) < 0) { return ioctl_status("failed to enable uinput high-resolution horizontal scroll"); } #else - if (::ioctl(fd, UI_SET_RELBIT, REL_HWHEEL) < 0) { + if (system_ioctl(fd, UI_SET_RELBIT, REL_HWHEEL) < 0) { return ioctl_status("failed to enable uinput horizontal scroll"); } #endif - if (::ioctl(fd, UI_SET_ABSBIT, ABS_X) < 0) { + if (system_ioctl(fd, UI_SET_ABSBIT, ABS_X) < 0) { return ioctl_status("failed to enable uinput absolute X axis"); } - if (::ioctl(fd, UI_SET_ABSBIT, ABS_Y) < 0) { + if (system_ioctl(fd, UI_SET_ABSBIT, ABS_Y) < 0) { return ioctl_status("failed to enable uinput absolute Y axis"); } @@ -1105,7 +1361,7 @@ namespace lvh::detail { } if (fd_ >= 0) { - if (::close(fd_) != 0 && status.ok()) { + if (system_close(fd_) != 0 && status.ok()) { status = system_error_status(ErrorCode::backend_failure, "failed to close /dev/uhid", errno); } fd_ = -1; @@ -1121,7 +1377,7 @@ namespace lvh::detail { return Status::failure(ErrorCode::device_closed, "UHID file descriptor is closed"); } - const auto result = ::write(fd_, &event, sizeof(event)); + const auto result = system_write(fd_, &event, sizeof(event)); if (result < 0) { return system_error_status(ErrorCode::backend_failure, "failed to write UHID event", errno); } @@ -1138,7 +1394,7 @@ namespace lvh::detail { descriptor.fd = fd_; descriptor.events = POLLIN; - const auto result = ::poll(&descriptor, 1, poll_timeout_ms); + const auto result = system_poll(&descriptor, 1, poll_timeout_ms); if (result < 0) { if (errno == EINTR) { continue; @@ -1156,7 +1412,7 @@ namespace lvh::detail { } uhid_event event {}; - const auto read_result = ::read(fd_, &event, sizeof(event)); + const auto read_result = system_read(fd_, &event, sizeof(event)); if (read_result < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) { continue; @@ -1254,7 +1510,7 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { - const auto fd = ::open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; } @@ -1269,7 +1525,7 @@ namespace lvh::detail { } BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) override { - const auto fd = ::open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return create_xtest_keyboard(); } @@ -1288,7 +1544,7 @@ namespace lvh::detail { } BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { - const auto fd = ::open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return create_xtest_mouse(); } @@ -1308,6 +1564,11 @@ namespace lvh::detail { private: BackendKeyboardCreationResult create_xtest_keyboard() { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->fake_xtest_keyboard) { + return {Status::success(), std::make_unique(active_test_syscalls->open_result)}; + } +#endif #if defined(LIBVIRTUALHID_HAVE_XTEST) auto keyboard = std::make_unique(); if (const auto status = keyboard->create(); !status.ok()) { @@ -1320,6 +1581,11 @@ namespace lvh::detail { } BackendMouseCreationResult create_xtest_mouse() { +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + if (active_test_syscalls != nullptr && active_test_syscalls->fake_xtest_mouse) { + return {Status::success(), std::make_unique(active_test_syscalls->open_result)}; + } +#endif #if defined(LIBVIRTUALHID_HAVE_XTEST) auto mouse = std::make_unique(); if (const auto status = mouse->create(); !status.ok()) { @@ -1336,6 +1602,727 @@ namespace lvh::detail { } // namespace +#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) + namespace test { + namespace { + constexpr auto fake_fd = 100000; + + std::vector read_input_events_until_eof(int fd) { + std::vector records; + input_event event {}; + while (::read(fd, &event, sizeof(event)) == sizeof(event)) { + records.push_back({ + .type = event.type, + .code = event.code, + .value = event.value, + }); + } + return records; + } + + bool write_uhid_event(int fd, const uhid_event &event) { + auto *data = reinterpret_cast(&event); + std::size_t written = 0; + while (written < sizeof(event)) { + const auto result = ::write(fd, data + written, sizeof(event) - written); + if (result <= 0) { + return false; + } + written += static_cast(result); + } + return true; + } + + bool read_uhid_event(int fd, uhid_event &event) { + pollfd descriptor {}; + descriptor.fd = fd; + descriptor.events = POLLIN; + const auto poll_result = ::poll(&descriptor, 1, 1000); + if (poll_result <= 0 || (descriptor.revents & POLLIN) == 0) { + return false; + } + + auto *data = reinterpret_cast(&event); + std::size_t read_size = 0; + while (read_size < sizeof(event)) { + const auto result = ::read(fd, data + read_size, sizeof(event) - read_size); + if (result <= 0) { + return false; + } + read_size += static_cast(result); + } + return true; + } + + void enable_fake_device_syscalls(LinuxTestSyscalls &syscalls) { + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.override_close = true; + } + + bool wait_for_poll_calls(const LinuxTestSyscalls &syscalls, int expected_calls) { + using namespace std::chrono_literals; + + for (auto attempt = 0; attempt < 100; ++attempt) { + if (syscalls.poll_call_count.load() >= expected_calls) { + return true; + } + std::this_thread::sleep_for(1ms); + } + return false; + } + + Status run_fake_uhid_read_loop(LinuxTestSyscalls &syscalls, int expected_poll_calls) { + syscalls.override_write = true; + syscalls.override_close = true; + + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + CreateGamepadOptions options; + options.profile = profiles::generic_gamepad(); + + UhidGamepad gamepad {fake_fd}; + if (const auto status = gamepad.create(1, options); !status.ok()) { + return status; + } + + const auto saw_expected_polls = wait_for_poll_calls(syscalls, expected_poll_calls); + const auto close_status = gamepad.close(); + if (!saw_expected_polls) { + return Status::failure(ErrorCode::backend_failure, "fake UHID read loop did not consume the scripted poll calls"); + } + return close_status; + } + + } // namespace + + std::string linux_copy_string_char_buffer(const std::string &source) { + char destination[5] {}; + copy_string(destination, source); + return destination; + } + + int linux_key_code(KeyboardKeyCode key_code) { + return key_code_to_linux(key_code); + } + + int linux_mouse_button(MouseButton button) { + return mouse_button_to_linux(button); + } + + std::uint16_t linux_uhid_bus(BusType bus_type) { + return to_uhid_bus(bus_type); + } + + std::uint16_t linux_uinput_bus(BusType bus_type) { + return to_uinput_bus(bus_type); + } + + int linux_absolute_axis(std::int32_t value, std::int32_t limit) { + return scale_absolute_axis(value, limit); + } + + std::vector linux_decode_utf8(const std::string &text) { + return decode_utf8(text); + } + + std::string linux_uppercase_hex(std::uint32_t codepoint) { + return uppercase_hex(codepoint); + } + + KeyboardKeyCode linux_hex_digit_key_code(char digit) { + return hex_digit_key_code(digit); + } + + int linux_legacy_scroll_steps(std::int32_t distance) { + return legacy_scroll_steps(distance); + } + + std::size_t linux_uhid_descriptor_limit() { + uhid_event event {}; + return sizeof(event.u.create2.rd_data); + } + + std::size_t linux_uhid_input_limit() { + uhid_event event {}; + return sizeof(event.u.input2.data); + } + + Status linux_uhid_create_with_descriptor_size(std::size_t descriptor_size) { + auto profile = profiles::generic_gamepad(); + profile.report_descriptor.assign(descriptor_size, 0); + + CreateGamepadOptions options; + options.profile = std::move(profile); + + UhidGamepad gamepad {-1}; + return gamepad.create(1, options); + } + + Status linux_uhid_submit_report_size(std::size_t report_size) { + UhidGamepad gamepad {-1}; + return gamepad.submit(std::vector(report_size, 0)); + } + + Status linux_uhid_submit_after_close() { + UhidGamepad gamepad {-1}; + static_cast(gamepad.close()); + return gamepad.submit({0}); + } + + Status linux_uinput_keyboard_create_invalid_fd() { + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + + UinputKeyboard keyboard {-1}; + return keyboard.create(1, options); + } + + Status linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event) { + UinputKeyboard keyboard {-1}; + return keyboard.submit(event); + } + + Status linux_uinput_keyboard_type_text_invalid_fd(const std::string &text) { + UinputKeyboard keyboard {-1}; + return keyboard.type_text({.text = text}); + } + + Status linux_uinput_keyboard_submit_after_close() { + UinputKeyboard keyboard {-1}; + static_cast(keyboard.close()); + return keyboard.submit({.key_code = 0x41, .pressed = true}); + } + + LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputKeyboard keyboard {descriptors[1]}; + auto status = keyboard.submit(event); + static_cast(keyboard.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + Status linux_uinput_user_device_invalid_fd() { + return write_uinput_user_device(-1, profiles::mouse(), 1); + } + + Status linux_uinput_user_device_pipe() { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno); + } + + auto status = write_uinput_user_device(descriptors[1], profiles::mouse(), 1); + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return status; + } + + Status linux_uinput_mouse_create_invalid_fd() { + CreateMouseOptions options; + options.profile = profiles::mouse(); + + UinputMouse mouse {-1}; + return mouse.create(1, options); + } + + Status linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event) { + UinputMouse mouse {-1}; + return mouse.submit(event); + } + + Status linux_uinput_mouse_submit_after_close() { + UinputMouse mouse {-1}; + static_cast(mouse.close()); + return mouse.submit({.kind = MouseEventKind::relative_motion, .x = 1, .y = 1}); + } + + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputMouse mouse {descriptors[1]}; + auto status = mouse.submit(event); + static_cast(mouse.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip() { + LinuxUhidRoundTripResult result; + int descriptors[2] {-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors) != 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno); + result.submit_status = result.create_status; + result.close_status = result.create_status; + return result; + } + + auto profile = profiles::xbox_360(); + CreateGamepadOptions options; + options.profile = profile; + options.metadata.stable_id = "linux-uhid-roundtrip"; + + UhidGamepad gamepad {descriptors[0]}; + result.create_status = gamepad.create(7, options); + + uhid_event event {}; + if (read_uhid_event(descriptors[1], event)) { + result.saw_create = event.type == UHID_CREATE2 && event.u.create2.vendor == profile.vendor_id && + event.u.create2.product == profile.product_id; + } + + gamepad.set_output_callback([&result](const GamepadOutput &output) { + ++result.output_callback_count; + result.last_output = output; + }); + + event = {}; + event.type = UHID_OUTPUT; + event.u.output.size = static_cast<__u16>(profile.output_report_size); + event.u.output.data[0] = profile.report_id; + event.u.output.data[1] = 0x34; + event.u.output.data[2] = 0x12; + event.u.output.data[3] = 0x78; + event.u.output.data[4] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 9; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event(descriptors[1], event)) { + result.saw_get_report_reply = event.type == UHID_GET_REPORT_REPLY && event.u.get_report_reply.id == 9; + } + + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 10; + event.u.set_report.size = static_cast<__u16>(profile.output_report_size); + event.u.set_report.data[0] = profile.report_id; + event.u.set_report.data[1] = 0x78; + event.u.set_report.data[2] = 0x56; + event.u.set_report.data[3] = 0x34; + event.u.set_report.data[4] = 0x12; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event(descriptors[1], event)) { + result.saw_set_report_reply = event.type == UHID_SET_REPORT_REPLY && event.u.set_report_reply.id == 10; + } + + event = {}; + event.type = UHID_OPEN; + static_cast(write_uhid_event(descriptors[1], event)); + + lvh::GamepadState state; + state.buttons.set(GamepadButton::a); + const auto report = reports::pack_input_report(profile, state); + result.submit_status = gamepad.submit(report); + if (read_uhid_event(descriptors[1], event)) { + result.saw_input = event.type == UHID_INPUT2 && event.u.input2.size == report.size(); + } + + result.close_status = gamepad.close(); + if (read_uhid_event(descriptors[1], event)) { + result.saw_destroy = event.type == UHID_DESTROY; + } + + static_cast(::close(descriptors[1])); + return result; + } + + LinuxBackendFakeCreationResult linux_backend_create_all_fake_success() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxBackendFakeCreationResult result; + LinuxUhidBackend backend; + result.capabilities = backend.capabilities(); + + CreateGamepadOptions gamepad_options; + gamepad_options.profile = profiles::xbox_360(); + gamepad_options.metadata.stable_id = "fake-linux-gamepad"; + auto gamepad = backend.create_gamepad(1, gamepad_options); + result.gamepad_status = gamepad.status; + if (gamepad) { + result.gamepad_close_status = gamepad.gamepad->close(); + } + + CreateKeyboardOptions keyboard_options; + keyboard_options.profile = profiles::keyboard(); + keyboard_options.stable_id = "fake-linux-keyboard"; + auto keyboard = backend.create_keyboard(2, keyboard_options); + result.keyboard_status = keyboard.status; + if (keyboard) { + result.keyboard_close_status = keyboard.keyboard->close(); + } + + CreateMouseOptions mouse_options; + mouse_options.profile = profiles::mouse(); + mouse_options.stable_id = "fake-linux-mouse"; + auto mouse = backend.create_mouse(3, mouse_options); + result.mouse_status = mouse.status; + if (mouse) { + result.mouse_close_status = mouse.mouse->close(); + } + + return result; + } + + BackendCapabilities linux_backend_fake_unavailable_capabilities() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.access_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + return backend.capabilities(); + } + + Status linux_backend_gamepad_fake_open_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateGamepadOptions options; + options.profile = profiles::xbox_360(); + return backend.create_gamepad(1, options).status; + } + + Status linux_backend_gamepad_fake_create_failure() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateGamepadOptions options; + options.profile = profiles::xbox_360(); + return backend.create_gamepad(1, options).status; + } + + Status linux_backend_keyboard_fake_open_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + return backend.create_keyboard(1, options).status; + } + + Status linux_backend_keyboard_fake_create_failure() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + return backend.create_keyboard(1, options).status; + } + + Status linux_backend_keyboard_fake_fallback_success() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + syscalls.fake_xtest_keyboard = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + auto keyboard = backend.create_keyboard(1, options); + if (!keyboard) { + return keyboard.status; + } + return keyboard.keyboard->close(); + } + + Status linux_backend_mouse_fake_open_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + return backend.create_mouse(1, options).status; + } + + Status linux_backend_mouse_fake_create_failure() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + return backend.create_mouse(1, options).status; + } + + Status linux_backend_mouse_fake_fallback_success() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + syscalls.fake_xtest_mouse = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + auto mouse = backend.create_mouse(1, options); + if (!mouse) { + return mouse.status; + } + return mouse.mouse->close(); + } + + Status linux_uhid_submit_fake_write_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.submit({0}); + } + + Status linux_uhid_submit_fake_short_write() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.submit({0}); + } + + Status linux_uhid_close_fake_write_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.close(); + } + + Status linux_uhid_close_fake_close_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_close = true; + syscalls.close_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.close(); + } + + Status linux_uhid_read_loop_fake_retry_branches() { + LinuxTestSyscalls syscalls; + syscalls.override_poll = true; + syscalls.poll_results = {-1, 0, 1, 1, 1}; + syscalls.poll_revents = {0, 0, 0, POLLIN, POLLIN}; + syscalls.poll_errors = {EINTR}; + syscalls.override_read = true; + syscalls.read_results = {-1, 0}; + syscalls.read_errors = {EAGAIN}; + return run_fake_uhid_read_loop(syscalls, 5); + } + + Status linux_uhid_read_loop_fake_poll_errors() { + LinuxTestSyscalls syscall_failure; + syscall_failure.override_poll = true; + syscall_failure.poll_results = {-1}; + syscall_failure.poll_errors = {EIO}; + if (const auto status = run_fake_uhid_read_loop(syscall_failure, 1); !status.ok()) { + return status; + } + + LinuxTestSyscalls event_failure; + event_failure.override_poll = true; + event_failure.poll_results = {1}; + event_failure.poll_revents = {POLLERR}; + return run_fake_uhid_read_loop(event_failure, 1); + } + + Status linux_uhid_read_loop_fake_read_error() { + LinuxTestSyscalls syscalls; + syscalls.override_poll = true; + syscalls.poll_results = {1}; + syscalls.poll_revents = {POLLIN}; + syscalls.override_read = true; + syscalls.read_results = {-1}; + syscalls.read_errors = {EIO}; + return run_fake_uhid_read_loop(syscalls, 1); + } + + Status linux_uhid_read_loop_fake_output_without_callback() { + LinuxTestSyscalls syscalls; + syscalls.override_poll = true; + syscalls.poll_results = {1, 1}; + syscalls.poll_revents = {POLLIN, POLLIN}; + syscalls.override_read = true; + syscalls.read_results = {static_cast(sizeof(uhid_event)), 0}; + syscalls.read_event.type = UHID_OUTPUT; + return run_fake_uhid_read_loop(syscalls, 2); + } + + Status linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.fail_ioctl_call = fail_ioctl_call; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + + UinputKeyboard keyboard {fake_fd}; + return keyboard.create(1, options); + } + + Status linux_uinput_user_device_fake_short_write() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + return write_uinput_user_device(fake_fd, profiles::mouse(), 1); + } + + Status linux_uinput_user_device_fake_create_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + return write_uinput_user_device(fake_fd, profiles::mouse(), 1); + } + + Status linux_uinput_keyboard_submit_fake_write_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + syscalls.override_ioctl = true; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.submit({.key_code = 0x41, .pressed = true}); + } + + Status linux_uinput_keyboard_submit_fake_short_write() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + syscalls.override_ioctl = true; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.submit({.key_code = 0x41, .pressed = true}); + } + + Status linux_uinput_keyboard_type_text_fake_success() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.type_text({.text = "A"}); + } + + Status linux_uinput_keyboard_close_fake_close_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_ioctl = true; + syscalls.override_close = true; + syscalls.close_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.close(); + } + + Status linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.fail_ioctl_call = fail_ioctl_call; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + + UinputMouse mouse {fake_fd}; + return mouse.create(1, options); + } + + Status linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + syscalls.override_ioctl = true; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputMouse mouse {fake_fd}; + return mouse.submit(event); + } + + Status linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + syscalls.override_ioctl = true; + syscalls.override_close = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputMouse mouse {fake_fd}; + return mouse.submit(event); + } + + } // namespace test +#endif + std::unique_ptr create_platform_backend() { return std::make_unique(); } diff --git a/src/platform/linux/uhid_backend_test_hooks.hpp b/src/platform/linux/uhid_backend_test_hooks.hpp new file mode 100644 index 0000000..2316c0b --- /dev/null +++ b/src/platform/linux/uhid_backend_test_hooks.hpp @@ -0,0 +1,555 @@ +/** + * @file src/platform/linux/uhid_backend_test_hooks.hpp + * @brief Test-only hooks for Linux UHID backend internals. + */ +#pragma once + +// standard includes +#include +#include +#include +#include + +// local includes +#include + +namespace lvh::detail::test { + + /** + * @brief Linux input event captured by a pipe-backed backend test. + */ + struct LinuxInputEventRecord { + /** + * @brief Event type. + */ + std::uint16_t type = 0; + + /** + * @brief Event code. + */ + std::uint16_t code = 0; + + /** + * @brief Event value. + */ + std::int32_t value = 0; + }; + + /** + * @brief Result from a pipe-backed uinput submission. + */ + struct LinuxInputSubmissionResult { + /** + * @brief Submit operation status. + */ + Status status; + + /** + * @brief Events written to the pipe. + */ + std::vector events; + }; + + /** + * @brief Result from a socketpair-backed UHID lifecycle test. + */ + struct LinuxUhidRoundTripResult { + /** + * @brief Create operation status. + */ + Status create_status; + + /** + * @brief Submit operation status. + */ + Status submit_status; + + /** + * @brief Close operation status. + */ + Status close_status; + + /** + * @brief Whether the peer observed a create event. + */ + bool saw_create = false; + + /** + * @brief Whether the peer observed an input report event. + */ + bool saw_input = false; + + /** + * @brief Whether the peer observed a get-report reply. + */ + bool saw_get_report_reply = false; + + /** + * @brief Whether the peer observed a set-report reply. + */ + bool saw_set_report_reply = false; + + /** + * @brief Whether the peer observed a destroy event. + */ + bool saw_destroy = false; + + /** + * @brief Number of output callbacks received. + */ + std::size_t output_callback_count = 0; + + /** + * @brief Last output callback payload. + */ + GamepadOutput last_output; + }; + + /** + * @brief Result from creating each Linux backend device through fake syscalls. + */ + struct LinuxBackendFakeCreationResult { + /** + * @brief Backend capabilities reported while fake device nodes are accessible. + */ + BackendCapabilities capabilities; + + /** + * @brief Gamepad creation status. + */ + Status gamepad_status; + + /** + * @brief Gamepad close status. + */ + Status gamepad_close_status; + + /** + * @brief Keyboard creation status. + */ + Status keyboard_status; + + /** + * @brief Keyboard close status. + */ + Status keyboard_close_status; + + /** + * @brief Mouse creation status. + */ + Status mouse_status; + + /** + * @brief Mouse close status. + */ + Status mouse_close_status; + }; + + /** + * @brief Copy into a fixed-size Linux char buffer using the backend string helper. + * + * @param source Source string. + * @return Copied, null-terminated string. + */ + std::string linux_copy_string_char_buffer(const std::string &source); + + /** + * @brief Translate a portable keyboard key code to a Linux input key code. + * + * @param key_code Portable keyboard key code. + * @return Linux key code, or `-1` when unsupported. + */ + int linux_key_code(KeyboardKeyCode key_code); + + /** + * @brief Translate a mouse button to a Linux input button code. + * + * @param button Mouse button. + * @return Linux button code. + */ + int linux_mouse_button(MouseButton button); + + /** + * @brief Translate a bus type to a Linux UHID bus code. + * + * @param bus_type Bus type. + * @return Linux UHID bus code. + */ + std::uint16_t linux_uhid_bus(BusType bus_type); + + /** + * @brief Translate a bus type to a Linux uinput bus code. + * + * @param bus_type Bus type. + * @return Linux uinput bus code. + */ + std::uint16_t linux_uinput_bus(BusType bus_type); + + /** + * @brief Scale an absolute pointer coordinate into the Linux absolute axis range. + * + * @param value Coordinate value. + * @param limit Coordinate space limit. + * @return Linux absolute axis value. + */ + int linux_absolute_axis(std::int32_t value, std::int32_t limit); + + /** + * @brief Decode UTF-8 into Unicode code points using the Linux backend decoder. + * + * @param text UTF-8 text. + * @return Decoded code points. + */ + std::vector linux_decode_utf8(const std::string &text); + + /** + * @brief Format a code point as upper-case hexadecimal. + * + * @param codepoint Unicode code point. + * @return Upper-case hexadecimal representation. + */ + std::string linux_uppercase_hex(std::uint32_t codepoint); + + /** + * @brief Convert a hexadecimal digit into the portable keyboard key code used by text input. + * + * @param digit Upper-case hexadecimal digit. + * @return Portable keyboard key code. + */ + KeyboardKeyCode linux_hex_digit_key_code(char digit); + + /** + * @brief Convert a high-resolution scroll distance into legacy wheel steps. + * + * @param distance High-resolution scroll distance. + * @return Legacy wheel steps. + */ + int linux_legacy_scroll_steps(std::int32_t distance); + + /** + * @brief Get the maximum UHID report descriptor size accepted by the backend. + * + * @return Maximum descriptor size. + */ + std::size_t linux_uhid_descriptor_limit(); + + /** + * @brief Get the maximum UHID input report size accepted by the backend. + * + * @return Maximum input report size. + */ + std::size_t linux_uhid_input_limit(); + + /** + * @brief Try creating a UHID gamepad on an invalid file descriptor. + * + * @param descriptor_size Descriptor size to use. + * @return Creation status. + */ + Status linux_uhid_create_with_descriptor_size(std::size_t descriptor_size); + + /** + * @brief Try submitting a UHID input report on an invalid file descriptor. + * + * @param report_size Input report size to use. + * @return Submit status. + */ + Status linux_uhid_submit_report_size(std::size_t report_size); + + /** + * @brief Try submitting a UHID input report after closing the backend device. + * + * @return Submit status. + */ + Status linux_uhid_submit_after_close(); + + /** + * @brief Try creating a uinput keyboard on an invalid file descriptor. + * + * @return Creation status. + */ + Status linux_uinput_keyboard_create_invalid_fd(); + + /** + * @brief Submit a keyboard event to a uinput keyboard on an invalid file descriptor. + * + * @param event Keyboard event. + * @return Submit status. + */ + Status linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event); + + /** + * @brief Submit text to a uinput keyboard on an invalid file descriptor. + * + * @param text UTF-8 text. + * @return Submit status. + */ + Status linux_uinput_keyboard_type_text_invalid_fd(const std::string &text); + + /** + * @brief Try submitting a keyboard event after closing the backend device. + * + * @return Submit status. + */ + Status linux_uinput_keyboard_submit_after_close(); + + /** + * @brief Submit a keyboard event to a pipe-backed uinput keyboard. + * + * @param event Keyboard event. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event); + + /** + * @brief Try writing a uinput device definition to an invalid file descriptor. + * + * @return Write status. + */ + Status linux_uinput_user_device_invalid_fd(); + + /** + * @brief Try writing a uinput device definition to a pipe. + * + * @return Write status. + */ + Status linux_uinput_user_device_pipe(); + + /** + * @brief Try creating a uinput mouse on an invalid file descriptor. + * + * @return Creation status. + */ + Status linux_uinput_mouse_create_invalid_fd(); + + /** + * @brief Submit a mouse event to a uinput mouse on an invalid file descriptor. + * + * @param event Mouse event. + * @return Submit status. + */ + Status linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event); + + /** + * @brief Try submitting a mouse event after closing the backend device. + * + * @return Submit status. + */ + Status linux_uinput_mouse_submit_after_close(); + + /** + * @brief Submit a mouse event to a pipe-backed uinput mouse. + * + * @param event Mouse event. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event); + + /** + * @brief Exercise a UHID gamepad lifecycle over a socketpair. + * + * @return Round-trip result. + */ + LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip(); + + /** + * @brief Create all Linux backend device types using fake successful syscalls. + * + * @return Creation and close statuses for each backend device. + */ + LinuxBackendFakeCreationResult linux_backend_create_all_fake_success(); + + /** + * @brief Get Linux backend capabilities while fake device-node access fails. + * + * @return Backend capabilities. + */ + BackendCapabilities linux_backend_fake_unavailable_capabilities(); + + /** + * @brief Try creating a Linux backend gamepad while fake open fails. + * + * @return Creation status. + */ + Status linux_backend_gamepad_fake_open_failure(); + + /** + * @brief Try creating a Linux backend gamepad while fake UHID creation fails. + * + * @return Creation status. + */ + Status linux_backend_gamepad_fake_create_failure(); + + /** + * @brief Try creating a Linux backend keyboard while fake open fails. + * + * @return Creation status. + */ + Status linux_backend_keyboard_fake_open_failure(); + + /** + * @brief Try creating a Linux backend keyboard while fake uinput creation fails. + * + * @return Creation status. + */ + Status linux_backend_keyboard_fake_create_failure(); + + /** + * @brief Create a Linux backend keyboard through a fake successful fallback after uinput creation fails. + * + * @return Creation status. + */ + Status linux_backend_keyboard_fake_fallback_success(); + + /** + * @brief Try creating a Linux backend mouse while fake open fails. + * + * @return Creation status. + */ + Status linux_backend_mouse_fake_open_failure(); + + /** + * @brief Try creating a Linux backend mouse while fake uinput creation fails. + * + * @return Creation status. + */ + Status linux_backend_mouse_fake_create_failure(); + + /** + * @brief Create a Linux backend mouse through a fake successful fallback after uinput creation fails. + * + * @return Creation status. + */ + Status linux_backend_mouse_fake_fallback_success(); + + /** + * @brief Try submitting a UHID input report while fake write fails. + * + * @return Submit status. + */ + Status linux_uhid_submit_fake_write_failure(); + + /** + * @brief Try submitting a UHID input report while fake write is short. + * + * @return Submit status. + */ + Status linux_uhid_submit_fake_short_write(); + + /** + * @brief Try closing a UHID gamepad while fake destroy write fails. + * + * @return Close status. + */ + Status linux_uhid_close_fake_write_failure(); + + /** + * @brief Try closing a UHID gamepad while fake close fails. + * + * @return Close status. + */ + Status linux_uhid_close_fake_close_failure(); + + /** + * @brief Exercise UHID read-loop timeout and retry branches using fake poll/read syscalls. + * + * @return Close status after the scripted read loop exits. + */ + Status linux_uhid_read_loop_fake_retry_branches(); + + /** + * @brief Exercise UHID read-loop poll error branches using fake poll syscalls. + * + * @return Close status after the scripted read loop exits. + */ + Status linux_uhid_read_loop_fake_poll_errors(); + + /** + * @brief Exercise UHID read-loop read error branches using fake read syscalls. + * + * @return Close status after the scripted read loop exits. + */ + Status linux_uhid_read_loop_fake_read_error(); + + /** + * @brief Exercise UHID read-loop output handling when no callback is registered. + * + * @return Close status after the scripted read loop exits. + */ + Status linux_uhid_read_loop_fake_output_without_callback(); + + /** + * @brief Try creating a uinput keyboard while a fake ioctl call fails. + * + * @param fail_ioctl_call One-based ioctl call to fail. + * @return Creation status. + */ + Status linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call); + + /** + * @brief Try writing a uinput device definition while fake write is short. + * + * @return Write status. + */ + Status linux_uinput_user_device_fake_short_write(); + + /** + * @brief Try writing a uinput device definition while fake device creation ioctl fails. + * + * @return Write status. + */ + Status linux_uinput_user_device_fake_create_failure(); + + /** + * @brief Submit a keyboard event while fake write fails. + * + * @return Submit status. + */ + Status linux_uinput_keyboard_submit_fake_write_failure(); + + /** + * @brief Submit a keyboard event while fake write is short. + * + * @return Submit status. + */ + Status linux_uinput_keyboard_submit_fake_short_write(); + + /** + * @brief Submit text through a fake successful uinput keyboard. + * + * @return Submit status. + */ + Status linux_uinput_keyboard_type_text_fake_success(); + + /** + * @brief Close a uinput keyboard while fake close fails. + * + * @return Close status. + */ + Status linux_uinput_keyboard_close_fake_close_failure(); + + /** + * @brief Try creating a uinput mouse while a fake ioctl call fails. + * + * @param fail_ioctl_call One-based ioctl call to fail. + * @return Creation status. + */ + Status linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call); + + /** + * @brief Submit a mouse event while fake write fails. + * + * @param event Mouse event to submit. + * @return Submit status. + */ + Status linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event); + + /** + * @brief Submit a mouse event while fake write is short. + * + * @param event Mouse event to submit. + * @return Submit status. + */ + Status linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event); + +} // namespace lvh::detail::test diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index f6b7971..a06d4c0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -17,6 +17,7 @@ set(TEST_BINARY test_libvirtualhid) add_executable(${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/fixtures.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_discovery.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_profiles.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" @@ -24,7 +25,8 @@ add_executable(${TEST_BINARY} target_include_directories(${TEST_BINARY} PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/include") + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/include" + "${PROJECT_SOURCE_DIR}/src") target_link_libraries(${TEST_BINARY} PRIVATE diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp new file mode 100644 index 0000000..4a56151 --- /dev/null +++ b/tests/unit/test_linux_backend.cpp @@ -0,0 +1,489 @@ +/** + * @file tests/unit/test_linux_backend.cpp + * @brief Unit tests for Linux backend internals. + */ + +// standard includes +#include +#include +#include + +// platform includes +#if defined(__linux__) + #include +#endif + +// local includes +#include "fixtures/fixtures.hpp" + +#include + +#if defined(__linux__) + #include "platform/linux/uhid_backend_test_hooks.hpp" +#endif + +TEST(LinuxBackendTest, TranslatesKeyboardKeys) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_key_code(0x08), KEY_BACKSPACE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x09), KEY_TAB); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x0D), KEY_ENTER); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x10), KEY_LEFTSHIFT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x11), KEY_LEFTCTRL); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x12), KEY_LEFTALT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x14), KEY_CAPSLOCK); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x1B), KEY_ESC); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x20), KEY_SPACE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x21), KEY_PAGEUP); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x22), KEY_PAGEDOWN); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x23), KEY_END); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x24), KEY_HOME); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x25), KEY_LEFT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x26), KEY_UP); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x27), KEY_RIGHT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x28), KEY_DOWN); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x2C), KEY_SYSRQ); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x2D), KEY_INSERT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x2E), KEY_DELETE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x5B), KEY_LEFTMETA); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x5C), KEY_RIGHTMETA); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x90), KEY_NUMLOCK); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x91), KEY_SCROLLLOCK); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xA0), KEY_LEFTSHIFT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xA1), KEY_RIGHTSHIFT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xA2), KEY_LEFTCTRL); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xA3), KEY_RIGHTCTRL); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xA4), KEY_LEFTALT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xA5), KEY_RIGHTALT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xBA), KEY_SEMICOLON); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xBB), KEY_EQUAL); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xBC), KEY_COMMA); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xBD), KEY_MINUS); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xBE), KEY_DOT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xBF), KEY_SLASH); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xC0), KEY_GRAVE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xDB), KEY_LEFTBRACE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xDC), KEY_BACKSLASH); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xDD), KEY_RIGHTBRACE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xDE), KEY_APOSTROPHE); + EXPECT_EQ(lvh::detail::test::linux_key_code(0xE2), KEY_102ND); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x30), KEY_0); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x31), KEY_1); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x39), KEY_9); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x41), KEY_A); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x42), KEY_B); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x5A), KEY_Z); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x60), KEY_KP0); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x61), KEY_KP1); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x69), KEY_KP9); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x6A), KEY_KPASTERISK); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x6B), KEY_KPPLUS); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x6D), KEY_KPMINUS); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x6E), KEY_KPDOT); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x6F), KEY_KPSLASH); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x70), KEY_F1); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x7B), KEY_F12); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x87), KEY_F24); + EXPECT_EQ(lvh::detail::test::linux_key_code(0), -1); + EXPECT_EQ(lvh::detail::test::linux_key_code(0x88), -1); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::left), BTN_LEFT); + EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::middle), BTN_MIDDLE); + EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::right), BTN_RIGHT); + EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::side), BTN_SIDE); + EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::extra), BTN_EXTRA); + EXPECT_EQ(lvh::detail::test::linux_mouse_button(static_cast(255)), BTN_LEFT); + + EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::unknown), BUS_USB); + EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::usb), BUS_USB); + EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); + EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_absolute_axis(-1, 100), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis(0, 100), 0); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis(50, 100), 32767); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis(100, 100), 65535); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis(101, 100), 65535); + EXPECT_EQ(lvh::detail::test::linux_absolute_axis(1, 0), 0); + + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(0), 0); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(1), 1); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(-1), -1); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(119), 1); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(120), 1); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(240), 2); + EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(-240), -2); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, DecodesTextHelpers) { +#if defined(__linux__) + EXPECT_EQ( + lvh::detail::test::linux_decode_utf8("A\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"), + (std::vector {0x41, 0xE9, 0x20AC, 0x1F600}) + ); + EXPECT_EQ( + lvh::detail::test::linux_decode_utf8(std::string {"A\xFF" + "B"}), + (std::vector {0x41, 0x42}) + ); + EXPECT_EQ( + lvh::detail::test::linux_decode_utf8(std::string {"A\xC3" + "B"}), + (std::vector {0x41, 0x42}) + ); + EXPECT_TRUE(lvh::detail::test::linux_decode_utf8("\xF0\x9F").empty()); + EXPECT_EQ(lvh::detail::test::linux_uppercase_hex(0x1F600), "1F600"); + EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('0'), 0x30); + EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('9'), 0x39); + EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('A'), 0x41); + EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('F'), 0x46); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) { +#if defined(__linux__) + EXPECT_EQ( + lvh::detail::test::linux_uhid_create_with_descriptor_size( + lvh::detail::test::linux_uhid_descriptor_limit() + 1 + ) + .code(), + lvh::ErrorCode::unsupported_profile + ); + EXPECT_EQ(lvh::detail::test::linux_uhid_create_with_descriptor_size(1).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ(lvh::detail::test::linux_uhid_submit_report_size(1).code(), lvh::ErrorCode::device_closed); + EXPECT_EQ( + lvh::detail::test::linux_uhid_submit_report_size(lvh::detail::test::linux_uhid_input_limit() + 1).code(), + lvh::ErrorCode::invalid_argument + ); + EXPECT_EQ(lvh::detail::test::linux_uhid_submit_after_close().code(), lvh::ErrorCode::device_closed); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ( + lvh::detail::test::linux_uinput_keyboard_submit_invalid_fd({.key_code = 0, .pressed = true}).code(), + lvh::ErrorCode::invalid_argument + ); + EXPECT_EQ( + lvh::detail::test::linux_uinput_keyboard_submit_invalid_fd({.key_code = 0x41, .pressed = true}).code(), + lvh::ErrorCode::device_closed + ); + EXPECT_TRUE(lvh::detail::test::linux_uinput_keyboard_type_text_invalid_fd("").ok()); + EXPECT_EQ( + lvh::detail::test::linux_uinput_keyboard_type_text_invalid_fd("A").code(), + lvh::ErrorCode::device_closed + ); + EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_submit_after_close().code(), lvh::ErrorCode::device_closed); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_copy_string_char_buffer("abcdef"), "abcd"); + + const auto result = lvh::detail::test::linux_uinput_keyboard_submit_pipe({.key_code = 0x41, .pressed = true}); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 2U); + EXPECT_EQ(result.events[0].type, EV_KEY); + EXPECT_EQ(result.events[0].code, KEY_A); + EXPECT_EQ(result.events[0].value, 1); + EXPECT_EQ(result.events[1].type, EV_SYN); + EXPECT_EQ(result.events[1].code, SYN_REPORT); + EXPECT_EQ(result.events[1].value, 0); + + EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_invalid_fd().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_pipe().code(), lvh::ErrorCode::backend_failure); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); + + lvh::MouseEvent event; + event.kind = static_cast(255); + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::invalid_argument); + + event.kind = lvh::MouseEventKind::relative_motion; + event.x = 5; + event.y = 0; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + event.x = 0; + event.y = 5; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + event.y = 0; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + event.kind = lvh::MouseEventKind::absolute_motion; + event.x = 50; + event.y = 75; + event.width = 100; + event.height = 100; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + event.kind = lvh::MouseEventKind::button; + event.button = lvh::MouseButton::side; + event.pressed = true; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + event.kind = lvh::MouseEventKind::vertical_scroll; + event.high_resolution_scroll = 120; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + event.kind = lvh::MouseEventKind::horizontal_scroll; + event.high_resolution_scroll = -120; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); + + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_after_close().code(), lvh::ErrorCode::device_closed); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { +#if defined(__linux__) + lvh::MouseEvent event; + event.kind = lvh::MouseEventKind::relative_motion; + event.x = 5; + event.y = -2; + auto result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 3U); + EXPECT_EQ(result.events[0].type, EV_REL); + EXPECT_EQ(result.events[0].code, REL_X); + EXPECT_EQ(result.events[0].value, 5); + EXPECT_EQ(result.events[1].type, EV_REL); + EXPECT_EQ(result.events[1].code, REL_Y); + EXPECT_EQ(result.events[1].value, -2); + EXPECT_EQ(result.events[2].type, EV_SYN); + + event = {}; + event.kind = lvh::MouseEventKind::absolute_motion; + event.x = 50; + event.y = 100; + event.width = 100; + event.height = 100; + result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 3U); + EXPECT_EQ(result.events[0].type, EV_ABS); + EXPECT_EQ(result.events[0].code, ABS_X); + EXPECT_EQ(result.events[0].value, 32767); + EXPECT_EQ(result.events[1].type, EV_ABS); + EXPECT_EQ(result.events[1].code, ABS_Y); + EXPECT_EQ(result.events[1].value, 65535); + EXPECT_EQ(result.events[2].type, EV_SYN); + + event = {}; + event.kind = lvh::MouseEventKind::button; + event.button = lvh::MouseButton::extra; + event.pressed = true; + result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 2U); + EXPECT_EQ(result.events[0].type, EV_KEY); + EXPECT_EQ(result.events[0].code, BTN_EXTRA); + EXPECT_EQ(result.events[0].value, 1); + EXPECT_EQ(result.events[1].type, EV_SYN); + + event = {}; + event.kind = lvh::MouseEventKind::vertical_scroll; + event.high_resolution_scroll = 120; + result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 2U); + EXPECT_EQ(result.events[0].type, EV_REL); + #if defined(REL_WHEEL_HI_RES) + EXPECT_EQ(result.events[0].code, REL_WHEEL_HI_RES); + EXPECT_EQ(result.events[0].value, 120); + #else + EXPECT_EQ(result.events[0].code, REL_WHEEL); + EXPECT_EQ(result.events[0].value, 1); + #endif + EXPECT_EQ(result.events[1].type, EV_SYN); + + event = {}; + event.kind = lvh::MouseEventKind::horizontal_scroll; + event.high_resolution_scroll = -120; + result = lvh::detail::test::linux_uinput_mouse_submit_pipe(event); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_EQ(result.events.size(), 2U); + EXPECT_EQ(result.events[0].type, EV_REL); + #if defined(REL_HWHEEL_HI_RES) + EXPECT_EQ(result.events[0].code, REL_HWHEEL_HI_RES); + EXPECT_EQ(result.events[0].value, -120); + #else + EXPECT_EQ(result.events[0].code, REL_HWHEEL); + EXPECT_EQ(result.events[0].value, -1); + #endif + EXPECT_EQ(result.events[1].type, EV_SYN); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { +#if defined(__linux__) + const auto result = lvh::detail::test::linux_uhid_socketpair_roundtrip(); + EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.saw_input); + EXPECT_TRUE(result.saw_get_report_reply); + EXPECT_TRUE(result.saw_set_report_reply); + EXPECT_TRUE(result.saw_destroy); + EXPECT_GE(result.output_callback_count, 2U); + EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); + EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { +#if defined(__linux__) + const auto unavailable = lvh::detail::test::linux_backend_fake_unavailable_capabilities(); + EXPECT_FALSE(unavailable.supports_virtual_hid); + EXPECT_FALSE(unavailable.supports_gamepad); + EXPECT_FALSE(unavailable.supports_keyboard); + EXPECT_FALSE(unavailable.supports_mouse); + EXPECT_FALSE(unavailable.supports_output_reports); + + EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_open_failure().code(), lvh::ErrorCode::backend_unavailable); + EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_create_failure().code(), lvh::ErrorCode::backend_failure); + + const auto keyboard_open_status = lvh::detail::test::linux_backend_keyboard_fake_open_failure(); + EXPECT_TRUE(keyboard_open_status.ok() || keyboard_open_status.code() == lvh::ErrorCode::backend_unavailable); + + const auto keyboard_create_status = lvh::detail::test::linux_backend_keyboard_fake_create_failure(); + EXPECT_TRUE(keyboard_create_status.ok() || keyboard_create_status.code() == lvh::ErrorCode::backend_failure); + EXPECT_TRUE(lvh::detail::test::linux_backend_keyboard_fake_fallback_success().ok()); + + const auto mouse_open_status = lvh::detail::test::linux_backend_mouse_fake_open_failure(); + EXPECT_TRUE(mouse_open_status.ok() || mouse_open_status.code() == lvh::ErrorCode::backend_unavailable); + + const auto mouse_create_status = lvh::detail::test::linux_backend_mouse_fake_create_failure(); + EXPECT_TRUE(mouse_create_status.ok() || mouse_create_status.code() == lvh::ErrorCode::backend_failure); + EXPECT_TRUE(lvh::detail::test::linux_backend_mouse_fake_fallback_success().ok()); + + const auto result = lvh::detail::test::linux_backend_create_all_fake_success(); + EXPECT_TRUE(result.capabilities.supports_virtual_hid); + EXPECT_TRUE(result.capabilities.supports_gamepad); + EXPECT_TRUE(result.capabilities.supports_keyboard); + EXPECT_TRUE(result.capabilities.supports_mouse); + EXPECT_TRUE(result.capabilities.supports_output_reports); + EXPECT_TRUE(result.gamepad_status.ok()) << result.gamepad_status.message(); + EXPECT_TRUE(result.gamepad_close_status.ok()) << result.gamepad_close_status.message(); + EXPECT_TRUE(result.keyboard_status.ok()) << result.keyboard_status.message(); + EXPECT_TRUE(result.keyboard_close_status.ok()) << result.keyboard_close_status.message(); + EXPECT_TRUE(result.mouse_status.ok()) << result.mouse_status.message(); + EXPECT_TRUE(result.mouse_close_status.ok()) << result.mouse_close_status.message(); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) { +#if defined(__linux__) + EXPECT_EQ(lvh::detail::test::linux_uhid_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uhid_submit_fake_short_write().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uhid_close_fake_write_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uhid_close_fake_close_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_retry_branches().ok()); + EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_poll_errors().ok()); + EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_read_error().ok()); + EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_output_without_callback().ok()); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, FakeUinputSyscallsCoverFailureBranches) { +#if defined(__linux__) + for (const auto fail_call : {1, 2}) { + EXPECT_EQ( + lvh::detail::test::linux_uinput_keyboard_create_fake_ioctl_failure(fail_call).code(), + lvh::ErrorCode::backend_failure + ); + } + + for (const auto fail_call : {1, 2, 3, 4, 9, 11, 12, 13, 14}) { + EXPECT_EQ( + lvh::detail::test::linux_uinput_mouse_create_fake_ioctl_failure(fail_call).code(), + lvh::ErrorCode::backend_failure + ); + } + + EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_fake_short_write().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_fake_create_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_submit_fake_short_write().code(), lvh::ErrorCode::backend_failure); + EXPECT_TRUE(lvh::detail::test::linux_uinput_keyboard_type_text_fake_success().ok()); + EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_close_fake_close_failure().code(), lvh::ErrorCode::backend_failure); + + lvh::MouseEvent event; + event.kind = lvh::MouseEventKind::relative_motion; + event.x = 1; + event.y = 1; + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_fake_write_failure(event).code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_fake_short_write(event).code(), lvh::ErrorCode::backend_failure); +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} + +TEST(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) { +#if defined(__linux__) + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(options); + + if (!runtime->capabilities().supports_gamepad) { + auto gamepad = runtime->create_gamepad(lvh::profiles::xbox_360()); + EXPECT_FALSE(gamepad); + EXPECT_EQ(gamepad.status.code(), lvh::ErrorCode::backend_unavailable); + } + + if (!runtime->capabilities().supports_keyboard) { + auto keyboard = runtime->create_keyboard(); + EXPECT_FALSE(keyboard); + EXPECT_EQ(keyboard.status.code(), lvh::ErrorCode::backend_unavailable); + } + + if (!runtime->capabilities().supports_mouse) { + auto mouse = runtime->create_mouse(); + EXPECT_FALSE(mouse); + EXPECT_EQ(mouse.status.code(), lvh::ErrorCode::backend_unavailable); + } +#else + GTEST_SKIP() << "Linux backend tests require Linux"; +#endif +} From 87fd22b7de80bbcb2ec9c2dce59382923c63112a Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:35:49 -0400 Subject: [PATCH 22/28] Move Linux UHID backend test hooks to tests Extract test-only Linux UHID backend hooks and syscall/XTest fakes out of src/ into a dedicated test translation unit (tests/fixtures/linux_backend_test_hooks.cpp) and move the header into tests/fixtures/include. Remove the LIBVIRTUALHID_ENABLE_TEST_HOOKS compile definition from src/CMakeLists.txt so production builds no longer expose test hooks. Update tests/CMakeLists.txt to collect test sources into a variable, conditionally add the Linux test hooks and X11/XTest links on Linux, and wire up compile defines/includes only when available. Add platform helpers and per-OS test base classes in fixtures (fixtures.cpp / fixtures.hpp). Update CI to install X11/XTest and sdl2-jstest, load uhid/uinput modules and adjust permissions for device nodes. Clarify README wording about Linux integration tests and their failure conditions when device nodes or discovery tools are unavailable. These changes keep test-only machinery out of production code and make the test harness platform-aware. --- .github/workflows/ci.yml | 8 +- README.md | 23 +- src/CMakeLists.txt | 6 - src/platform/linux/uhid_backend.cpp | 878 -------------- tests/CMakeLists.txt | 28 +- tests/fixtures/fixtures.cpp | 39 + tests/fixtures/include/fixtures/fixtures.hpp | 41 + .../fixtures/linux_backend_test_hooks.hpp | 2 +- tests/fixtures/linux_backend_test_hooks.cpp | 1013 +++++++++++++++++ tests/unit/test_linux_backend.cpp | 135 +-- tests/unit/test_linux_discovery.cpp | 57 +- tests/unit/test_runtime.cpp | 42 +- 12 files changed, 1233 insertions(+), 1039 deletions(-) rename src/platform/linux/uhid_backend_test_hooks.hpp => tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp (99%) create mode 100644 tests/fixtures/linux_backend_test_hooks.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61c1f62..22db30a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,8 +78,14 @@ jobs: build-essential \ clang \ cmake \ + libx11-dev \ + libxtst-dev \ llvm \ - ninja-build + ninja-build \ + sdl2-jstest + sudo modprobe uhid + sudo modprobe uinput + sudo chmod a+rw /dev/uhid /dev/uinput - name: Setup Dependencies macOS if: runner.os == 'macOS' diff --git a/README.md b/README.md index eeeda59..222649d 100644 --- a/README.md +++ b/README.md @@ -147,20 +147,15 @@ the `input` group, then log out and back in: sudo usermod -aG input $USER ``` -The Linux UHID smoke test is opt-in because it creates a real virtual gamepad. -Run it with `LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS=1` on a Linux host -where the current user can open `/dev/uhid`. - -The Linux uinput smoke test is opt-in because it creates real keyboard and -mouse devices. Run it with `LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS=1` on -a Linux host where the current user can open `/dev/uinput`. - -The Linux discovery integration test is opt-in because it creates a real UHID -gamepad and probes external input discovery tools. Run it with -`LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS=1` on a Linux host where the -current user can open `/dev/uhid`. The test uses `sdl2-jstest` and -`hidapitester` when either tool is installed, and skips cleanly when no -discovery tool is available. +The Linux UHID smoke test creates a real virtual gamepad and fails when the +current user cannot open `/dev/uhid`. + +The Linux uinput smoke test creates real keyboard and mouse devices and fails +when the current user cannot open `/dev/uinput`. + +The Linux discovery integration test creates a real UHID gamepad and probes +external input discovery tools. It fails when `/dev/uhid` is unavailable, or +when neither `sdl2-jstest` nor `hidapitester` is installed. When `BUILD_EXAMPLES` is enabled on Linux, the `linux_discovery_probe` example creates a generic UHID gamepad and performs the same SDL/HIDAPI discovery probe diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index eb4ca78..70fd7c0 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -18,12 +18,6 @@ if(LIBVIRTUALHID_USES_THREADS) target_sources(${PROJECT_NAME} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/platform/linux/uhid_backend.cpp") - if(BUILD_TESTS) - # Expose private Linux backend helpers only to tests so coverage does not depend on real device nodes. - target_compile_definitions(${PROJECT_NAME} - PRIVATE - LIBVIRTUALHID_ENABLE_TEST_HOOKS=1) - endif() target_link_libraries(${PROJECT_NAME} PUBLIC Threads::Threads) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index d99d395..91f7e65 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -47,10 +47,6 @@ #include #include -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - #include "platform/linux/uhid_backend_test_hooks.hpp" -#endif - namespace lvh::detail { namespace { @@ -59,174 +55,31 @@ namespace lvh::detail { constexpr auto absolute_axis_max = 65535; constexpr auto poll_timeout_ms = 100; -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - struct LinuxTestSyscalls { - bool override_access = false; - int access_result = 0; - bool override_open = false; - int open_result = 100000; - bool override_write = false; - std::atomic_int write_call_count = 0; - int fail_write_call = -1; - int short_write_call = -1; - std::size_t short_write_size = 1; - bool override_ioctl = false; - std::atomic_int ioctl_call_count = 0; - int fail_ioctl_call = -1; - bool override_close = false; - int close_result = 0; - bool override_poll = false; - std::atomic_int poll_call_count = 0; - std::vector poll_results; - std::vector poll_revents; - std::vector poll_errors; - bool override_read = false; - std::atomic_int read_call_count = 0; - std::vector read_results; - std::vector read_errors; - uhid_event read_event {}; - bool fake_xtest_keyboard = false; - bool fake_xtest_mouse = false; - }; - - LinuxTestSyscalls *active_test_syscalls = nullptr; - - class ScopedLinuxTestSyscalls { - public: - explicit ScopedLinuxTestSyscalls(LinuxTestSyscalls &syscalls): - previous_ {active_test_syscalls} { - active_test_syscalls = &syscalls; - } - - ScopedLinuxTestSyscalls(const ScopedLinuxTestSyscalls &) = delete; - ScopedLinuxTestSyscalls &operator=(const ScopedLinuxTestSyscalls &) = delete; - ScopedLinuxTestSyscalls(ScopedLinuxTestSyscalls &&) noexcept = delete; - ScopedLinuxTestSyscalls &operator=(ScopedLinuxTestSyscalls &&) noexcept = delete; - - ~ScopedLinuxTestSyscalls() { - active_test_syscalls = previous_; - } - - private: - LinuxTestSyscalls *previous_ = nullptr; - }; -#endif - int system_access(const char *path, int mode) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_access) { - if (active_test_syscalls->access_result < 0) { - errno = EACCES; - } - return active_test_syscalls->access_result; - } -#endif return ::access(path, mode); } int system_open(const char *path, int flags) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_open) { - if (active_test_syscalls->open_result < 0) { - errno = ENOENT; - } - return active_test_syscalls->open_result; - } -#endif return ::open(path, flags); } int system_close(int fd) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_close) { - if (active_test_syscalls->close_result < 0) { - errno = EIO; - } - return active_test_syscalls->close_result; - } -#endif return ::close(fd); } std::ptrdiff_t system_write(int fd, const void *buffer, std::size_t size) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_write) { - const auto call_count = ++active_test_syscalls->write_call_count; - if (active_test_syscalls->fail_write_call == call_count) { - errno = EIO; - return -1; - } - if (active_test_syscalls->short_write_call == call_count) { - return static_cast(active_test_syscalls->short_write_size); - } - return static_cast(size); - } -#endif return static_cast(::write(fd, buffer, size)); } int system_ioctl(int fd, unsigned long request, unsigned long argument = 0) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_ioctl) { - const auto call_count = ++active_test_syscalls->ioctl_call_count; - if (active_test_syscalls->fail_ioctl_call == call_count) { - errno = EINVAL; - return -1; - } - return 0; - } -#endif return ::ioctl(fd, request, argument); } int system_poll(pollfd *descriptors, nfds_t descriptor_count, int timeout) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_poll) { - const auto call_index = static_cast(active_test_syscalls->poll_call_count++); - auto result = 0; - if (call_index < active_test_syscalls->poll_results.size()) { - result = active_test_syscalls->poll_results[call_index]; - } - - if (descriptor_count > 0) { - descriptors[0].revents = 0; - if (result > 0 && call_index < active_test_syscalls->poll_revents.size()) { - descriptors[0].revents = active_test_syscalls->poll_revents[call_index]; - } - } - - if (result < 0) { - errno = call_index < active_test_syscalls->poll_errors.size() ? active_test_syscalls->poll_errors[call_index] : EIO; - } - return result; - } -#endif return ::poll(descriptors, descriptor_count, timeout); } std::ptrdiff_t system_read(int fd, void *buffer, std::size_t size) { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->override_read) { - const auto call_index = static_cast(active_test_syscalls->read_call_count++); - auto result = std::ptrdiff_t {0}; - if (call_index < active_test_syscalls->read_results.size()) { - result = active_test_syscalls->read_results[call_index]; - } - - if (result < 0) { - errno = call_index < active_test_syscalls->read_errors.size() ? active_test_syscalls->read_errors[call_index] : EIO; - return result; - } - - if (result > 0) { - const auto bytes = std::min(static_cast(result), std::min(size, sizeof(uhid_event))); - std::memcpy(buffer, &active_test_syscalls->read_event, bytes); - return static_cast(bytes); - } - - return result; - } -#endif return static_cast(::read(fd, buffer, size)); } @@ -1564,11 +1417,6 @@ namespace lvh::detail { private: BackendKeyboardCreationResult create_xtest_keyboard() { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->fake_xtest_keyboard) { - return {Status::success(), std::make_unique(active_test_syscalls->open_result)}; - } -#endif #if defined(LIBVIRTUALHID_HAVE_XTEST) auto keyboard = std::make_unique(); if (const auto status = keyboard->create(); !status.ok()) { @@ -1581,11 +1429,6 @@ namespace lvh::detail { } BackendMouseCreationResult create_xtest_mouse() { -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - if (active_test_syscalls != nullptr && active_test_syscalls->fake_xtest_mouse) { - return {Status::success(), std::make_unique(active_test_syscalls->open_result)}; - } -#endif #if defined(LIBVIRTUALHID_HAVE_XTEST) auto mouse = std::make_unique(); if (const auto status = mouse->create(); !status.ok()) { @@ -1602,727 +1445,6 @@ namespace lvh::detail { } // namespace -#if defined(LIBVIRTUALHID_ENABLE_TEST_HOOKS) - namespace test { - namespace { - constexpr auto fake_fd = 100000; - - std::vector read_input_events_until_eof(int fd) { - std::vector records; - input_event event {}; - while (::read(fd, &event, sizeof(event)) == sizeof(event)) { - records.push_back({ - .type = event.type, - .code = event.code, - .value = event.value, - }); - } - return records; - } - - bool write_uhid_event(int fd, const uhid_event &event) { - auto *data = reinterpret_cast(&event); - std::size_t written = 0; - while (written < sizeof(event)) { - const auto result = ::write(fd, data + written, sizeof(event) - written); - if (result <= 0) { - return false; - } - written += static_cast(result); - } - return true; - } - - bool read_uhid_event(int fd, uhid_event &event) { - pollfd descriptor {}; - descriptor.fd = fd; - descriptor.events = POLLIN; - const auto poll_result = ::poll(&descriptor, 1, 1000); - if (poll_result <= 0 || (descriptor.revents & POLLIN) == 0) { - return false; - } - - auto *data = reinterpret_cast(&event); - std::size_t read_size = 0; - while (read_size < sizeof(event)) { - const auto result = ::read(fd, data + read_size, sizeof(event) - read_size); - if (result <= 0) { - return false; - } - read_size += static_cast(result); - } - return true; - } - - void enable_fake_device_syscalls(LinuxTestSyscalls &syscalls) { - syscalls.override_access = true; - syscalls.override_open = true; - syscalls.override_write = true; - syscalls.override_ioctl = true; - syscalls.override_close = true; - } - - bool wait_for_poll_calls(const LinuxTestSyscalls &syscalls, int expected_calls) { - using namespace std::chrono_literals; - - for (auto attempt = 0; attempt < 100; ++attempt) { - if (syscalls.poll_call_count.load() >= expected_calls) { - return true; - } - std::this_thread::sleep_for(1ms); - } - return false; - } - - Status run_fake_uhid_read_loop(LinuxTestSyscalls &syscalls, int expected_poll_calls) { - syscalls.override_write = true; - syscalls.override_close = true; - - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - CreateGamepadOptions options; - options.profile = profiles::generic_gamepad(); - - UhidGamepad gamepad {fake_fd}; - if (const auto status = gamepad.create(1, options); !status.ok()) { - return status; - } - - const auto saw_expected_polls = wait_for_poll_calls(syscalls, expected_poll_calls); - const auto close_status = gamepad.close(); - if (!saw_expected_polls) { - return Status::failure(ErrorCode::backend_failure, "fake UHID read loop did not consume the scripted poll calls"); - } - return close_status; - } - - } // namespace - - std::string linux_copy_string_char_buffer(const std::string &source) { - char destination[5] {}; - copy_string(destination, source); - return destination; - } - - int linux_key_code(KeyboardKeyCode key_code) { - return key_code_to_linux(key_code); - } - - int linux_mouse_button(MouseButton button) { - return mouse_button_to_linux(button); - } - - std::uint16_t linux_uhid_bus(BusType bus_type) { - return to_uhid_bus(bus_type); - } - - std::uint16_t linux_uinput_bus(BusType bus_type) { - return to_uinput_bus(bus_type); - } - - int linux_absolute_axis(std::int32_t value, std::int32_t limit) { - return scale_absolute_axis(value, limit); - } - - std::vector linux_decode_utf8(const std::string &text) { - return decode_utf8(text); - } - - std::string linux_uppercase_hex(std::uint32_t codepoint) { - return uppercase_hex(codepoint); - } - - KeyboardKeyCode linux_hex_digit_key_code(char digit) { - return hex_digit_key_code(digit); - } - - int linux_legacy_scroll_steps(std::int32_t distance) { - return legacy_scroll_steps(distance); - } - - std::size_t linux_uhid_descriptor_limit() { - uhid_event event {}; - return sizeof(event.u.create2.rd_data); - } - - std::size_t linux_uhid_input_limit() { - uhid_event event {}; - return sizeof(event.u.input2.data); - } - - Status linux_uhid_create_with_descriptor_size(std::size_t descriptor_size) { - auto profile = profiles::generic_gamepad(); - profile.report_descriptor.assign(descriptor_size, 0); - - CreateGamepadOptions options; - options.profile = std::move(profile); - - UhidGamepad gamepad {-1}; - return gamepad.create(1, options); - } - - Status linux_uhid_submit_report_size(std::size_t report_size) { - UhidGamepad gamepad {-1}; - return gamepad.submit(std::vector(report_size, 0)); - } - - Status linux_uhid_submit_after_close() { - UhidGamepad gamepad {-1}; - static_cast(gamepad.close()); - return gamepad.submit({0}); - } - - Status linux_uinput_keyboard_create_invalid_fd() { - CreateKeyboardOptions options; - options.profile = profiles::keyboard(); - - UinputKeyboard keyboard {-1}; - return keyboard.create(1, options); - } - - Status linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event) { - UinputKeyboard keyboard {-1}; - return keyboard.submit(event); - } - - Status linux_uinput_keyboard_type_text_invalid_fd(const std::string &text) { - UinputKeyboard keyboard {-1}; - return keyboard.type_text({.text = text}); - } - - Status linux_uinput_keyboard_submit_after_close() { - UinputKeyboard keyboard {-1}; - static_cast(keyboard.close()); - return keyboard.submit({.key_code = 0x41, .pressed = true}); - } - - LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event) { - int descriptors[2] {-1, -1}; - if (::pipe(descriptors) != 0) { - return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; - } - - UinputKeyboard keyboard {descriptors[1]}; - auto status = keyboard.submit(event); - static_cast(keyboard.close()); - auto records = read_input_events_until_eof(descriptors[0]); - static_cast(::close(descriptors[0])); - return {std::move(status), std::move(records)}; - } - - Status linux_uinput_user_device_invalid_fd() { - return write_uinput_user_device(-1, profiles::mouse(), 1); - } - - Status linux_uinput_user_device_pipe() { - int descriptors[2] {-1, -1}; - if (::pipe(descriptors) != 0) { - return system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno); - } - - auto status = write_uinput_user_device(descriptors[1], profiles::mouse(), 1); - static_cast(::close(descriptors[0])); - static_cast(::close(descriptors[1])); - return status; - } - - Status linux_uinput_mouse_create_invalid_fd() { - CreateMouseOptions options; - options.profile = profiles::mouse(); - - UinputMouse mouse {-1}; - return mouse.create(1, options); - } - - Status linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event) { - UinputMouse mouse {-1}; - return mouse.submit(event); - } - - Status linux_uinput_mouse_submit_after_close() { - UinputMouse mouse {-1}; - static_cast(mouse.close()); - return mouse.submit({.kind = MouseEventKind::relative_motion, .x = 1, .y = 1}); - } - - LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { - int descriptors[2] {-1, -1}; - if (::pipe(descriptors) != 0) { - return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; - } - - UinputMouse mouse {descriptors[1]}; - auto status = mouse.submit(event); - static_cast(mouse.close()); - auto records = read_input_events_until_eof(descriptors[0]); - static_cast(::close(descriptors[0])); - return {std::move(status), std::move(records)}; - } - - LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip() { - LinuxUhidRoundTripResult result; - int descriptors[2] {-1, -1}; - if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors) != 0) { - result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno); - result.submit_status = result.create_status; - result.close_status = result.create_status; - return result; - } - - auto profile = profiles::xbox_360(); - CreateGamepadOptions options; - options.profile = profile; - options.metadata.stable_id = "linux-uhid-roundtrip"; - - UhidGamepad gamepad {descriptors[0]}; - result.create_status = gamepad.create(7, options); - - uhid_event event {}; - if (read_uhid_event(descriptors[1], event)) { - result.saw_create = event.type == UHID_CREATE2 && event.u.create2.vendor == profile.vendor_id && - event.u.create2.product == profile.product_id; - } - - gamepad.set_output_callback([&result](const GamepadOutput &output) { - ++result.output_callback_count; - result.last_output = output; - }); - - event = {}; - event.type = UHID_OUTPUT; - event.u.output.size = static_cast<__u16>(profile.output_report_size); - event.u.output.data[0] = profile.report_id; - event.u.output.data[1] = 0x34; - event.u.output.data[2] = 0x12; - event.u.output.data[3] = 0x78; - event.u.output.data[4] = 0x56; - static_cast(write_uhid_event(descriptors[1], event)); - - event = {}; - event.type = UHID_GET_REPORT; - event.u.get_report.id = 9; - static_cast(write_uhid_event(descriptors[1], event)); - if (read_uhid_event(descriptors[1], event)) { - result.saw_get_report_reply = event.type == UHID_GET_REPORT_REPLY && event.u.get_report_reply.id == 9; - } - - event = {}; - event.type = UHID_SET_REPORT; - event.u.set_report.id = 10; - event.u.set_report.size = static_cast<__u16>(profile.output_report_size); - event.u.set_report.data[0] = profile.report_id; - event.u.set_report.data[1] = 0x78; - event.u.set_report.data[2] = 0x56; - event.u.set_report.data[3] = 0x34; - event.u.set_report.data[4] = 0x12; - static_cast(write_uhid_event(descriptors[1], event)); - if (read_uhid_event(descriptors[1], event)) { - result.saw_set_report_reply = event.type == UHID_SET_REPORT_REPLY && event.u.set_report_reply.id == 10; - } - - event = {}; - event.type = UHID_OPEN; - static_cast(write_uhid_event(descriptors[1], event)); - - lvh::GamepadState state; - state.buttons.set(GamepadButton::a); - const auto report = reports::pack_input_report(profile, state); - result.submit_status = gamepad.submit(report); - if (read_uhid_event(descriptors[1], event)) { - result.saw_input = event.type == UHID_INPUT2 && event.u.input2.size == report.size(); - } - - result.close_status = gamepad.close(); - if (read_uhid_event(descriptors[1], event)) { - result.saw_destroy = event.type == UHID_DESTROY; - } - - static_cast(::close(descriptors[1])); - return result; - } - - LinuxBackendFakeCreationResult linux_backend_create_all_fake_success() { - LinuxTestSyscalls syscalls; - enable_fake_device_syscalls(syscalls); - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxBackendFakeCreationResult result; - LinuxUhidBackend backend; - result.capabilities = backend.capabilities(); - - CreateGamepadOptions gamepad_options; - gamepad_options.profile = profiles::xbox_360(); - gamepad_options.metadata.stable_id = "fake-linux-gamepad"; - auto gamepad = backend.create_gamepad(1, gamepad_options); - result.gamepad_status = gamepad.status; - if (gamepad) { - result.gamepad_close_status = gamepad.gamepad->close(); - } - - CreateKeyboardOptions keyboard_options; - keyboard_options.profile = profiles::keyboard(); - keyboard_options.stable_id = "fake-linux-keyboard"; - auto keyboard = backend.create_keyboard(2, keyboard_options); - result.keyboard_status = keyboard.status; - if (keyboard) { - result.keyboard_close_status = keyboard.keyboard->close(); - } - - CreateMouseOptions mouse_options; - mouse_options.profile = profiles::mouse(); - mouse_options.stable_id = "fake-linux-mouse"; - auto mouse = backend.create_mouse(3, mouse_options); - result.mouse_status = mouse.status; - if (mouse) { - result.mouse_close_status = mouse.mouse->close(); - } - - return result; - } - - BackendCapabilities linux_backend_fake_unavailable_capabilities() { - LinuxTestSyscalls syscalls; - syscalls.override_access = true; - syscalls.access_result = -1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - return backend.capabilities(); - } - - Status linux_backend_gamepad_fake_open_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_access = true; - syscalls.override_open = true; - syscalls.open_result = -1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateGamepadOptions options; - options.profile = profiles::xbox_360(); - return backend.create_gamepad(1, options).status; - } - - Status linux_backend_gamepad_fake_create_failure() { - LinuxTestSyscalls syscalls; - enable_fake_device_syscalls(syscalls); - syscalls.fail_write_call = 1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateGamepadOptions options; - options.profile = profiles::xbox_360(); - return backend.create_gamepad(1, options).status; - } - - Status linux_backend_keyboard_fake_open_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_access = true; - syscalls.override_open = true; - syscalls.open_result = -1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateKeyboardOptions options; - options.profile = profiles::keyboard(); - return backend.create_keyboard(1, options).status; - } - - Status linux_backend_keyboard_fake_create_failure() { - LinuxTestSyscalls syscalls; - enable_fake_device_syscalls(syscalls); - syscalls.fail_ioctl_call = 1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateKeyboardOptions options; - options.profile = profiles::keyboard(); - return backend.create_keyboard(1, options).status; - } - - Status linux_backend_keyboard_fake_fallback_success() { - LinuxTestSyscalls syscalls; - enable_fake_device_syscalls(syscalls); - syscalls.fail_ioctl_call = 1; - syscalls.fake_xtest_keyboard = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateKeyboardOptions options; - options.profile = profiles::keyboard(); - auto keyboard = backend.create_keyboard(1, options); - if (!keyboard) { - return keyboard.status; - } - return keyboard.keyboard->close(); - } - - Status linux_backend_mouse_fake_open_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_access = true; - syscalls.override_open = true; - syscalls.open_result = -1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateMouseOptions options; - options.profile = profiles::mouse(); - return backend.create_mouse(1, options).status; - } - - Status linux_backend_mouse_fake_create_failure() { - LinuxTestSyscalls syscalls; - enable_fake_device_syscalls(syscalls); - syscalls.fail_ioctl_call = 1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateMouseOptions options; - options.profile = profiles::mouse(); - return backend.create_mouse(1, options).status; - } - - Status linux_backend_mouse_fake_fallback_success() { - LinuxTestSyscalls syscalls; - enable_fake_device_syscalls(syscalls); - syscalls.fail_ioctl_call = 1; - syscalls.fake_xtest_mouse = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - LinuxUhidBackend backend; - - CreateMouseOptions options; - options.profile = profiles::mouse(); - auto mouse = backend.create_mouse(1, options); - if (!mouse) { - return mouse.status; - } - return mouse.mouse->close(); - } - - Status linux_uhid_submit_fake_write_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.fail_write_call = 1; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UhidGamepad gamepad {fake_fd}; - return gamepad.submit({0}); - } - - Status linux_uhid_submit_fake_short_write() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.short_write_call = 1; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UhidGamepad gamepad {fake_fd}; - return gamepad.submit({0}); - } - - Status linux_uhid_close_fake_write_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.fail_write_call = 1; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UhidGamepad gamepad {fake_fd}; - return gamepad.close(); - } - - Status linux_uhid_close_fake_close_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.override_close = true; - syscalls.close_result = -1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UhidGamepad gamepad {fake_fd}; - return gamepad.close(); - } - - Status linux_uhid_read_loop_fake_retry_branches() { - LinuxTestSyscalls syscalls; - syscalls.override_poll = true; - syscalls.poll_results = {-1, 0, 1, 1, 1}; - syscalls.poll_revents = {0, 0, 0, POLLIN, POLLIN}; - syscalls.poll_errors = {EINTR}; - syscalls.override_read = true; - syscalls.read_results = {-1, 0}; - syscalls.read_errors = {EAGAIN}; - return run_fake_uhid_read_loop(syscalls, 5); - } - - Status linux_uhid_read_loop_fake_poll_errors() { - LinuxTestSyscalls syscall_failure; - syscall_failure.override_poll = true; - syscall_failure.poll_results = {-1}; - syscall_failure.poll_errors = {EIO}; - if (const auto status = run_fake_uhid_read_loop(syscall_failure, 1); !status.ok()) { - return status; - } - - LinuxTestSyscalls event_failure; - event_failure.override_poll = true; - event_failure.poll_results = {1}; - event_failure.poll_revents = {POLLERR}; - return run_fake_uhid_read_loop(event_failure, 1); - } - - Status linux_uhid_read_loop_fake_read_error() { - LinuxTestSyscalls syscalls; - syscalls.override_poll = true; - syscalls.poll_results = {1}; - syscalls.poll_revents = {POLLIN}; - syscalls.override_read = true; - syscalls.read_results = {-1}; - syscalls.read_errors = {EIO}; - return run_fake_uhid_read_loop(syscalls, 1); - } - - Status linux_uhid_read_loop_fake_output_without_callback() { - LinuxTestSyscalls syscalls; - syscalls.override_poll = true; - syscalls.poll_results = {1, 1}; - syscalls.poll_revents = {POLLIN, POLLIN}; - syscalls.override_read = true; - syscalls.read_results = {static_cast(sizeof(uhid_event)), 0}; - syscalls.read_event.type = UHID_OUTPUT; - return run_fake_uhid_read_loop(syscalls, 2); - } - - Status linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call) { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.override_ioctl = true; - syscalls.fail_ioctl_call = fail_ioctl_call; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - CreateKeyboardOptions options; - options.profile = profiles::keyboard(); - - UinputKeyboard keyboard {fake_fd}; - return keyboard.create(1, options); - } - - Status linux_uinput_user_device_fake_short_write() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.short_write_call = 1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - return write_uinput_user_device(fake_fd, profiles::mouse(), 1); - } - - Status linux_uinput_user_device_fake_create_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.override_ioctl = true; - syscalls.fail_ioctl_call = 1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - return write_uinput_user_device(fake_fd, profiles::mouse(), 1); - } - - Status linux_uinput_keyboard_submit_fake_write_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.fail_write_call = 1; - syscalls.override_ioctl = true; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UinputKeyboard keyboard {fake_fd}; - return keyboard.submit({.key_code = 0x41, .pressed = true}); - } - - Status linux_uinput_keyboard_submit_fake_short_write() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.short_write_call = 1; - syscalls.override_ioctl = true; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UinputKeyboard keyboard {fake_fd}; - return keyboard.submit({.key_code = 0x41, .pressed = true}); - } - - Status linux_uinput_keyboard_type_text_fake_success() { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.override_ioctl = true; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UinputKeyboard keyboard {fake_fd}; - return keyboard.type_text({.text = "A"}); - } - - Status linux_uinput_keyboard_close_fake_close_failure() { - LinuxTestSyscalls syscalls; - syscalls.override_ioctl = true; - syscalls.override_close = true; - syscalls.close_result = -1; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UinputKeyboard keyboard {fake_fd}; - return keyboard.close(); - } - - Status linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call) { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.override_ioctl = true; - syscalls.fail_ioctl_call = fail_ioctl_call; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - CreateMouseOptions options; - options.profile = profiles::mouse(); - - UinputMouse mouse {fake_fd}; - return mouse.create(1, options); - } - - Status linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event) { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.fail_write_call = 1; - syscalls.override_ioctl = true; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UinputMouse mouse {fake_fd}; - return mouse.submit(event); - } - - Status linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event) { - LinuxTestSyscalls syscalls; - syscalls.override_write = true; - syscalls.short_write_call = 1; - syscalls.override_ioctl = true; - syscalls.override_close = true; - ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; - - UinputMouse mouse {fake_fd}; - return mouse.submit(event); - } - - } // namespace test -#endif - std::unique_ptr create_platform_backend() { return std::make_unique(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a06d4c0..420521f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,7 +14,7 @@ add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/googletest" set(TEST_BINARY test_libvirtualhid) -add_executable(${TEST_BINARY} +set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/fixtures.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" @@ -23,6 +23,18 @@ add_executable(${TEST_BINARY} "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_report.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_runtime.cpp") +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + list(APPEND LIBVIRTUALHID_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp") + + if(LIBVIRTUALHID_ENABLE_XTEST) + find_package(X11 QUIET) + endif() +endif() + +add_executable(${TEST_BINARY} + ${LIBVIRTUALHID_TEST_SOURCES}) + target_include_directories(${TEST_BINARY} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/include" @@ -33,6 +45,20 @@ target_link_libraries(${TEST_BINARY} gmock_main libvirtualhid::libvirtualhid) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND LIBVIRTUALHID_ENABLE_XTEST AND X11_FOUND AND X11_XTest_FOUND) + target_compile_definitions(${TEST_BINARY} + PRIVATE + LIBVIRTUALHID_HAVE_XTEST=1) + target_include_directories(${TEST_BINARY} + PRIVATE + ${X11_INCLUDE_DIR} + ${X11_XTest_INCLUDE_PATH}) + target_link_libraries(${TEST_BINARY} + PRIVATE + ${X11_LIBRARIES} + ${X11_XTest_LIB}) +endif() + libvirtualhid_copy_mingw_runtime(${TEST_BINARY}) gtest_discover_tests(${TEST_BINARY}) diff --git a/tests/fixtures/fixtures.cpp b/tests/fixtures/fixtures.cpp index f82f8c6..cb5279a 100644 --- a/tests/fixtures/fixtures.cpp +++ b/tests/fixtures/fixtures.cpp @@ -6,6 +6,11 @@ // standard includes #include +// platform includes +#if defined(__linux__) + #include +#endif + // local includes #include "fixtures/fixtures.hpp" @@ -31,3 +36,37 @@ void BaseTest::TearDown() { << cout_buffer_.str() << std::endl; } } + +void LinuxTest::SetUp() { +#if !defined(__linux__) + GTEST_SKIP() << "Skipping, this test is for Linux only."; +#endif + BaseTest::SetUp(); +} + +::testing::AssertionResult LinuxTest::HasReadableWritableDeviceNode(const char *path) { +#if defined(__linux__) + if (::access(path, R_OK | W_OK) == 0) { + return ::testing::AssertionSuccess(); + } + + return ::testing::AssertionFailure() << path << " must be readable and writable"; +#else + static_cast(path); + return ::testing::AssertionSuccess(); +#endif +} + +void MacOSTest::SetUp() { +#if !defined(__APPLE__) || !defined(__MACH__) + GTEST_SKIP() << "Skipping, this test is for macOS only."; +#endif + BaseTest::SetUp(); +} + +void WindowsTest::SetUp() { +#if !defined(_WIN32) + GTEST_SKIP() << "Skipping, this test is for Windows only."; +#endif + BaseTest::SetUp(); +} diff --git a/tests/fixtures/include/fixtures/fixtures.hpp b/tests/fixtures/include/fixtures/fixtures.hpp index a9154f9..0b3a7de 100644 --- a/tests/fixtures/include/fixtures/fixtures.hpp +++ b/tests/fixtures/include/fixtures/fixtures.hpp @@ -31,6 +31,47 @@ class BaseTest: public ::testing::Test { std::streambuf *cout_streambuf_ {nullptr}; }; +/** + * @brief Base class for Linux-only tests. + */ +class LinuxTest: public BaseTest { +protected: + /** + * @brief Set up the test. + */ + void SetUp() override; + + /** + * @brief Check that a Linux device node is readable and writable. + * + * @param path Device node path. + * @return GoogleTest assertion result. + */ + static ::testing::AssertionResult HasReadableWritableDeviceNode(const char *path); +}; + +/** + * @brief Base class for macOS-only tests. + */ +class MacOSTest: public BaseTest { +protected: + /** + * @brief Set up the test. + */ + void SetUp() override; +}; + +/** + * @brief Base class for Windows-only tests. + */ +class WindowsTest: public BaseTest { +protected: + /** + * @brief Set up the test. + */ + void SetUp() override; +}; + // Undefine the original TEST macro. #undef TEST // NOSONAR(cpp:S959): Tests intentionally wrap TEST to use BaseTest. diff --git a/src/platform/linux/uhid_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp similarity index 99% rename from src/platform/linux/uhid_backend_test_hooks.hpp rename to tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index 2316c0b..c82b755 100644 --- a/src/platform/linux/uhid_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -1,5 +1,5 @@ /** - * @file src/platform/linux/uhid_backend_test_hooks.hpp + * @file tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp * @brief Test-only hooks for Linux UHID backend internals. */ #pragma once diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp new file mode 100644 index 0000000..fb2e49b --- /dev/null +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -0,0 +1,1013 @@ +/** + * @file tests/fixtures/linux_backend_test_hooks.cpp + * @brief Linux backend test hook definitions. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#if defined(__linux__) + #include + #ifndef __user + #define __user + #endif + #include + #include + #include + #include + #include + #include + + #if defined(LIBVIRTUALHID_HAVE_XTEST) + #include + #include + #endif +#endif + +// local includes +#include "fixtures/linux_backend_test_hooks.hpp" + +#if defined(__linux__) +namespace lvh::detail::test { + namespace { + + struct LinuxTestSyscalls { + bool override_access = false; + int access_result = 0; + bool override_open = false; + int open_result = 100000; + bool override_write = false; + std::atomic_int write_call_count = 0; + int fail_write_call = -1; + int short_write_call = -1; + std::size_t short_write_size = 1; + bool override_ioctl = false; + std::atomic_int ioctl_call_count = 0; + int fail_ioctl_call = -1; + bool override_poll = false; + std::atomic_int poll_call_count = 0; + std::vector poll_results; + std::vector poll_revents; + std::vector poll_errors; + bool override_read = false; + std::atomic_int read_call_count = 0; + std::vector read_results; + std::vector read_errors; + uhid_event read_event {}; + }; + + LinuxTestSyscalls *active_test_syscalls = nullptr; + + class ScopedLinuxTestSyscalls { + public: + explicit ScopedLinuxTestSyscalls(LinuxTestSyscalls &syscalls): + previous_ {active_test_syscalls} { + active_test_syscalls = &syscalls; + } + + ScopedLinuxTestSyscalls(const ScopedLinuxTestSyscalls &) = delete; + ScopedLinuxTestSyscalls &operator=(const ScopedLinuxTestSyscalls &) = delete; + ScopedLinuxTestSyscalls(ScopedLinuxTestSyscalls &&) noexcept = delete; + ScopedLinuxTestSyscalls &operator=(ScopedLinuxTestSyscalls &&) noexcept = delete; + + ~ScopedLinuxTestSyscalls() { + active_test_syscalls = previous_; + } + + private: + LinuxTestSyscalls *previous_ = nullptr; + }; + + } // namespace +} // namespace lvh::detail::test + +int lvh_linux_test_access(const char *path, int mode) { + if (lvh::detail::test::active_test_syscalls != nullptr && lvh::detail::test::active_test_syscalls->override_access) { + if (lvh::detail::test::active_test_syscalls->access_result < 0) { + errno = EACCES; + } + return lvh::detail::test::active_test_syscalls->access_result; + } + return ::access(path, mode); +} + +int lvh_linux_test_open(const char *path, int flags) { + if (lvh::detail::test::active_test_syscalls != nullptr && lvh::detail::test::active_test_syscalls->override_open) { + if (lvh::detail::test::active_test_syscalls->open_result < 0) { + errno = ENOENT; + return lvh::detail::test::active_test_syscalls->open_result; + } + const auto fd = ::open("/dev/null", O_RDWR); + if (fd < 0) { + errno = EIO; + } + return fd; + } + return ::open(path, flags); +} + +std::ptrdiff_t lvh_linux_test_write(int fd, const void *buffer, std::size_t size) { + if (lvh::detail::test::active_test_syscalls != nullptr && lvh::detail::test::active_test_syscalls->override_write) { + const auto call_count = ++lvh::detail::test::active_test_syscalls->write_call_count; + if (lvh::detail::test::active_test_syscalls->fail_write_call == call_count) { + errno = EIO; + return -1; + } + if (lvh::detail::test::active_test_syscalls->short_write_call == call_count) { + return static_cast(lvh::detail::test::active_test_syscalls->short_write_size); + } + return static_cast(size); + } + return static_cast(::write(fd, buffer, size)); +} + +int lvh_linux_test_ioctl(int fd, unsigned long request, unsigned long argument) { + if (lvh::detail::test::active_test_syscalls != nullptr && lvh::detail::test::active_test_syscalls->override_ioctl) { + const auto call_count = ++lvh::detail::test::active_test_syscalls->ioctl_call_count; + if (lvh::detail::test::active_test_syscalls->fail_ioctl_call == call_count) { + errno = EINVAL; + return -1; + } + return 0; + } + return ::ioctl(fd, request, argument); +} + +int lvh_linux_test_poll(pollfd *descriptors, nfds_t descriptor_count, int timeout) { + if (lvh::detail::test::active_test_syscalls != nullptr && lvh::detail::test::active_test_syscalls->override_poll) { + const auto call_index = static_cast(lvh::detail::test::active_test_syscalls->poll_call_count++); + auto result = 0; + if (call_index < lvh::detail::test::active_test_syscalls->poll_results.size()) { + result = lvh::detail::test::active_test_syscalls->poll_results[call_index]; + } + + if (descriptor_count > 0) { + descriptors[0].revents = 0; + if (result > 0 && call_index < lvh::detail::test::active_test_syscalls->poll_revents.size()) { + descriptors[0].revents = lvh::detail::test::active_test_syscalls->poll_revents[call_index]; + } + } + + if (result < 0) { + errno = call_index < lvh::detail::test::active_test_syscalls->poll_errors.size() ? + lvh::detail::test::active_test_syscalls->poll_errors[call_index] : + EIO; + } + return result; + } + return ::poll(descriptors, descriptor_count, timeout); +} + +std::ptrdiff_t lvh_linux_test_read(int fd, void *buffer, std::size_t size) { + if (lvh::detail::test::active_test_syscalls != nullptr && lvh::detail::test::active_test_syscalls->override_read) { + const auto call_index = static_cast(lvh::detail::test::active_test_syscalls->read_call_count++); + auto result = std::ptrdiff_t {0}; + if (call_index < lvh::detail::test::active_test_syscalls->read_results.size()) { + result = lvh::detail::test::active_test_syscalls->read_results[call_index]; + } + + if (result < 0) { + errno = call_index < lvh::detail::test::active_test_syscalls->read_errors.size() ? + lvh::detail::test::active_test_syscalls->read_errors[call_index] : + EIO; + return result; + } + + if (result > 0) { + const auto bytes = std::min(static_cast(result), std::min(size, sizeof(uhid_event))); + std::memcpy(buffer, &lvh::detail::test::active_test_syscalls->read_event, bytes); + return static_cast(bytes); + } + + return result; + } + return static_cast(::read(fd, buffer, size)); +} + +#if defined(LIBVIRTUALHID_HAVE_XTEST) +Display *lvh_linux_test_x_open_display(const char *) { + return reinterpret_cast(0x1); +} + +int lvh_linux_test_x_close_display(Display *) { + return 0; +} + +Bool lvh_linux_test_xtest_query_extension(Display *, int *, int *, int *, int *) { + return True; +} + +KeyCode lvh_linux_test_x_keysym_to_keycode(Display *, KeySym keysym) { + return keysym == NoSymbol ? 0 : 1; +} + +int lvh_linux_test_x_flush(Display *) { + return 0; +} + +int lvh_linux_test_default_screen(Display *) { + return 0; +} + +int lvh_linux_test_display_width(Display *, int) { + return 1920; +} + +int lvh_linux_test_display_height(Display *, int) { + return 1080; +} + +int lvh_linux_test_xtest_fake_key_event(Display *, unsigned int, Bool, unsigned long) { + return 1; +} + +int lvh_linux_test_xtest_fake_button_event(Display *, unsigned int, Bool, unsigned long) { + return 1; +} + +int lvh_linux_test_xtest_fake_motion_event(Display *, int, int, int, unsigned long) { + return 1; +} + +int lvh_linux_test_xtest_fake_relative_motion_event(Display *, int, int, unsigned long) { + return 1; +} +#endif + +#define access lvh_linux_test_access +#define ioctl lvh_linux_test_ioctl +#define open lvh_linux_test_open +#define poll lvh_linux_test_poll +#define read lvh_linux_test_read +#define write lvh_linux_test_write + +#if defined(LIBVIRTUALHID_HAVE_XTEST) + #define DefaultScreen lvh_linux_test_default_screen + #define DisplayHeight lvh_linux_test_display_height + #define DisplayWidth lvh_linux_test_display_width + #define XCloseDisplay lvh_linux_test_x_close_display + #define XFlush lvh_linux_test_x_flush + #define XKeysymToKeycode lvh_linux_test_x_keysym_to_keycode + #define XOpenDisplay lvh_linux_test_x_open_display + #define XTestFakeButtonEvent lvh_linux_test_xtest_fake_button_event + #define XTestFakeKeyEvent lvh_linux_test_xtest_fake_key_event + #define XTestFakeMotionEvent lvh_linux_test_xtest_fake_motion_event + #define XTestFakeRelativeMotionEvent lvh_linux_test_xtest_fake_relative_motion_event + #define XTestQueryExtension lvh_linux_test_xtest_query_extension +#endif + +#define create_platform_backend create_platform_backend_for_linux_backend_test_hooks +#include "../../src/platform/linux/uhid_backend.cpp" +#undef create_platform_backend + +#if defined(LIBVIRTUALHID_HAVE_XTEST) + #undef XTestQueryExtension + #undef XTestFakeRelativeMotionEvent + #undef XTestFakeMotionEvent + #undef XTestFakeKeyEvent + #undef XTestFakeButtonEvent + #undef XOpenDisplay + #undef XKeysymToKeycode + #undef XFlush + #undef XCloseDisplay + #undef DisplayWidth + #undef DisplayHeight + #undef DefaultScreen +#endif + +#undef write +#undef read +#undef poll +#undef open +#undef ioctl +#undef access + +namespace lvh::detail::test { + namespace { + + constexpr auto fake_fd = 100000; + + int open_test_fd() { + return ::open("/dev/null", O_RDWR); + } + + std::vector read_input_events_until_eof(int fd) { + std::vector records; + input_event event {}; + while (::read(fd, &event, sizeof(event)) == sizeof(event)) { + records.push_back({ + .type = event.type, + .code = event.code, + .value = event.value, + }); + } + return records; + } + + bool write_uhid_event(int fd, const uhid_event &event) { + auto *data = reinterpret_cast(&event); + std::size_t written = 0; + while (written < sizeof(event)) { + const auto result = ::write(fd, data + written, sizeof(event) - written); + if (result <= 0) { + return false; + } + written += static_cast(result); + } + return true; + } + + bool read_uhid_event(int fd, uhid_event &event) { + pollfd descriptor {}; + descriptor.fd = fd; + descriptor.events = POLLIN; + const auto poll_result = ::poll(&descriptor, 1, 1000); + if (poll_result <= 0 || (descriptor.revents & POLLIN) == 0) { + return false; + } + + auto *data = reinterpret_cast(&event); + std::size_t read_size = 0; + while (read_size < sizeof(event)) { + const auto result = ::read(fd, data + read_size, sizeof(event) - read_size); + if (result <= 0) { + return false; + } + read_size += static_cast(result); + } + return true; + } + + void enable_fake_device_syscalls(LinuxTestSyscalls &syscalls) { + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.override_write = true; + syscalls.override_ioctl = true; + } + + bool wait_for_poll_calls(const LinuxTestSyscalls &syscalls, int expected_calls) { + using namespace std::chrono_literals; + + for (auto attempt = 0; attempt < 100; ++attempt) { + if (syscalls.poll_call_count.load() >= expected_calls) { + return true; + } + std::this_thread::sleep_for(1ms); + } + return false; + } + + Status run_fake_uhid_read_loop(LinuxTestSyscalls &syscalls, int expected_poll_calls) { + syscalls.override_write = true; + + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + CreateGamepadOptions options; + options.profile = profiles::generic_gamepad(); + + const auto fd = open_test_fd(); + if (fd < 0) { + return system_error_status(ErrorCode::backend_failure, "failed to open test file descriptor", errno); + } + + UhidGamepad gamepad {fd}; + if (const auto status = gamepad.create(1, options); !status.ok()) { + return status; + } + + const auto saw_expected_polls = wait_for_poll_calls(syscalls, expected_poll_calls); + const auto close_status = gamepad.close(); + if (!saw_expected_polls) { + return Status::failure(ErrorCode::backend_failure, "fake UHID read loop did not consume the scripted poll calls"); + } + return close_status; + } + + } // namespace + + std::string linux_copy_string_char_buffer(const std::string &source) { + char destination[5] {}; + copy_string(destination, source); + return destination; + } + + int linux_key_code(KeyboardKeyCode key_code) { + return key_code_to_linux(key_code); + } + + int linux_mouse_button(MouseButton button) { + return mouse_button_to_linux(button); + } + + std::uint16_t linux_uhid_bus(BusType bus_type) { + return to_uhid_bus(bus_type); + } + + std::uint16_t linux_uinput_bus(BusType bus_type) { + return to_uinput_bus(bus_type); + } + + int linux_absolute_axis(std::int32_t value, std::int32_t limit) { + return scale_absolute_axis(value, limit); + } + + std::vector linux_decode_utf8(const std::string &text) { + return decode_utf8(text); + } + + std::string linux_uppercase_hex(std::uint32_t codepoint) { + return uppercase_hex(codepoint); + } + + KeyboardKeyCode linux_hex_digit_key_code(char digit) { + return hex_digit_key_code(digit); + } + + int linux_legacy_scroll_steps(std::int32_t distance) { + return legacy_scroll_steps(distance); + } + + std::size_t linux_uhid_descriptor_limit() { + uhid_event event {}; + return sizeof(event.u.create2.rd_data); + } + + std::size_t linux_uhid_input_limit() { + uhid_event event {}; + return sizeof(event.u.input2.data); + } + + Status linux_uhid_create_with_descriptor_size(std::size_t descriptor_size) { + auto profile = profiles::generic_gamepad(); + profile.report_descriptor.assign(descriptor_size, 0); + + CreateGamepadOptions options; + options.profile = std::move(profile); + + UhidGamepad gamepad {-1}; + return gamepad.create(1, options); + } + + Status linux_uhid_submit_report_size(std::size_t report_size) { + UhidGamepad gamepad {-1}; + return gamepad.submit(std::vector(report_size, 0)); + } + + Status linux_uhid_submit_after_close() { + UhidGamepad gamepad {-1}; + static_cast(gamepad.close()); + return gamepad.submit({0}); + } + + Status linux_uinput_keyboard_create_invalid_fd() { + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + + UinputKeyboard keyboard {-1}; + return keyboard.create(1, options); + } + + Status linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event) { + UinputKeyboard keyboard {-1}; + return keyboard.submit(event); + } + + Status linux_uinput_keyboard_type_text_invalid_fd(const std::string &text) { + UinputKeyboard keyboard {-1}; + return keyboard.type_text({.text = text}); + } + + Status linux_uinput_keyboard_submit_after_close() { + UinputKeyboard keyboard {-1}; + static_cast(keyboard.close()); + return keyboard.submit({.key_code = 0x41, .pressed = true}); + } + + LinuxInputSubmissionResult linux_uinput_keyboard_submit_pipe(const KeyboardEvent &event) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputKeyboard keyboard {descriptors[1]}; + auto status = keyboard.submit(event); + static_cast(keyboard.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + Status linux_uinput_user_device_invalid_fd() { + return write_uinput_user_device(-1, profiles::mouse(), 1); + } + + Status linux_uinput_user_device_pipe() { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno); + } + + auto status = write_uinput_user_device(descriptors[1], profiles::mouse(), 1); + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return status; + } + + Status linux_uinput_mouse_create_invalid_fd() { + CreateMouseOptions options; + options.profile = profiles::mouse(); + + UinputMouse mouse {-1}; + return mouse.create(1, options); + } + + Status linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event) { + UinputMouse mouse {-1}; + return mouse.submit(event); + } + + Status linux_uinput_mouse_submit_after_close() { + UinputMouse mouse {-1}; + static_cast(mouse.close()); + return mouse.submit({.kind = MouseEventKind::relative_motion, .x = 1, .y = 1}); + } + + LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputMouse mouse {descriptors[1]}; + auto status = mouse.submit(event); + static_cast(mouse.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip() { + LinuxUhidRoundTripResult result; + int descriptors[2] {-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors) != 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno); + result.submit_status = result.create_status; + result.close_status = result.create_status; + return result; + } + + auto profile = profiles::xbox_360(); + CreateGamepadOptions options; + options.profile = profile; + options.metadata.stable_id = "linux-uhid-roundtrip"; + + UhidGamepad gamepad {descriptors[0]}; + result.create_status = gamepad.create(7, options); + + uhid_event event {}; + if (read_uhid_event(descriptors[1], event)) { + result.saw_create = + event.type == UHID_CREATE2 && event.u.create2.vendor == profile.vendor_id && event.u.create2.product == profile.product_id; + } + + gamepad.set_output_callback([&result](const GamepadOutput &output) { + ++result.output_callback_count; + result.last_output = output; + }); + + event = {}; + event.type = UHID_OUTPUT; + event.u.output.size = static_cast<__u16>(profile.output_report_size); + event.u.output.data[0] = profile.report_id; + event.u.output.data[1] = 0x34; + event.u.output.data[2] = 0x12; + event.u.output.data[3] = 0x78; + event.u.output.data[4] = 0x56; + static_cast(write_uhid_event(descriptors[1], event)); + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 9; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event(descriptors[1], event)) { + result.saw_get_report_reply = event.type == UHID_GET_REPORT_REPLY && event.u.get_report_reply.id == 9; + } + + event = {}; + event.type = UHID_SET_REPORT; + event.u.set_report.id = 10; + event.u.set_report.size = static_cast<__u16>(profile.output_report_size); + event.u.set_report.data[0] = profile.report_id; + event.u.set_report.data[1] = 0x78; + event.u.set_report.data[2] = 0x56; + event.u.set_report.data[3] = 0x34; + event.u.set_report.data[4] = 0x12; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event(descriptors[1], event)) { + result.saw_set_report_reply = event.type == UHID_SET_REPORT_REPLY && event.u.set_report_reply.id == 10; + } + + event = {}; + event.type = UHID_OPEN; + static_cast(write_uhid_event(descriptors[1], event)); + + lvh::GamepadState state; + state.buttons.set(GamepadButton::a); + const auto report = reports::pack_input_report(profile, state); + result.submit_status = gamepad.submit(report); + if (read_uhid_event(descriptors[1], event)) { + result.saw_input = event.type == UHID_INPUT2 && event.u.input2.size == report.size(); + } + + result.close_status = gamepad.close(); + if (read_uhid_event(descriptors[1], event)) { + result.saw_destroy = event.type == UHID_DESTROY; + } + + static_cast(::close(descriptors[1])); + return result; + } + + LinuxBackendFakeCreationResult linux_backend_create_all_fake_success() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxBackendFakeCreationResult result; + LinuxUhidBackend backend; + result.capabilities = backend.capabilities(); + + CreateGamepadOptions gamepad_options; + gamepad_options.profile = profiles::xbox_360(); + gamepad_options.metadata.stable_id = "fake-linux-gamepad"; + auto gamepad = backend.create_gamepad(1, gamepad_options); + result.gamepad_status = gamepad.status; + if (gamepad) { + result.gamepad_close_status = gamepad.gamepad->close(); + } + + CreateKeyboardOptions keyboard_options; + keyboard_options.profile = profiles::keyboard(); + keyboard_options.stable_id = "fake-linux-keyboard"; + auto keyboard = backend.create_keyboard(2, keyboard_options); + result.keyboard_status = keyboard.status; + if (keyboard) { + result.keyboard_close_status = keyboard.keyboard->close(); + } + + CreateMouseOptions mouse_options; + mouse_options.profile = profiles::mouse(); + mouse_options.stable_id = "fake-linux-mouse"; + auto mouse = backend.create_mouse(3, mouse_options); + result.mouse_status = mouse.status; + if (mouse) { + result.mouse_close_status = mouse.mouse->close(); + } + + return result; + } + + BackendCapabilities linux_backend_fake_unavailable_capabilities() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.access_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + return backend.capabilities(); + } + + Status linux_backend_gamepad_fake_open_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateGamepadOptions options; + options.profile = profiles::xbox_360(); + return backend.create_gamepad(1, options).status; + } + + Status linux_backend_gamepad_fake_create_failure() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateGamepadOptions options; + options.profile = profiles::xbox_360(); + return backend.create_gamepad(1, options).status; + } + + Status linux_backend_keyboard_fake_open_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + return backend.create_keyboard(1, options).status; + } + + Status linux_backend_keyboard_fake_create_failure() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + return backend.create_keyboard(1, options).status; + } + + Status linux_backend_keyboard_fake_fallback_success() { +#if defined(LIBVIRTUALHID_HAVE_XTEST) + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + auto keyboard = backend.create_keyboard(1, options); + if (!keyboard) { + return keyboard.status; + } + return keyboard.keyboard->close(); +#else + return Status::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); +#endif + } + + Status linux_backend_mouse_fake_open_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_access = true; + syscalls.override_open = true; + syscalls.open_result = -1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + return backend.create_mouse(1, options).status; + } + + Status linux_backend_mouse_fake_create_failure() { + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + return backend.create_mouse(1, options).status; + } + + Status linux_backend_mouse_fake_fallback_success() { +#if defined(LIBVIRTUALHID_HAVE_XTEST) + LinuxTestSyscalls syscalls; + enable_fake_device_syscalls(syscalls); + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + LinuxUhidBackend backend; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + auto mouse = backend.create_mouse(1, options); + if (!mouse) { + return mouse.status; + } + return mouse.mouse->close(); +#else + return Status::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); +#endif + } + + Status linux_uhid_submit_fake_write_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.submit({0}); + } + + Status linux_uhid_submit_fake_short_write() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.submit({0}); + } + + Status linux_uhid_close_fake_write_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.close(); + } + + Status linux_uhid_close_fake_close_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.close(); + } + + Status linux_uhid_read_loop_fake_retry_branches() { + LinuxTestSyscalls syscalls; + syscalls.override_poll = true; + syscalls.poll_results = {-1, 0, 1, 1, 1}; + syscalls.poll_revents = {0, 0, 0, POLLIN, POLLIN}; + syscalls.poll_errors = {EINTR}; + syscalls.override_read = true; + syscalls.read_results = {-1, 0}; + syscalls.read_errors = {EAGAIN}; + return run_fake_uhid_read_loop(syscalls, 5); + } + + Status linux_uhid_read_loop_fake_poll_errors() { + LinuxTestSyscalls syscall_failure; + syscall_failure.override_poll = true; + syscall_failure.poll_results = {-1}; + syscall_failure.poll_errors = {EIO}; + if (const auto status = run_fake_uhid_read_loop(syscall_failure, 1); !status.ok()) { + return status; + } + + LinuxTestSyscalls event_failure; + event_failure.override_poll = true; + event_failure.poll_results = {1}; + event_failure.poll_revents = {POLLERR}; + return run_fake_uhid_read_loop(event_failure, 1); + } + + Status linux_uhid_read_loop_fake_read_error() { + LinuxTestSyscalls syscalls; + syscalls.override_poll = true; + syscalls.poll_results = {1}; + syscalls.poll_revents = {POLLIN}; + syscalls.override_read = true; + syscalls.read_results = {-1}; + syscalls.read_errors = {EIO}; + return run_fake_uhid_read_loop(syscalls, 1); + } + + Status linux_uhid_read_loop_fake_output_without_callback() { + LinuxTestSyscalls syscalls; + syscalls.override_poll = true; + syscalls.poll_results = {1, 1}; + syscalls.poll_revents = {POLLIN, POLLIN}; + syscalls.override_read = true; + syscalls.read_results = {static_cast(sizeof(uhid_event)), 0}; + syscalls.read_event.type = UHID_OUTPUT; + return run_fake_uhid_read_loop(syscalls, 2); + } + + Status linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.fail_ioctl_call = fail_ioctl_call; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + + UinputKeyboard keyboard {fake_fd}; + return keyboard.create(1, options); + } + + Status linux_uinput_user_device_fake_short_write() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + return write_uinput_user_device(fake_fd, profiles::mouse(), 1); + } + + Status linux_uinput_user_device_fake_create_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.fail_ioctl_call = 1; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + return write_uinput_user_device(fake_fd, profiles::mouse(), 1); + } + + Status linux_uinput_keyboard_submit_fake_write_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.submit({.key_code = 0x41, .pressed = true}); + } + + Status linux_uinput_keyboard_submit_fake_short_write() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.submit({.key_code = 0x41, .pressed = true}); + } + + Status linux_uinput_keyboard_type_text_fake_success() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.type_text({.text = "A"}); + } + + Status linux_uinput_keyboard_close_fake_close_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.close(); + } + + Status linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.override_ioctl = true; + syscalls.fail_ioctl_call = fail_ioctl_call; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + CreateMouseOptions options; + options.profile = profiles::mouse(); + + UinputMouse mouse {fake_fd}; + return mouse.create(1, options); + } + + Status linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.fail_write_call = 1; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputMouse mouse {fake_fd}; + return mouse.submit(event); + } + + Status linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event) { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + syscalls.short_write_call = 1; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputMouse mouse {fake_fd}; + return mouse.submit(event); + } + +} // namespace lvh::detail::test +#endif diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 4a56151..8147462 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -13,17 +13,23 @@ #include #endif +// lib includes +#include + // local includes #include "fixtures/fixtures.hpp" -#include - #if defined(__linux__) - #include "platform/linux/uhid_backend_test_hooks.hpp" + #include "fixtures/linux_backend_test_hooks.hpp" #endif -TEST(LinuxBackendTest, TranslatesKeyboardKeys) { +/** + * @brief Test fixture for Linux backend internals. + */ +class LinuxBackendTest: public LinuxTest {}; + #if defined(__linux__) +TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::linux_key_code(0x08), KEY_BACKSPACE); EXPECT_EQ(lvh::detail::test::linux_key_code(0x09), KEY_TAB); EXPECT_EQ(lvh::detail::test::linux_key_code(0x0D), KEY_ENTER); @@ -85,13 +91,9 @@ TEST(LinuxBackendTest, TranslatesKeyboardKeys) { EXPECT_EQ(lvh::detail::test::linux_key_code(0x87), KEY_F24); EXPECT_EQ(lvh::detail::test::linux_key_code(0), -1); EXPECT_EQ(lvh::detail::test::linux_key_code(0x88), -1); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::left), BTN_LEFT); EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::middle), BTN_MIDDLE); EXPECT_EQ(lvh::detail::test::linux_mouse_button(lvh::MouseButton::right), BTN_RIGHT); @@ -103,13 +105,9 @@ TEST(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) { EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::usb), BUS_USB); EXPECT_EQ(lvh::detail::test::linux_uhid_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); EXPECT_EQ(lvh::detail::test::linux_uinput_bus(lvh::BusType::bluetooth), BUS_BLUETOOTH); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) { EXPECT_EQ(lvh::detail::test::linux_absolute_axis(-1, 100), 0); EXPECT_EQ(lvh::detail::test::linux_absolute_axis(0, 100), 0); EXPECT_EQ(lvh::detail::test::linux_absolute_axis(50, 100), 32767); @@ -124,13 +122,9 @@ TEST(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) { EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(120), 1); EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(240), 2); EXPECT_EQ(lvh::detail::test::linux_legacy_scroll_steps(-240), -2); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, DecodesTextHelpers) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, DecodesTextHelpers) { EXPECT_EQ( lvh::detail::test::linux_decode_utf8("A\xC3\xA9\xE2\x82\xAC\xF0\x9F\x98\x80"), (std::vector {0x41, 0xE9, 0x20AC, 0x1F600}) @@ -151,13 +145,9 @@ TEST(LinuxBackendTest, DecodesTextHelpers) { EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('9'), 0x39); EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('A'), 0x41); EXPECT_EQ(lvh::detail::test::linux_hex_digit_key_code('F'), 0x46); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) { EXPECT_EQ( lvh::detail::test::linux_uhid_create_with_descriptor_size( lvh::detail::test::linux_uhid_descriptor_limit() + 1 @@ -172,13 +162,9 @@ TEST(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) { lvh::ErrorCode::invalid_argument ); EXPECT_EQ(lvh::detail::test::linux_uhid_submit_after_close().code(), lvh::ErrorCode::device_closed); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) { EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); EXPECT_EQ( lvh::detail::test::linux_uinput_keyboard_submit_invalid_fd({.key_code = 0, .pressed = true}).code(), @@ -194,13 +180,9 @@ TEST(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) { lvh::ErrorCode::device_closed ); EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_submit_after_close().code(), lvh::ErrorCode::device_closed); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { EXPECT_EQ(lvh::detail::test::linux_copy_string_char_buffer("abcdef"), "abcd"); const auto result = lvh::detail::test::linux_uinput_keyboard_submit_pipe({.key_code = 0x41, .pressed = true}); @@ -215,13 +197,9 @@ TEST(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) { EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_invalid_fd().code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_pipe().code(), lvh::ErrorCode::backend_failure); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_create_invalid_fd().code(), lvh::ErrorCode::backend_failure); lvh::MouseEvent event; @@ -261,13 +239,9 @@ TEST(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_invalid_fd(event).code(), lvh::ErrorCode::device_closed); EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_after_close().code(), lvh::ErrorCode::device_closed); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { lvh::MouseEvent event; event.kind = lvh::MouseEventKind::relative_motion; event.x = 5; @@ -343,13 +317,9 @@ TEST(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { EXPECT_EQ(result.events[0].value, -1); #endif EXPECT_EQ(result.events[1].type, EV_SYN); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { const auto result = lvh::detail::test::linux_uhid_socketpair_roundtrip(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); EXPECT_TRUE(result.submit_status.ok()) << result.submit_status.message(); @@ -363,13 +333,9 @@ TEST(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { EXPECT_EQ(result.last_output.kind, lvh::GamepadOutputKind::rumble); EXPECT_EQ(result.last_output.low_frequency_rumble, 0x5678); EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { const auto unavailable = lvh::detail::test::linux_backend_fake_unavailable_capabilities(); EXPECT_FALSE(unavailable.supports_virtual_hid); EXPECT_FALSE(unavailable.supports_gamepad); @@ -385,14 +351,16 @@ TEST(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { const auto keyboard_create_status = lvh::detail::test::linux_backend_keyboard_fake_create_failure(); EXPECT_TRUE(keyboard_create_status.ok() || keyboard_create_status.code() == lvh::ErrorCode::backend_failure); - EXPECT_TRUE(lvh::detail::test::linux_backend_keyboard_fake_fallback_success().ok()); + const auto keyboard_fallback_status = lvh::detail::test::linux_backend_keyboard_fake_fallback_success(); + EXPECT_TRUE(keyboard_fallback_status.ok() || keyboard_fallback_status.code() == lvh::ErrorCode::backend_unavailable); const auto mouse_open_status = lvh::detail::test::linux_backend_mouse_fake_open_failure(); EXPECT_TRUE(mouse_open_status.ok() || mouse_open_status.code() == lvh::ErrorCode::backend_unavailable); const auto mouse_create_status = lvh::detail::test::linux_backend_mouse_fake_create_failure(); EXPECT_TRUE(mouse_create_status.ok() || mouse_create_status.code() == lvh::ErrorCode::backend_failure); - EXPECT_TRUE(lvh::detail::test::linux_backend_mouse_fake_fallback_success().ok()); + const auto mouse_fallback_status = lvh::detail::test::linux_backend_mouse_fake_fallback_success(); + EXPECT_TRUE(mouse_fallback_status.ok() || mouse_fallback_status.code() == lvh::ErrorCode::backend_unavailable); const auto result = lvh::detail::test::linux_backend_create_all_fake_success(); EXPECT_TRUE(result.capabilities.supports_virtual_hid); @@ -406,13 +374,9 @@ TEST(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { EXPECT_TRUE(result.keyboard_close_status.ok()) << result.keyboard_close_status.message(); EXPECT_TRUE(result.mouse_status.ok()) << result.mouse_status.message(); EXPECT_TRUE(result.mouse_close_status.ok()) << result.mouse_close_status.message(); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) { EXPECT_EQ(lvh::detail::test::linux_uhid_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(lvh::detail::test::linux_uhid_submit_fake_short_write().code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(lvh::detail::test::linux_uhid_close_fake_write_failure().code(), lvh::ErrorCode::backend_failure); @@ -421,13 +385,9 @@ TEST(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) { EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_poll_errors().ok()); EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_read_error().ok()); EXPECT_TRUE(lvh::detail::test::linux_uhid_read_loop_fake_output_without_callback().ok()); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif } -TEST(LinuxBackendTest, FakeUinputSyscallsCoverFailureBranches) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, FakeUinputSyscallsCoverFailureBranches) { for (const auto fail_call : {1, 2}) { EXPECT_EQ( lvh::detail::test::linux_uinput_keyboard_create_fake_ioctl_failure(fail_call).code(), @@ -444,24 +404,32 @@ TEST(LinuxBackendTest, FakeUinputSyscallsCoverFailureBranches) { EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_fake_short_write().code(), lvh::ErrorCode::backend_failure); EXPECT_EQ(lvh::detail::test::linux_uinput_user_device_fake_create_failure().code(), lvh::ErrorCode::backend_failure); - EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_submit_fake_write_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ( + lvh::detail::test::linux_uinput_keyboard_submit_fake_write_failure().code(), + lvh::ErrorCode::backend_failure + ); EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_submit_fake_short_write().code(), lvh::ErrorCode::backend_failure); EXPECT_TRUE(lvh::detail::test::linux_uinput_keyboard_type_text_fake_success().ok()); - EXPECT_EQ(lvh::detail::test::linux_uinput_keyboard_close_fake_close_failure().code(), lvh::ErrorCode::backend_failure); + EXPECT_EQ( + lvh::detail::test::linux_uinput_keyboard_close_fake_close_failure().code(), + lvh::ErrorCode::backend_failure + ); lvh::MouseEvent event; event.kind = lvh::MouseEventKind::relative_motion; event.x = 1; event.y = 1; - EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_fake_write_failure(event).code(), lvh::ErrorCode::backend_failure); - EXPECT_EQ(lvh::detail::test::linux_uinput_mouse_submit_fake_short_write(event).code(), lvh::ErrorCode::backend_failure); -#else - GTEST_SKIP() << "Linux backend tests require Linux"; -#endif + EXPECT_EQ( + lvh::detail::test::linux_uinput_mouse_submit_fake_write_failure(event).code(), + lvh::ErrorCode::backend_failure + ); + EXPECT_EQ( + lvh::detail::test::linux_uinput_mouse_submit_fake_short_write(event).code(), + lvh::ErrorCode::backend_failure + ); } -TEST(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) { -#if defined(__linux__) +TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) { lvh::RuntimeOptions options; options.backend = lvh::BackendKind::platform_default; auto runtime = lvh::Runtime::create(options); @@ -483,7 +451,20 @@ TEST(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesA EXPECT_FALSE(mouse); EXPECT_EQ(mouse.status.code(), lvh::ErrorCode::backend_unavailable); } +} #else - GTEST_SKIP() << "Linux backend tests require Linux"; +TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) {} +TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {} +TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) {} +TEST_F(LinuxBackendTest, DecodesTextHelpers) {} +TEST_F(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) {} +TEST_F(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) {} +TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) {} +TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) {} +TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) {} +TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} +TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {} +TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) {} +TEST_F(LinuxBackendTest, FakeUinputSyscallsCoverFailureBranches) {} +TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) {} #endif -} diff --git a/tests/unit/test_linux_discovery.cpp b/tests/unit/test_linux_discovery.cpp index 8904d0a..06e3546 100644 --- a/tests/unit/test_linux_discovery.cpp +++ b/tests/unit/test_linux_discovery.cpp @@ -21,19 +21,19 @@ #include #include -// platform includes -#if defined(__linux__) - #include -#endif +// lib includes +#include // local includes #include "fixtures/fixtures.hpp" -#include +/** + * @brief Test fixture for Linux virtual device discovery. + */ +class LinuxDiscoveryTest: public LinuxTest {}; namespace { -#if defined(__linux__) struct DiscoveryProbe { std::string name; std::string executable; @@ -66,8 +66,7 @@ namespace { CommandResult run_command(const DiscoveryProbe &probe, std::size_t index) { const auto output_path = std::filesystem::temp_directory_path() / - ("libvirtualhid-discovery-test-" + std::to_string(::getpid()) + "-" + - std::to_string(index) + ".txt"); + ("libvirtualhid-discovery-test-" + std::to_string(index) + ".txt"); const auto command = probe.command + " > \"" + output_path.string() + "\" 2>&1"; CommandResult result; result.exit_code = std::system(command.c_str()); @@ -117,23 +116,30 @@ namespace { << result.output << '\n'; return false; } -#endif } // namespace -TEST(LinuxDiscoveryTest, SdlOrHidapiCanDiscoverUhidGamepadWhenAvailable) { -#if defined(__linux__) - const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS"); - if (enabled == nullptr || std::string_view {enabled} != "1") { - GTEST_SKIP() << "set LIBVIRTUALHID_ENABLE_DISCOVERY_INTEGRATION_TESTS=1 to validate SDL/HIDAPI discovery"; +TEST_F(LinuxDiscoveryTest, SdlOrHidapiDiscoveryRequiresPrerequisites) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); + + const auto probes = discovery_probes(); + std::vector available_probe_indices; + for (std::size_t index = 0; index < probes.size(); ++index) { + const auto &probe = probes[index]; + if (!command_available(probe.executable)) { + std::cout << probe.executable << " is not installed; " << probe.name << " is unavailable" << '\n'; + continue; + } + + available_probe_indices.push_back(index); } + ASSERT_FALSE(available_probe_indices.empty()) << "install sdl2-jstest or hidapitester to validate external discovery"; + lvh::RuntimeOptions runtime_options; runtime_options.backend = lvh::BackendKind::platform_default; auto runtime = lvh::Runtime::create(runtime_options); - if (!runtime->capabilities().supports_gamepad) { - GTEST_SKIP() << "/dev/uhid is not accessible"; - } + ASSERT_TRUE(runtime->capabilities().supports_gamepad); lvh::CreateGamepadOptions options; options.profile = lvh::profiles::generic_gamepad(); @@ -147,23 +153,8 @@ TEST(LinuxDiscoveryTest, SdlOrHidapiCanDiscoverUhidGamepadWhenAvailable) { state.left_stick = {0.25F, -0.25F}; ASSERT_TRUE(created.gamepad->submit(state).ok()); - std::size_t available_probe_count = 0; - const auto probes = discovery_probes(); - for (std::size_t index = 0; index < probes.size(); ++index) { + for (const auto index : available_probe_indices) { const auto &probe = probes[index]; - if (!command_available(probe.executable)) { - std::cout << probe.executable << " is not installed; skipping " << probe.name << '\n'; - continue; - } - - ++available_probe_count; EXPECT_TRUE(wait_for_probe(probe, options.profile, index)); } - - if (available_probe_count == 0) { - GTEST_SKIP() << "install sdl2-jstest or hidapitester to validate external discovery"; - } -#else - GTEST_SKIP() << "SDL/HIDAPI discovery validation requires Linux UHID"; -#endif } diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 2dd23af..945ef00 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -3,14 +3,16 @@ * @brief Unit tests for runtime and virtual gamepad handles. */ -// standard includes -#include -#include +// lib includes +#include // local includes #include "fixtures/fixtures.hpp" -#include +/** + * @brief Test fixture for Linux runtime integration tests. + */ +class LinuxRuntimeTest: public LinuxTest {}; TEST(RuntimeTest, FakeBackendReportsCapabilities) { auto runtime = lvh::Runtime::create(); @@ -149,19 +151,13 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesMouse) { EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); } -TEST(RuntimeTest, LinuxUhidSmokeTestWhenExplicitlyEnabled) { -#if defined(__linux__) - const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS"); - if (enabled == nullptr || std::string_view {enabled} != "1") { - GTEST_SKIP() << "set LIBVIRTUALHID_ENABLE_UHID_INTEGRATION_TESTS=1 to exercise /dev/uhid"; - } +TEST_F(LinuxRuntimeTest, LinuxUhidSmokeTestRequiresPrerequisites) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); lvh::RuntimeOptions options; options.backend = lvh::BackendKind::platform_default; auto runtime = lvh::Runtime::create(options); - if (!runtime->capabilities().supports_gamepad) { - GTEST_SKIP() << "/dev/uhid is not accessible"; - } + ASSERT_TRUE(runtime->capabilities().supports_gamepad); auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); ASSERT_TRUE(created) << created.status.message(); @@ -173,35 +169,25 @@ TEST(RuntimeTest, LinuxUhidSmokeTestWhenExplicitlyEnabled) { EXPECT_TRUE(created.gamepad->submit(state).ok()); EXPECT_TRUE(created.gamepad->close().ok()); -#else - GTEST_SKIP() << "UHID is only available on Linux"; -#endif } -TEST(RuntimeTest, LinuxUinputSmokeTestWhenExplicitlyEnabled) { -#if defined(__linux__) - const auto *enabled = std::getenv("LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS"); - if (enabled == nullptr || std::string_view {enabled} != "1") { - GTEST_SKIP() << "set LIBVIRTUALHID_ENABLE_UINPUT_INTEGRATION_TESTS=1 to exercise /dev/uinput"; - } +TEST_F(LinuxRuntimeTest, LinuxUinputSmokeTestRequiresPrerequisites) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); lvh::RuntimeOptions options; options.backend = lvh::BackendKind::platform_default; auto runtime = lvh::Runtime::create(options); - if (!runtime->capabilities().supports_keyboard || !runtime->capabilities().supports_mouse) { - GTEST_SKIP() << "/dev/uinput or XTest fallback is not accessible"; - } + const auto &capabilities = runtime->capabilities(); + ASSERT_TRUE(capabilities.supports_keyboard); auto keyboard = runtime->create_keyboard(); ASSERT_TRUE(keyboard) << keyboard.status.message(); EXPECT_TRUE(keyboard.keyboard->press(0x41).ok()); EXPECT_TRUE(keyboard.keyboard->release(0x41).ok()); + ASSERT_TRUE(capabilities.supports_mouse); auto mouse = runtime->create_mouse(); ASSERT_TRUE(mouse) << mouse.status.message(); EXPECT_TRUE(mouse.mouse->move_relative(1, 1).ok()); EXPECT_TRUE(mouse.mouse->vertical_scroll(120).ok()); -#else - GTEST_SKIP() << "uinput is only available on Linux"; -#endif } From 21c983a7e96e35db089fdf59c9c2549c0067145b Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:39:51 -0400 Subject: [PATCH 23/28] Replace sdl2-jstest with evdev-joystick Replace the SDL2-based discovery probe with an evdev-based tool: update examples and unit tests to use evdev-joystick (--listdevs) instead of sdl2-jstest, and generalize the test name to ExternalDiscoveryRequiresPrerequisites. Update CI to install the joystick package and remove sdl2-jstest from the apt list. Update user-facing messages to mention "joystick" instead of "sdl2-jstest". --- .github/workflows/ci.yml | 9 +++++++-- README.md | 9 +++++---- examples/linux_discovery_probe.cpp | 4 ++-- tests/unit/test_linux_discovery.cpp | 7 ++++--- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22db30a..9423f2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,11 +78,16 @@ jobs: build-essential \ clang \ cmake \ + joystick \ libx11-dev \ libxtst-dev \ llvm \ - ninja-build \ - sdl2-jstest + ninja-build + sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF' + KERNEL=="hidraw*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" + SUBSYSTEMS=="input", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" + EOF + sudo udevadm control --reload-rules sudo modprobe uhid sudo modprobe uinput sudo chmod a+rw /dev/uhid /dev/uinput diff --git a/README.md b/README.md index 222649d..28bdb70 100644 --- a/README.md +++ b/README.md @@ -155,10 +155,11 @@ when the current user cannot open `/dev/uinput`. The Linux discovery integration test creates a real UHID gamepad and probes external input discovery tools. It fails when `/dev/uhid` is unavailable, or -when neither `sdl2-jstest` nor `hidapitester` is installed. +when neither `evdev-joystick` from the `joystick` package nor `hidapitester` is +installed. When `BUILD_EXAMPLES` is enabled on Linux, the `linux_discovery_probe` example -creates a generic UHID gamepad and performs the same SDL/HIDAPI discovery probe +creates a generic UHID gamepad and performs the same external discovery probe outside the test runner. The XTest fallback should not be treated as a gamepad backend. It can cover @@ -325,8 +326,8 @@ third-party/googletest/ GoogleTest submodule - [x] Add `uinput` support for keyboard and mouse once the gamepad path is stable. - [x] Support output report callbacks for rumble and profile-specific feedback. - [x] Add X11/XTest fallback support for keyboard and mouse only. -- [x] Add examples and integration tests that validate SDL/HIDAPI discovery where - available. +- [x] Add examples and integration tests that validate external gamepad + discovery where available. - [x] Document required Linux permissions and sample udev rules. ### Phase 3: Windows MVP diff --git a/examples/linux_discovery_probe.cpp b/examples/linux_discovery_probe.cpp index 36641ef..f9c04d3 100644 --- a/examples/linux_discovery_probe.cpp +++ b/examples/linux_discovery_probe.cpp @@ -42,7 +42,7 @@ namespace { std::vector discovery_probes() { return { - {"SDL2 joystick list", "sdl2-jstest", "sdl2-jstest --list"}, + {"evdev joystick list", "evdev-joystick", "evdev-joystick --listdevs"}, {"HIDAPI device list", "hidapitester", "hidapitester --list"}, }; } @@ -160,7 +160,7 @@ int main() { } if (available_probe_count == 0) { - std::cout << "Install sdl2-jstest or hidapitester to validate external discovery." << '\n'; + std::cout << "Install joystick or hidapitester to validate external discovery." << '\n'; return 0; } diff --git a/tests/unit/test_linux_discovery.cpp b/tests/unit/test_linux_discovery.cpp index 06e3546..8a79de8 100644 --- a/tests/unit/test_linux_discovery.cpp +++ b/tests/unit/test_linux_discovery.cpp @@ -47,7 +47,7 @@ namespace { std::vector discovery_probes() { return { - {"SDL2 joystick list", "sdl2-jstest", "sdl2-jstest --list"}, + {"evdev joystick list", "evdev-joystick", "evdev-joystick --listdevs"}, {"HIDAPI device list", "hidapitester", "hidapitester --list"}, }; } @@ -119,7 +119,7 @@ namespace { } // namespace -TEST_F(LinuxDiscoveryTest, SdlOrHidapiDiscoveryRequiresPrerequisites) { +TEST_F(LinuxDiscoveryTest, ExternalDiscoveryRequiresPrerequisites) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); const auto probes = discovery_probes(); @@ -134,7 +134,8 @@ TEST_F(LinuxDiscoveryTest, SdlOrHidapiDiscoveryRequiresPrerequisites) { available_probe_indices.push_back(index); } - ASSERT_FALSE(available_probe_indices.empty()) << "install sdl2-jstest or hidapitester to validate external discovery"; + ASSERT_FALSE(available_probe_indices.empty()) + << "install joystick or hidapitester to validate external discovery"; lvh::RuntimeOptions runtime_options; runtime_options.backend = lvh::BackendKind::platform_default; From f998905d4e725cdef999b5627a05b88d1f364c3e Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:59:45 -0400 Subject: [PATCH 24/28] Add Linux consumer tests; remove discovery probe Replace external discovery-based tests with in-process consumer integration tests that validate virtual devices via SDL2 and libinput. Added tests/unit/test_linux_consumers.cpp and updated tests/CMakeLists.txt to use pkg-config and link PkgConfig::LIBINPUT and PkgConfig::SDL2. Removed the linux_discovery_probe example and the legacy tests/unit/test_linux_discovery.cpp, and cleaned up examples/CMakeLists.txt. Updated README to document the new Linux test requirements (SDL2, libinput, device nodes, X11/XTest where applicable) and adjusted CI (.github/workflows/ci.yml) to install libinput-dev, libsdl2-dev and pkg-config instead of the old joystick package. --- .github/workflows/ci.yml | 29 +- README.md | 23 +- examples/CMakeLists.txt | 9 - examples/linux_discovery_probe.cpp | 168 -------- src/platform/linux/uhid_backend.cpp | 5 + tests/CMakeLists.txt | 13 +- tests/fixtures/linux_backend_test_hooks.cpp | 111 ++--- tests/unit/test_linux_backend.cpp | 13 + tests/unit/test_linux_consumers.cpp | 445 ++++++++++++++++++++ tests/unit/test_linux_discovery.cpp | 161 ------- 10 files changed, 567 insertions(+), 410 deletions(-) delete mode 100644 examples/linux_discovery_probe.cpp create mode 100644 tests/unit/test_linux_consumers.cpp delete mode 100644 tests/unit/test_linux_discovery.cpp diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9423f2a..b0dc0b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,19 +78,38 @@ jobs: build-essential \ clang \ cmake \ - joystick \ + libinput-dev \ + libsdl2-dev \ libx11-dev \ libxtst-dev \ llvm \ - ninja-build + ninja-build \ + pkg-config + kernel_modules_package="linux-modules-extra-$(uname -r)" + if apt-cache show "${kernel_modules_package}" >/dev/null 2>&1; then + sudo apt-get install -y "${kernel_modules_package}" + else + echo "::warning::${kernel_modules_package} is unavailable; relying on the runner image kernel modules." + fi sudo tee /etc/udev/rules.d/99-libvirtualhid-ci.rules >/dev/null <<'EOF' KERNEL=="hidraw*", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" SUBSYSTEMS=="input", ATTRS{name}=="libvirtualhid*", MODE="0666", TAG+="uaccess" EOF sudo udevadm control --reload-rules - sudo modprobe uhid - sudo modprobe uinput - sudo chmod a+rw /dev/uhid /dev/uinput + for module in uhid uinput; do + if ! sudo modprobe "${module}"; then + message="Unable to load ${module}; tests requiring /dev/${module} will fail unless the device already exists." + echo "::warning::${message}" + fi + done + for node in /dev/uhid /dev/uinput; do + if [[ -e "${node}" ]]; then + sudo chmod a+rw "${node}" + else + echo "::error::${node} does not exist after module setup." + exit 1 + fi + done - name: Setup Dependencies macOS if: runner.os == 'macOS' diff --git a/README.md b/README.md index 28bdb70..cf97d09 100644 --- a/README.md +++ b/README.md @@ -153,14 +153,11 @@ current user cannot open `/dev/uhid`. The Linux uinput smoke test creates real keyboard and mouse devices and fails when the current user cannot open `/dev/uinput`. -The Linux discovery integration test creates a real UHID gamepad and probes -external input discovery tools. It fails when `/dev/uhid` is unavailable, or -when neither `evdev-joystick` from the `joystick` package nor `hidapitester` is -installed. - -When `BUILD_EXAMPLES` is enabled on Linux, the `linux_discovery_probe` example -creates a generic UHID gamepad and performs the same external discovery probe -outside the test runner. +The Linux consumer integration tests create real virtual devices and validate +them through in-process consumer libraries. SDL2 must see the UHID gamepad and +observe button/axis input. libinput must see the uinput keyboard and mouse and +observe key, pointer motion, and button events. These tests fail when the Linux +device nodes or consumer development libraries are unavailable. The XTest fallback should not be treated as a gamepad backend. It can cover keyboard and mouse injection on X11, but it does not create virtual HID devices, @@ -273,8 +270,8 @@ the requirements expressed in terms that apply to other consumers: - [x] Keep the public headers under `include/libvirtualhid` and the implementation split into shared core code plus platform-specific backends. - [x] Add Windows CI coverage for the client library with MSVC and MinGW/UCRT64. -- [x] Add Linux CI coverage for GCC and Clang, with integration tests gated behind - explicit availability of `/dev/uinput`, `/dev/uhid`, or X11/XTest. +- [x] Add Linux CI coverage for GCC and Clang, with integration tests requiring + `/dev/uinput`, `/dev/uhid`, SDL2, libinput, and X11/XTest where applicable. - [ ] Add separate WDK/MSVC validation for the driver package once driver sources exist. @@ -326,8 +323,8 @@ third-party/googletest/ GoogleTest submodule - [x] Add `uinput` support for keyboard and mouse once the gamepad path is stable. - [x] Support output report callbacks for rumble and profile-specific feedback. - [x] Add X11/XTest fallback support for keyboard and mouse only. -- [x] Add examples and integration tests that validate external gamepad - discovery where available. +- [x] Add examples and integration tests that validate virtual device visibility + through SDL2 for gamepads and libinput for keyboard/mouse. - [x] Document required Linux permissions and sample udev rules. ### Phase 3: Windows MVP @@ -368,7 +365,7 @@ third-party/googletest/ GoogleTest submodule - [ ] Validate multi-controller behavior and stable ordering. - [ ] Test against real consumers where practical: Sunshine, SDL, HIDAPI, browser Gamepad API, DirectInput/XInput/GameInput on Windows, and evdev/libinput - tooling on Linux. + libraries on Linux. ## License diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 2319a86..b560a9f 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -14,12 +14,3 @@ target_link_libraries(keyboard_mouse_adapter libvirtualhid_copy_mingw_runtime(gamepad_adapter) libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) - -if(CMAKE_SYSTEM_NAME STREQUAL "Linux") - add_executable(linux_discovery_probe - "${CMAKE_CURRENT_SOURCE_DIR}/linux_discovery_probe.cpp") - - target_link_libraries(linux_discovery_probe - PRIVATE - libvirtualhid::libvirtualhid) -endif() diff --git a/examples/linux_discovery_probe.cpp b/examples/linux_discovery_probe.cpp deleted file mode 100644 index f9c04d3..0000000 --- a/examples/linux_discovery_probe.cpp +++ /dev/null @@ -1,168 +0,0 @@ -/** - * @file examples/linux_discovery_probe.cpp - * @brief Linux virtual gamepad discovery probe for external input libraries. - */ - -// standard includes -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// platform includes -#include - -// local includes -#include - -namespace { - - struct Probe { - std::string name; - std::string executable; - std::string command; - }; - - struct CommandResult { - int exit_code = 1; - std::string output; - }; - - std::vector discovery_probes() { - return { - {"evdev joystick list", "evdev-joystick", "evdev-joystick --listdevs"}, - {"HIDAPI device list", "hidapitester", "hidapitester --list"}, - }; - } - - bool command_available(std::string_view executable) { - const auto command = "command -v " + std::string {executable} + " >/dev/null 2>&1"; - return std::system(command.c_str()) == 0; - } - - std::string read_file(const std::filesystem::path &path) { - std::ifstream file {path}; - std::ostringstream stream; - stream << file.rdbuf(); - return stream.str(); - } - - CommandResult run_command(const Probe &probe, std::size_t index) { - const auto output_path = std::filesystem::temp_directory_path() / - ("libvirtualhid-discovery-" + std::to_string(::getpid()) + "-" + - std::to_string(index) + ".txt"); - const auto command = probe.command + " > \"" + output_path.string() + "\" 2>&1"; - CommandResult result; - result.exit_code = std::system(command.c_str()); - result.output = read_file(output_path); - std::error_code error; - std::filesystem::remove(output_path, error); - return result; - } - - std::string lowercase(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { - return static_cast(std::tolower(character)); - }); - return value; - } - - std::string hex4(std::uint16_t value) { - std::ostringstream stream; - stream << std::hex << std::nouppercase << std::setw(4) << std::setfill('0') << value; - return stream.str(); - } - - bool output_matches_profile(std::string_view output, const lvh::DeviceProfile &profile) { - const auto lower_output = lowercase(std::string {output}); - const auto lower_name = lowercase(profile.name); - const auto vendor = hex4(profile.vendor_id); - const auto product = hex4(profile.product_id); - - return lower_output.find(lower_name) != std::string::npos || - lower_output.find(vendor + ":" + product) != std::string::npos || - lower_output.find(vendor + "/" + product) != std::string::npos; - } - - bool wait_for_probe(const Probe &probe, const lvh::DeviceProfile &profile, std::size_t index) { - CommandResult result; - for (auto attempt = 0; attempt < 12; ++attempt) { - result = run_command(probe, index); - if (result.exit_code == 0 && output_matches_profile(result.output, profile)) { - std::cout << probe.name << " discovered " << profile.name << '\n'; - return true; - } - - std::this_thread::sleep_for(std::chrono::milliseconds {250}); - } - - std::cerr << probe.name << " did not discover " << profile.name << '\n' - << "Command output:" << '\n' - << result.output << '\n'; - return false; - } - -} // namespace - -int main() { - lvh::RuntimeOptions runtime_options; - runtime_options.backend = lvh::BackendKind::platform_default; - auto runtime = lvh::Runtime::create(runtime_options); - if (!runtime->capabilities().supports_gamepad) { - std::cerr << "/dev/uhid is not accessible; install udev rules or run with sufficient permissions" << '\n'; - return 1; - } - - lvh::CreateGamepadOptions options; - options.profile = lvh::profiles::generic_gamepad(); - options.metadata.stable_id = "libvirtualhid-discovery-probe"; - - auto created = runtime->create_gamepad(options); - if (!created) { - std::cerr << created.status.message() << '\n'; - return 1; - } - - lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.left_stick = {0.25F, -0.25F}; - if (const auto status = created.gamepad->submit(state); !status.ok()) { - std::cerr << status.message() << '\n'; - return 1; - } - - std::size_t available_probe_count = 0; - std::size_t discovered_probe_count = 0; - const auto probes = discovery_probes(); - for (std::size_t index = 0; index < probes.size(); ++index) { - const auto &probe = probes[index]; - if (!command_available(probe.executable)) { - std::cout << probe.executable << " is not installed; skipping " << probe.name << '\n'; - continue; - } - - ++available_probe_count; - if (wait_for_probe(probe, options.profile, index)) { - ++discovered_probe_count; - } - } - - if (available_probe_count == 0) { - std::cout << "Install joystick or hidapitester to validate external discovery." << '\n'; - return 0; - } - - return discovered_probe_count == available_probe_count ? 0 : 1; -} diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 91f7e65..39771f1 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -39,6 +39,11 @@ #include #include #include + + // Xlib defines Status as a macro, which collides with lvh::Status. + #if defined(Status) + #undef Status + #endif #endif // local includes diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 420521f..30ce703 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,12 +18,16 @@ set(LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/fixtures.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_gamepad_lifecycle.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_backend.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_discovery.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_linux_consumers.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") if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + find_package(PkgConfig REQUIRED) + pkg_check_modules(LIBINPUT REQUIRED IMPORTED_TARGET libinput) + pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) + list(APPEND LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/linux_backend_test_hooks.cpp") @@ -45,6 +49,13 @@ target_link_libraries(${TEST_BINARY} gmock_main libvirtualhid::libvirtualhid) +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + target_link_libraries(${TEST_BINARY} + PRIVATE + PkgConfig::LIBINPUT + PkgConfig::SDL2) +endif() + if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND LIBVIRTUALHID_ENABLE_XTEST AND X11_FOUND AND X11_XTest_FOUND) target_compile_definitions(${TEST_BINARY} PRIVATE diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index fb2e49b..84fd76b 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -31,6 +31,11 @@ #if defined(LIBVIRTUALHID_HAVE_XTEST) #include #include + + // Xlib defines Status as a macro, which collides with lvh::Status declarations. + #if defined(Status) + #undef Status + #endif #endif #endif @@ -194,7 +199,7 @@ std::ptrdiff_t lvh_linux_test_read(int fd, void *buffer, std::size_t size) { return static_cast(::read(fd, buffer, size)); } -#if defined(LIBVIRTUALHID_HAVE_XTEST) + #if defined(LIBVIRTUALHID_HAVE_XTEST) Display *lvh_linux_test_x_open_display(const char *) { return reinterpret_cast(0x1); } @@ -242,55 +247,55 @@ int lvh_linux_test_xtest_fake_motion_event(Display *, int, int, int, unsigned lo int lvh_linux_test_xtest_fake_relative_motion_event(Display *, int, int, unsigned long) { return 1; } -#endif + #endif -#define access lvh_linux_test_access -#define ioctl lvh_linux_test_ioctl -#define open lvh_linux_test_open -#define poll lvh_linux_test_poll -#define read lvh_linux_test_read -#define write lvh_linux_test_write - -#if defined(LIBVIRTUALHID_HAVE_XTEST) - #define DefaultScreen lvh_linux_test_default_screen - #define DisplayHeight lvh_linux_test_display_height - #define DisplayWidth lvh_linux_test_display_width - #define XCloseDisplay lvh_linux_test_x_close_display - #define XFlush lvh_linux_test_x_flush - #define XKeysymToKeycode lvh_linux_test_x_keysym_to_keycode - #define XOpenDisplay lvh_linux_test_x_open_display - #define XTestFakeButtonEvent lvh_linux_test_xtest_fake_button_event - #define XTestFakeKeyEvent lvh_linux_test_xtest_fake_key_event - #define XTestFakeMotionEvent lvh_linux_test_xtest_fake_motion_event - #define XTestFakeRelativeMotionEvent lvh_linux_test_xtest_fake_relative_motion_event - #define XTestQueryExtension lvh_linux_test_xtest_query_extension -#endif + #define access lvh_linux_test_access + #define ioctl lvh_linux_test_ioctl + #define open lvh_linux_test_open + #define poll lvh_linux_test_poll + #define read lvh_linux_test_read + #define write lvh_linux_test_write -#define create_platform_backend create_platform_backend_for_linux_backend_test_hooks -#include "../../src/platform/linux/uhid_backend.cpp" -#undef create_platform_backend - -#if defined(LIBVIRTUALHID_HAVE_XTEST) - #undef XTestQueryExtension - #undef XTestFakeRelativeMotionEvent - #undef XTestFakeMotionEvent - #undef XTestFakeKeyEvent - #undef XTestFakeButtonEvent - #undef XOpenDisplay - #undef XKeysymToKeycode - #undef XFlush - #undef XCloseDisplay - #undef DisplayWidth - #undef DisplayHeight - #undef DefaultScreen -#endif + #if defined(LIBVIRTUALHID_HAVE_XTEST) + #define DefaultScreen lvh_linux_test_default_screen + #define DisplayHeight lvh_linux_test_display_height + #define DisplayWidth lvh_linux_test_display_width + #define XCloseDisplay lvh_linux_test_x_close_display + #define XFlush lvh_linux_test_x_flush + #define XKeysymToKeycode lvh_linux_test_x_keysym_to_keycode + #define XOpenDisplay lvh_linux_test_x_open_display + #define XTestFakeButtonEvent lvh_linux_test_xtest_fake_button_event + #define XTestFakeKeyEvent lvh_linux_test_xtest_fake_key_event + #define XTestFakeMotionEvent lvh_linux_test_xtest_fake_motion_event + #define XTestFakeRelativeMotionEvent lvh_linux_test_xtest_fake_relative_motion_event + #define XTestQueryExtension lvh_linux_test_xtest_query_extension + #endif + + #define create_platform_backend create_platform_backend_for_linux_backend_test_hooks + #include "../../src/platform/linux/uhid_backend.cpp" + #undef create_platform_backend -#undef write -#undef read -#undef poll -#undef open -#undef ioctl -#undef access + #if defined(LIBVIRTUALHID_HAVE_XTEST) + #undef XTestQueryExtension + #undef XTestFakeRelativeMotionEvent + #undef XTestFakeMotionEvent + #undef XTestFakeKeyEvent + #undef XTestFakeButtonEvent + #undef XOpenDisplay + #undef XKeysymToKeycode + #undef XFlush + #undef XCloseDisplay + #undef DisplayWidth + #undef DisplayHeight + #undef DefaultScreen + #endif + + #undef write + #undef read + #undef poll + #undef open + #undef ioctl + #undef access namespace lvh::detail::test { namespace { @@ -742,7 +747,7 @@ namespace lvh::detail::test { } Status linux_backend_keyboard_fake_fallback_success() { -#if defined(LIBVIRTUALHID_HAVE_XTEST) + #if defined(LIBVIRTUALHID_HAVE_XTEST) LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.fail_ioctl_call = 1; @@ -757,9 +762,9 @@ namespace lvh::detail::test { return keyboard.status; } return keyboard.keyboard->close(); -#else + #else return Status::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); -#endif + #endif } Status linux_backend_mouse_fake_open_failure() { @@ -790,7 +795,7 @@ namespace lvh::detail::test { } Status linux_backend_mouse_fake_fallback_success() { -#if defined(LIBVIRTUALHID_HAVE_XTEST) + #if defined(LIBVIRTUALHID_HAVE_XTEST) LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.fail_ioctl_call = 1; @@ -805,9 +810,9 @@ namespace lvh::detail::test { return mouse.status; } return mouse.mouse->close(); -#else + #else return Status::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); -#endif + #endif } Status linux_uhid_submit_fake_write_failure() { diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 8147462..ab7475a 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -454,17 +454,30 @@ TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNode } #else TEST_F(LinuxBackendTest, TranslatesKeyboardKeys) {} + TEST_F(LinuxBackendTest, TranslatesMouseButtonsAndBusTypes) {} + TEST_F(LinuxBackendTest, ScalesAbsoluteAxesAndScrollSteps) {} + TEST_F(LinuxBackendTest, DecodesTextHelpers) {} + TEST_F(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) {} + TEST_F(LinuxBackendTest, HandlesUinputKeyboardInvalidFileDescriptorPaths) {} + TEST_F(LinuxBackendTest, PipeBackedUinputKeyboardEmitsEvents) {} + TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) {} + TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) {} + TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} + TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {} + TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) {} + TEST_F(LinuxBackendTest, FakeUinputSyscallsCoverFailureBranches) {} + TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) {} #endif diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp new file mode 100644 index 0000000..772c445 --- /dev/null +++ b/tests/unit/test_linux_consumers.cpp @@ -0,0 +1,445 @@ +/** + * @file tests/unit/test_linux_consumers.cpp + * @brief Linux integration tests for SDL2 and libinput consumers. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#if defined(__linux__) + #include + #include + #include + #include +#endif + +// lib includes +#if defined(__linux__) + #include + #include +#endif +#include + +// local includes +#include "fixtures/fixtures.hpp" + +/** + * @brief Test fixture for Linux consumer input libraries. + */ +class LinuxConsumerTest: public LinuxTest {}; + +namespace { + +#if defined(__linux__) + using LibinputContext = std::unique_ptr; + using LibinputEvent = std::unique_ptr; + using SdlJoystick = std::unique_ptr; + + /** + * @brief Execute cleanup code when a scope exits. + */ + class ScopeExit { + public: + /** + * @brief Construct a scope-exit guard. + * + * @param function Cleanup function. + */ + explicit ScopeExit(std::function function): + function_ {std::move(function)} {} + + /** + * @brief Execute the cleanup function. + */ + ~ScopeExit() { + function_(); + } + + ScopeExit(const ScopeExit &) = delete; + ScopeExit &operator=(const ScopeExit &) = delete; + ScopeExit(ScopeExit &&) noexcept = delete; + ScopeExit &operator=(ScopeExit &&) noexcept = delete; + + private: + std::function function_; + }; + + std::string unique_device_name(std::string_view suffix) { + return "libvirtualhid " + std::string {suffix} + " " + std::to_string(::getpid()); + } + + std::optional read_first_line(const std::filesystem::path &path) { + std::ifstream file {path}; + if (!file) { + return std::nullopt; + } + + std::string line; + std::getline(file, line); + return line; + } + + std::vector input_event_nodes_named(std::string_view name) { + std::vector nodes; + + std::error_code error; + const std::filesystem::path sysfs_input {"/sys/class/input"}; + if (!std::filesystem::exists(sysfs_input, error)) { + return nodes; + } + + for (std::filesystem::directory_iterator it {sysfs_input, error}, end; !error && it != end; it.increment(error)) { + const auto filename = it->path().filename().string(); + if (!filename.starts_with("event")) { + continue; + } + + const auto sysfs_name = read_first_line(it->path() / "device" / "name"); + if (sysfs_name && *sysfs_name == name) { + nodes.emplace_back(std::filesystem::path {"/dev/input"} / filename); + } + } + + return nodes; + } + + std::optional wait_for_readable_event_node(std::string_view name) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + + while (std::chrono::steady_clock::now() < deadline) { + for (const auto &node : input_event_nodes_named(name)) { + const auto node_string = node.string(); + if (::access(node_string.c_str(), R_OK) == 0) { + return node; + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + + return std::nullopt; + } + + bool sdl_joystick_matches_profile(int index, const lvh::DeviceProfile &profile) { + const auto *name = SDL_JoystickNameForIndex(index); + if (name != nullptr && profile.name == name) { + return true; + } + + const auto vendor_id = SDL_JoystickGetDeviceVendor(index); + const auto product_id = SDL_JoystickGetDeviceProduct(index); + return vendor_id == profile.vendor_id && product_id == profile.product_id; + } + + void pump_sdl_events() { + SDL_JoystickUpdate(); + + SDL_Event event; + while (SDL_PollEvent(&event) != 0) { + std::cout << "SDL event type: " << event.type << '\n'; + } + } + + void dump_sdl_joysticks() { + const auto joystick_count = SDL_NumJoysticks(); + std::cout << "SDL joystick count: " << joystick_count << '\n'; + for (int index = 0; index < joystick_count; ++index) { + const auto *name = SDL_JoystickNameForIndex(index); + std::cout << "SDL joystick[" << index << "]: " << (name == nullptr ? "" : name) + << " vendor=" << SDL_JoystickGetDeviceVendor(index) + << " product=" << SDL_JoystickGetDeviceProduct(index) << '\n'; + } + } + + int wait_for_sdl_joystick(const lvh::DeviceProfile &profile) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + + while (std::chrono::steady_clock::now() < deadline) { + pump_sdl_events(); + + const auto joystick_count = SDL_NumJoysticks(); + for (int index = 0; index < joystick_count; ++index) { + if (sdl_joystick_matches_profile(index, profile)) { + return index; + } + } + + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + + dump_sdl_joysticks(); + return -1; + } + + std::string describe_sdl_state(SDL_Joystick *joystick) { + std::ostringstream stream; + stream << "buttons=" << SDL_JoystickNumButtons(joystick) << " axes=" << SDL_JoystickNumAxes(joystick); + + for (int button = 0; button < SDL_JoystickNumButtons(joystick); ++button) { + stream << " button[" << button << "]=" << static_cast(SDL_JoystickGetButton(joystick, button)); + } + + for (int axis = 0; axis < SDL_JoystickNumAxes(joystick); ++axis) { + stream << " axis[" << axis << "]=" << SDL_JoystickGetAxis(joystick, axis); + } + + return stream.str(); + } + + bool wait_for_sdl_gamepad_input(SDL_Joystick *joystick) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + + while (std::chrono::steady_clock::now() < deadline) { + pump_sdl_events(); + + const auto button_pressed = SDL_JoystickNumButtons(joystick) > 0 && SDL_JoystickGetButton(joystick, 0) != 0; + bool axis_moved = false; + for (int axis = 0; axis < SDL_JoystickNumAxes(joystick); ++axis) { + if (std::abs(static_cast(SDL_JoystickGetAxis(joystick, axis))) > 8000) { + axis_moved = true; + break; + } + } + + if (button_pressed && axis_moved) { + return true; + } + + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + + return false; + } + + void destroy_libinput_event(libinput_event *event) { + if (event != nullptr) { + libinput_event_destroy(event); + } + } + + void unref_libinput(libinput *context) { + if (context != nullptr) { + static_cast(libinput_unref(context)); + } + } + + int open_restricted(const char *path, int flags, void *user_data) { + static_cast(user_data); + + const auto fd = ::open(path, flags); + return fd < 0 ? -errno : fd; + } + + void close_restricted(int fd, void *user_data) { + static_cast(user_data); + ::close(fd); + } + + const libinput_interface test_libinput_interface { + open_restricted, + close_restricted, + }; + + LibinputContext create_libinput_context(const std::filesystem::path &node) { + LibinputContext context {libinput_path_create_context(&test_libinput_interface, nullptr), unref_libinput}; + if (context == nullptr) { + return context; + } + + const auto node_string = node.string(); + auto *device = libinput_path_add_device(context.get(), node_string.c_str()); + if (device == nullptr) { + return LibinputContext {nullptr, unref_libinput}; + } + + return context; + } + + LibinputEvent wait_for_libinput_event( + libinput *context, + std::initializer_list expected_types + ) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {3}; + + while (std::chrono::steady_clock::now() < deadline) { + static_cast(libinput_dispatch(context)); + + while (auto *raw_event = libinput_get_event(context)) { + LibinputEvent event {raw_event, destroy_libinput_event}; + const auto event_type = libinput_event_get_type(event.get()); + if (std::find(expected_types.begin(), expected_types.end(), event_type) != expected_types.end()) { + return event; + } + + std::cout << "Ignoring libinput event type: " << event_type << '\n'; + } + + std::this_thread::sleep_for(std::chrono::milliseconds {50}); + } + + return LibinputEvent {nullptr, destroy_libinput_event}; + } + +#endif // defined(__linux__) + +} // namespace + +#if defined(__linux__) +TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); + + SDL_SetHint(SDL_HINT_JOYSTICK_ALLOW_BACKGROUND_EVENTS, "1"); + ASSERT_EQ(SDL_Init(SDL_INIT_JOYSTICK | SDL_INIT_EVENTS), 0) << SDL_GetError(); + ScopeExit sdl_quit {[]() { + SDL_Quit(); + }}; + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_TRUE(runtime->capabilities().supports_gamepad); + + lvh::CreateGamepadOptions options; + options.profile = lvh::profiles::generic_gamepad(); + options.profile.name = unique_device_name("SDL Gamepad"); + options.metadata.stable_id = "libvirtualhid-sdl-gamepad-test"; + + auto created = runtime->create_gamepad(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto joystick_index = wait_for_sdl_joystick(options.profile); + ASSERT_GE(joystick_index, 0); + + SdlJoystick joystick {SDL_JoystickOpen(joystick_index), SDL_JoystickClose}; + ASSERT_NE(joystick.get(), nullptr) << SDL_GetError(); + EXPECT_GE(SDL_JoystickNumButtons(joystick.get()), 1); + EXPECT_GE(SDL_JoystickNumAxes(joystick.get()), 2); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.left_stick = {0.75F, -0.5F}; + ASSERT_TRUE(created.gamepad->submit(state).ok()); + + EXPECT_TRUE(wait_for_sdl_gamepad_input(joystick.get())) << describe_sdl_state(joystick.get()); +} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_TRUE(runtime->capabilities().supports_keyboard); + + lvh::CreateKeyboardOptions options; + options.profile = lvh::profiles::keyboard(); + options.profile.name = unique_device_name("libinput Keyboard"); + options.stable_id = "libvirtualhid-libinput-keyboard-test"; + + auto created = runtime->create_keyboard(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto node = wait_for_readable_event_node(options.profile.name); + ASSERT_TRUE(node) << "libinput keyboard event node was not readable for " << options.profile.name; + + auto context = create_libinput_context(*node); + ASSERT_NE(context.get(), nullptr) << "libinput could not open " << node->string(); + + auto event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_DEVICE_ADDED}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_device(event.get()), nullptr); + EXPECT_TRUE(libinput_device_has_capability(libinput_event_get_device(event.get()), LIBINPUT_DEVICE_CAP_KEYBOARD)); + + ASSERT_TRUE(created.keyboard->press(0x41).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_KEYBOARD_KEY}); + ASSERT_NE(event.get(), nullptr); + auto *keyboard_event = libinput_event_get_keyboard_event(event.get()); + ASSERT_NE(keyboard_event, nullptr); + EXPECT_EQ(libinput_event_keyboard_get_key(keyboard_event), KEY_A); + EXPECT_EQ(libinput_event_keyboard_get_key_state(keyboard_event), LIBINPUT_KEY_STATE_PRESSED); + + ASSERT_TRUE(created.keyboard->release(0x41).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_KEYBOARD_KEY}); + ASSERT_NE(event.get(), nullptr); + keyboard_event = libinput_event_get_keyboard_event(event.get()); + ASSERT_NE(keyboard_event, nullptr); + EXPECT_EQ(libinput_event_keyboard_get_key(keyboard_event), KEY_A); + EXPECT_EQ(libinput_event_keyboard_get_key_state(keyboard_event), LIBINPUT_KEY_STATE_RELEASED); +} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_TRUE(runtime->capabilities().supports_mouse); + + lvh::CreateMouseOptions options; + options.profile = lvh::profiles::mouse(); + options.profile.name = unique_device_name("libinput Mouse"); + options.stable_id = "libvirtualhid-libinput-mouse-test"; + + auto created = runtime->create_mouse(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto node = wait_for_readable_event_node(options.profile.name); + ASSERT_TRUE(node) << "libinput mouse event node was not readable for " << options.profile.name; + + auto context = create_libinput_context(*node); + ASSERT_NE(context.get(), nullptr) << "libinput could not open " << node->string(); + + auto event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_DEVICE_ADDED}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_device(event.get()), nullptr); + EXPECT_TRUE(libinput_device_has_capability(libinput_event_get_device(event.get()), LIBINPUT_DEVICE_CAP_POINTER)); + + ASSERT_TRUE(created.mouse->move_relative(25, -10).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_MOTION}); + ASSERT_NE(event.get(), nullptr); + auto *pointer_event = libinput_event_get_pointer_event(event.get()); + ASSERT_NE(pointer_event, nullptr); + EXPECT_DOUBLE_EQ(libinput_event_pointer_get_dx_unaccelerated(pointer_event), 25.0); + EXPECT_DOUBLE_EQ(libinput_event_pointer_get_dy_unaccelerated(pointer_event), -10.0); + + ASSERT_TRUE(created.mouse->button(lvh::MouseButton::left, true).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); + ASSERT_NE(event.get(), nullptr); + pointer_event = libinput_event_get_pointer_event(event.get()); + ASSERT_NE(pointer_event, nullptr); + EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); + EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_PRESSED); + + ASSERT_TRUE(created.mouse->button(lvh::MouseButton::left, false).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); + ASSERT_NE(event.get(), nullptr); + pointer_event = libinput_event_get_pointer_event(event.get()); + ASSERT_NE(pointer_event, nullptr); + EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); + EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_RELEASED); +} +#else +TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) {} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) {} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) {} +#endif diff --git a/tests/unit/test_linux_discovery.cpp b/tests/unit/test_linux_discovery.cpp deleted file mode 100644 index 8a79de8..0000000 --- a/tests/unit/test_linux_discovery.cpp +++ /dev/null @@ -1,161 +0,0 @@ -/** - * @file tests/unit/test_linux_discovery.cpp - * @brief Linux integration tests for external discovery of virtual devices. - */ - -// standard includes -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -// lib includes -#include - -// local includes -#include "fixtures/fixtures.hpp" - -/** - * @brief Test fixture for Linux virtual device discovery. - */ -class LinuxDiscoveryTest: public LinuxTest {}; - -namespace { - - struct DiscoveryProbe { - std::string name; - std::string executable; - std::string command; - }; - - struct CommandResult { - int exit_code = 1; - std::string output; - }; - - std::vector discovery_probes() { - return { - {"evdev joystick list", "evdev-joystick", "evdev-joystick --listdevs"}, - {"HIDAPI device list", "hidapitester", "hidapitester --list"}, - }; - } - - bool command_available(std::string_view executable) { - const auto command = "command -v " + std::string {executable} + " >/dev/null 2>&1"; - return std::system(command.c_str()) == 0; - } - - std::string read_file(const std::filesystem::path &path) { - std::ifstream file {path}; - std::ostringstream stream; - stream << file.rdbuf(); - return stream.str(); - } - - CommandResult run_command(const DiscoveryProbe &probe, std::size_t index) { - const auto output_path = std::filesystem::temp_directory_path() / - ("libvirtualhid-discovery-test-" + std::to_string(index) + ".txt"); - const auto command = probe.command + " > \"" + output_path.string() + "\" 2>&1"; - CommandResult result; - result.exit_code = std::system(command.c_str()); - result.output = read_file(output_path); - std::error_code error; - std::filesystem::remove(output_path, error); - return result; - } - - std::string lowercase(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), [](unsigned char character) { - return static_cast(std::tolower(character)); - }); - return value; - } - - std::string hex4(std::uint16_t value) { - std::ostringstream stream; - stream << std::hex << std::nouppercase << std::setw(4) << std::setfill('0') << value; - return stream.str(); - } - - bool output_matches_profile(std::string_view output, const lvh::DeviceProfile &profile) { - const auto lower_output = lowercase(std::string {output}); - const auto lower_name = lowercase(profile.name); - const auto vendor = hex4(profile.vendor_id); - const auto product = hex4(profile.product_id); - - return lower_output.find(lower_name) != std::string::npos || - lower_output.find(vendor + ":" + product) != std::string::npos || - lower_output.find(vendor + "/" + product) != std::string::npos; - } - - bool wait_for_probe(const DiscoveryProbe &probe, const lvh::DeviceProfile &profile, std::size_t index) { - CommandResult result; - for (auto attempt = 0; attempt < 12; ++attempt) { - result = run_command(probe, index); - if (result.exit_code == 0 && output_matches_profile(result.output, profile)) { - std::cout << probe.name << " discovered " << profile.name << '\n'; - return true; - } - - std::this_thread::sleep_for(std::chrono::milliseconds {250}); - } - - std::cout << probe.name << " output:" << '\n' - << result.output << '\n'; - return false; - } - -} // namespace - -TEST_F(LinuxDiscoveryTest, ExternalDiscoveryRequiresPrerequisites) { - ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); - - const auto probes = discovery_probes(); - std::vector available_probe_indices; - for (std::size_t index = 0; index < probes.size(); ++index) { - const auto &probe = probes[index]; - if (!command_available(probe.executable)) { - std::cout << probe.executable << " is not installed; " << probe.name << " is unavailable" << '\n'; - continue; - } - - available_probe_indices.push_back(index); - } - - ASSERT_FALSE(available_probe_indices.empty()) - << "install joystick or hidapitester to validate external discovery"; - - lvh::RuntimeOptions runtime_options; - runtime_options.backend = lvh::BackendKind::platform_default; - auto runtime = lvh::Runtime::create(runtime_options); - ASSERT_TRUE(runtime->capabilities().supports_gamepad); - - lvh::CreateGamepadOptions options; - options.profile = lvh::profiles::generic_gamepad(); - options.metadata.stable_id = "libvirtualhid-discovery-test"; - - auto created = runtime->create_gamepad(options); - ASSERT_TRUE(created) << created.status.message(); - - lvh::GamepadState state; - state.buttons.set(lvh::GamepadButton::a); - state.left_stick = {0.25F, -0.25F}; - ASSERT_TRUE(created.gamepad->submit(state).ok()); - - for (const auto index : available_probe_indices) { - const auto &probe = probes[index]; - EXPECT_TRUE(wait_for_probe(probe, options.profile, index)); - } -} From aa962746f3df1e03435ad1c8e40f65b7388202ee Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:29:36 -0400 Subject: [PATCH 25/28] Rename Status to OperationStatus Rename the Status type to OperationStatus across the codebase and update all related APIs and implementations (constructors, factory methods, ok/code/message accessors, virtual interfaces, and return usages). Propagate the change through core/runtime, core/backend, core/types, platform implementations (linux UHID/uinput and XTest fallbacks, unsupported backend), tests/fixtures, and fake backends. Also tweak the CI workflow warning message concatenation and remove the Xlib Status macro undef block in the Linux UHID backend. --- .github/workflows/ci.yml | 3 +- include/libvirtualhid/runtime.hpp | 38 ++--- include/libvirtualhid/types.hpp | 12 +- src/core/backend.cpp | 34 ++-- src/core/backend.hpp | 20 +-- src/core/runtime.cpp | 114 ++++++------- src/core/types.cpp | 14 +- src/platform/linux/uhid_backend.cpp | 161 +++++++++--------- src/platform/unsupported_backend.cpp | 6 +- .../fixtures/linux_backend_test_hooks.hpp | 96 +++++------ tests/fixtures/linux_backend_test_hooks.cpp | 101 ++++++----- 11 files changed, 301 insertions(+), 298 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0dc0b4..c2cc1e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,7 +98,8 @@ jobs: sudo udevadm control --reload-rules for module in uhid uinput; do if ! sudo modprobe "${module}"; then - message="Unable to load ${module}; tests requiring /dev/${module} will fail unless the device already exists." + message="Unable to load ${module}; tests requiring /dev/${module}" + message="${message} will fail unless the device already exists." echo "::warning::${message}" fi done diff --git a/include/libvirtualhid/runtime.hpp b/include/libvirtualhid/runtime.hpp index 0f6c615..5a615f8 100644 --- a/include/libvirtualhid/runtime.hpp +++ b/include/libvirtualhid/runtime.hpp @@ -57,7 +57,7 @@ namespace lvh { * * @return Close operation status. */ - virtual Status close() = 0; + virtual OperationStatus close() = 0; }; /** @@ -122,7 +122,7 @@ namespace lvh { /** * @copydoc VirtualDevice::close */ - Status close() override; + OperationStatus close() override; /** * @brief Submit the latest gamepad input state. @@ -130,7 +130,7 @@ namespace lvh { * @param state Gamepad input state. * @return Submit operation status. */ - Status submit(const GamepadState &state); + OperationStatus submit(const GamepadState &state); /** * @brief Register a callback for backend output events. @@ -145,7 +145,7 @@ namespace lvh { * @param output Output event. * @return Dispatch operation status. */ - Status dispatch_output(const GamepadOutput &output); + OperationStatus dispatch_output(const GamepadOutput &output); /** * @brief Get the most recently submitted gamepad state. @@ -231,7 +231,7 @@ namespace lvh { /** * @copydoc VirtualDevice::close */ - Status close() override; + OperationStatus close() override; /** * @brief Submit a keyboard key transition. @@ -239,7 +239,7 @@ namespace lvh { * @param event Keyboard event. * @return Submit operation status. */ - Status submit(const KeyboardEvent &event); + OperationStatus submit(const KeyboardEvent &event); /** * @brief Press a keyboard key. @@ -247,7 +247,7 @@ namespace lvh { * @param key_code Portable key code. * @return Submit operation status. */ - Status press(KeyboardKeyCode key_code); + OperationStatus press(KeyboardKeyCode key_code); /** * @brief Release a keyboard key. @@ -255,7 +255,7 @@ namespace lvh { * @param key_code Portable key code. * @return Submit operation status. */ - Status release(KeyboardKeyCode key_code); + OperationStatus release(KeyboardKeyCode key_code); /** * @brief Type UTF-8 text. @@ -263,7 +263,7 @@ namespace lvh { * @param event Text event. * @return Submit operation status. */ - Status type_text(const KeyboardTextEvent &event); + OperationStatus type_text(const KeyboardTextEvent &event); /** * @brief Get the most recently submitted keyboard event. @@ -342,7 +342,7 @@ namespace lvh { /** * @copydoc VirtualDevice::close */ - Status close() override; + OperationStatus close() override; /** * @brief Submit a mouse event. @@ -350,7 +350,7 @@ namespace lvh { * @param event Mouse event. * @return Submit operation status. */ - Status submit(const MouseEvent &event); + OperationStatus submit(const MouseEvent &event); /** * @brief Submit relative pointer movement. @@ -359,7 +359,7 @@ namespace lvh { * @param delta_y Vertical delta. * @return Submit operation status. */ - Status move_relative(std::int32_t delta_x, std::int32_t delta_y); + OperationStatus move_relative(std::int32_t delta_x, std::int32_t delta_y); /** * @brief Submit absolute pointer movement. @@ -370,7 +370,7 @@ namespace lvh { * @param height Height of the absolute coordinate space. * @return Submit operation status. */ - Status move_absolute(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height); + OperationStatus move_absolute(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height); /** * @brief Submit a mouse button transition. @@ -379,7 +379,7 @@ namespace lvh { * @param pressed Whether the button is pressed. * @return Submit operation status. */ - Status button(MouseButton button, bool pressed); + OperationStatus button(MouseButton button, bool pressed); /** * @brief Submit high-resolution vertical scroll. @@ -387,7 +387,7 @@ namespace lvh { * @param distance High-resolution scroll distance. * @return Submit operation status. */ - Status vertical_scroll(std::int32_t distance); + OperationStatus vertical_scroll(std::int32_t distance); /** * @brief Submit high-resolution horizontal scroll. @@ -395,7 +395,7 @@ namespace lvh { * @param distance High-resolution scroll distance. * @return Submit operation status. */ - Status horizontal_scroll(std::int32_t distance); + OperationStatus horizontal_scroll(std::int32_t distance); /** * @brief Get the most recently submitted mouse event. @@ -426,7 +426,7 @@ namespace lvh { /** * @brief Creation status. */ - Status status; + OperationStatus status; /** * @brief Created gamepad handle when creation succeeds. @@ -450,7 +450,7 @@ namespace lvh { /** * @brief Creation status. */ - Status status; + OperationStatus status; /** * @brief Created keyboard handle when creation succeeds. @@ -474,7 +474,7 @@ namespace lvh { /** * @brief Creation status. */ - Status status; + OperationStatus status; /** * @brief Created mouse handle when creation succeeds. diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index 06e6321..ae48d95 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -36,12 +36,12 @@ namespace lvh { /** * @brief Result status with an error category and human-readable message. */ - class Status { + class OperationStatus { public: /** * @brief Construct a successful status. */ - Status(); + OperationStatus(); /** * @brief Construct a status with an explicit error code and message. @@ -49,14 +49,14 @@ namespace lvh { * @param code Error category. * @param message Human-readable status message. */ - Status(ErrorCode code, std::string message); + OperationStatus(ErrorCode code, std::string message); /** * @brief Create a successful status. * * @return Successful status object. */ - static Status success(); + static OperationStatus success(); /** * @brief Create a failing status. @@ -65,7 +65,7 @@ namespace lvh { * @param message Human-readable failure message. * @return Failing status object. */ - static Status failure(ErrorCode code, std::string message); + static OperationStatus failure(ErrorCode code, std::string message); /** * @brief Check whether the operation succeeded. @@ -84,7 +84,7 @@ namespace lvh { /** * @brief Get the human-readable status message. * - * @return Status message. + * @return Human-readable status message. */ const std::string &message() const; diff --git a/src/core/backend.cpp b/src/core/backend.cpp index 19e2b78..b0bbce2 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -19,16 +19,16 @@ namespace lvh::detail { */ class FakeGamepad final: public BackendGamepad { public: - Status submit(const std::vector & /*report*/) override { - return Status::success(); + OperationStatus submit(const std::vector & /*report*/) override { + return OperationStatus::success(); } void set_output_callback(OutputCallback callback) override { output_callback_ = std::move(callback); } - Status close() override { - return Status::success(); + OperationStatus close() override { + return OperationStatus::success(); } private: @@ -40,16 +40,16 @@ namespace lvh::detail { */ class FakeKeyboard final: public BackendKeyboard { public: - Status submit(const KeyboardEvent & /*event*/) override { - return Status::success(); + OperationStatus submit(const KeyboardEvent & /*event*/) override { + return OperationStatus::success(); } - Status type_text(const KeyboardTextEvent & /*event*/) override { - return Status::success(); + OperationStatus type_text(const KeyboardTextEvent & /*event*/) override { + return OperationStatus::success(); } - Status close() override { - return Status::success(); + OperationStatus close() override { + return OperationStatus::success(); } }; @@ -58,12 +58,12 @@ namespace lvh::detail { */ class FakeMouse final: public BackendMouse { public: - Status submit(const MouseEvent & /*event*/) override { - return Status::success(); + OperationStatus submit(const MouseEvent & /*event*/) override { + return OperationStatus::success(); } - Status close() override { - return Status::success(); + OperationStatus close() override { + return OperationStatus::success(); } }; @@ -85,18 +85,18 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId /*id*/, const CreateGamepadOptions & /*options*/) override { - return {Status::success(), std::make_unique()}; + return {OperationStatus::success(), std::make_unique()}; } BackendKeyboardCreationResult create_keyboard( DeviceId /*id*/, const CreateKeyboardOptions & /*options*/ ) override { - return {Status::success(), std::make_unique()}; + return {OperationStatus::success(), std::make_unique()}; } BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions & /*options*/) override { - return {Status::success(), std::make_unique()}; + return {OperationStatus::success(), std::make_unique()}; } private: diff --git a/src/core/backend.hpp b/src/core/backend.hpp index 44f4d60..5bc0509 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -35,7 +35,7 @@ namespace lvh::detail { * @param report Packed HID input report. * @return Submit status. */ - virtual Status submit(const std::vector &report) = 0; + virtual OperationStatus submit(const std::vector &report) = 0; /** * @brief Register a callback for backend output reports. @@ -49,7 +49,7 @@ namespace lvh::detail { * * @return Close status. */ - virtual Status close() = 0; + virtual OperationStatus close() = 0; protected: BackendGamepad() = default; @@ -76,7 +76,7 @@ namespace lvh::detail { * @param event Keyboard event. * @return Submit status. */ - virtual Status submit(const KeyboardEvent &event) = 0; + virtual OperationStatus submit(const KeyboardEvent &event) = 0; /** * @brief Submit UTF-8 text to the backend. @@ -84,14 +84,14 @@ namespace lvh::detail { * @param event Text event. * @return Submit status. */ - virtual Status type_text(const KeyboardTextEvent &event) = 0; + virtual OperationStatus type_text(const KeyboardTextEvent &event) = 0; /** * @brief Close the backend device. * * @return Close status. */ - virtual Status close() = 0; + virtual OperationStatus close() = 0; protected: BackendKeyboard() = default; @@ -118,14 +118,14 @@ namespace lvh::detail { * @param event Mouse event. * @return Submit status. */ - virtual Status submit(const MouseEvent &event) = 0; + virtual OperationStatus submit(const MouseEvent &event) = 0; /** * @brief Close the backend device. * * @return Close status. */ - virtual Status close() = 0; + virtual OperationStatus close() = 0; protected: BackendMouse() = default; @@ -138,7 +138,7 @@ namespace lvh::detail { /** * @brief Creation status. */ - Status status; + OperationStatus status; /** * @brief Backend device when creation succeeds. @@ -162,7 +162,7 @@ namespace lvh::detail { /** * @brief Creation status. */ - Status status; + OperationStatus status; /** * @brief Backend device when creation succeeds. @@ -186,7 +186,7 @@ namespace lvh::detail { /** * @brief Creation status. */ - Status status; + OperationStatus status; /** * @brief Backend device when creation succeeds. diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index e9bee8d..9bc8104 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -96,62 +96,62 @@ namespace lvh::detail { namespace lvh { namespace { - Status validate_gamepad_options(const CreateGamepadOptions &options) { + OperationStatus validate_gamepad_options(const CreateGamepadOptions &options) { if (options.profile.device_type != DeviceType::gamepad) { - return Status::failure(ErrorCode::unsupported_profile, "device profile is not a gamepad"); + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a gamepad"); } if (options.profile.name.empty()) { - return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); } if (options.profile.report_descriptor.empty()) { - return Status::failure(ErrorCode::invalid_argument, "device profile report descriptor must not be empty"); + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile report descriptor must not be empty"); } if (options.profile.report_id == 0) { - return Status::failure(ErrorCode::invalid_argument, "device profile report id must not be zero"); + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile report id must not be zero"); } if (options.profile.input_report_size == 0) { - return Status::failure(ErrorCode::invalid_argument, "device profile input report size must not be zero"); + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile input report size must not be zero"); } - return Status::success(); + return OperationStatus::success(); } - Status validate_keyboard_options(const CreateKeyboardOptions &options) { + OperationStatus validate_keyboard_options(const CreateKeyboardOptions &options) { if (options.profile.device_type != DeviceType::keyboard) { - return Status::failure(ErrorCode::unsupported_profile, "device profile is not a keyboard"); + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a keyboard"); } if (options.profile.name.empty()) { - return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); } - return Status::success(); + return OperationStatus::success(); } - Status validate_mouse_options(const CreateMouseOptions &options) { + OperationStatus validate_mouse_options(const CreateMouseOptions &options) { if (options.profile.device_type != DeviceType::mouse) { - return Status::failure(ErrorCode::unsupported_profile, "device profile is not a mouse"); + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a mouse"); } if (options.profile.name.empty()) { - return Status::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); } - return Status::success(); + return OperationStatus::success(); } - Status validate_keyboard_event(const KeyboardEvent &event) { + OperationStatus validate_keyboard_event(const KeyboardEvent &event) { if (event.key_code == 0) { - return Status::failure(ErrorCode::invalid_argument, "keyboard key code must not be zero"); + return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code must not be zero"); } - return Status::success(); + return OperationStatus::success(); } - Status validate_mouse_event(const MouseEvent &event) { + OperationStatus validate_mouse_event(const MouseEvent &event) { if (event.kind == MouseEventKind::absolute_motion && (event.width <= 0 || event.height <= 0)) { - return Status::failure(ErrorCode::invalid_argument, "absolute mouse movement requires positive dimensions"); + return OperationStatus::failure(ErrorCode::invalid_argument, "absolute mouse movement requires positive dimensions"); } - return Status::success(); + return OperationStatus::success(); } template @@ -213,13 +213,13 @@ namespace lvh { }); } - Status Gamepad::close() { + OperationStatus Gamepad::close() { return with_device(device_, [](auto &device) { if (!device.open) { - return Status::success(); + return OperationStatus::success(); } - auto status = Status::success(); + auto status = OperationStatus::success(); if (device.backend) { status = device.backend->close(); } @@ -229,15 +229,15 @@ namespace lvh { }); } - Status Gamepad::submit(const GamepadState &state) { + OperationStatus Gamepad::submit(const GamepadState &state) { return with_device(device_, [&state](auto &device) { if (!device.open) { - return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "gamepad is closed"); } auto report = reports::pack_input_report(device.options.profile, state); if (report.empty()) { - return Status::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); + return OperationStatus::failure(ErrorCode::backend_failure, "failed to pack gamepad input report"); } if (device.backend) { @@ -249,7 +249,7 @@ namespace lvh { device.last_state = reports::normalize_state(state); device.last_report = std::move(report); ++device.submitted_reports; - return Status::success(); + return OperationStatus::success(); }); } @@ -263,14 +263,14 @@ namespace lvh { }); } - Status Gamepad::dispatch_output(const GamepadOutput &output) { + OperationStatus Gamepad::dispatch_output(const GamepadOutput &output) { OutputCallback callback; const auto status = with_device(device_, [&callback](auto &device) { if (!device.open) { - return Status::failure(ErrorCode::device_closed, "gamepad is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "gamepad is closed"); } callback = device.output_callback; - return Status::success(); + return OperationStatus::success(); }); if (!status.ok()) { @@ -279,7 +279,7 @@ namespace lvh { if (callback) { callback(output); } - return Status::success(); + return OperationStatus::success(); } GamepadState Gamepad::last_submitted_state() const { @@ -321,13 +321,13 @@ namespace lvh { }); } - Status Keyboard::close() { + OperationStatus Keyboard::close() { return with_device(device_, [](auto &device) { if (!device.open) { - return Status::success(); + return OperationStatus::success(); } - auto status = Status::success(); + auto status = OperationStatus::success(); if (device.backend) { status = device.backend->close(); } @@ -337,14 +337,14 @@ namespace lvh { }); } - Status Keyboard::submit(const KeyboardEvent &event) { + OperationStatus Keyboard::submit(const KeyboardEvent &event) { if (const auto validation = validate_keyboard_event(event); !validation.ok()) { return validation; } return with_device(device_, [&event](auto &device) { if (!device.open) { - return Status::failure(ErrorCode::device_closed, "keyboard is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "keyboard is closed"); } if (device.backend) { @@ -355,22 +355,22 @@ namespace lvh { device.last_event = event; ++device.submitted_events; - return Status::success(); + return OperationStatus::success(); }); } - Status Keyboard::press(KeyboardKeyCode key_code) { + OperationStatus Keyboard::press(KeyboardKeyCode key_code) { return submit({.key_code = key_code, .pressed = true}); } - Status Keyboard::release(KeyboardKeyCode key_code) { + OperationStatus Keyboard::release(KeyboardKeyCode key_code) { return submit({.key_code = key_code, .pressed = false}); } - Status Keyboard::type_text(const KeyboardTextEvent &event) { + OperationStatus Keyboard::type_text(const KeyboardTextEvent &event) { return with_device(device_, [&event](auto &device) { if (!device.open) { - return Status::failure(ErrorCode::device_closed, "keyboard is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "keyboard is closed"); } if (device.backend) { @@ -381,7 +381,7 @@ namespace lvh { device.last_text_event = event; ++device.submitted_events; - return Status::success(); + return OperationStatus::success(); }); } @@ -418,13 +418,13 @@ namespace lvh { }); } - Status Mouse::close() { + OperationStatus Mouse::close() { return with_device(device_, [](auto &device) { if (!device.open) { - return Status::success(); + return OperationStatus::success(); } - auto status = Status::success(); + auto status = OperationStatus::success(); if (device.backend) { status = device.backend->close(); } @@ -434,14 +434,14 @@ namespace lvh { }); } - Status Mouse::submit(const MouseEvent &event) { + OperationStatus Mouse::submit(const MouseEvent &event) { if (const auto validation = validate_mouse_event(event); !validation.ok()) { return validation; } return with_device(device_, [&event](auto &device) { if (!device.open) { - return Status::failure(ErrorCode::device_closed, "mouse is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "mouse is closed"); } if (device.backend) { @@ -452,19 +452,19 @@ namespace lvh { device.last_event = event; ++device.submitted_events; - return Status::success(); + return OperationStatus::success(); }); } - Status Mouse::move_relative(std::int32_t delta_x, std::int32_t delta_y) { + OperationStatus Mouse::move_relative(std::int32_t delta_x, std::int32_t delta_y) { return submit({.kind = MouseEventKind::relative_motion, .x = delta_x, .y = delta_y}); } - Status Mouse::move_absolute(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) { + OperationStatus Mouse::move_absolute(std::int32_t x, std::int32_t y, std::int32_t width, std::int32_t height) { return submit({.kind = MouseEventKind::absolute_motion, .x = x, .y = y, .width = width, .height = height}); } - Status Mouse::button(MouseButton button, bool pressed) { + OperationStatus Mouse::button(MouseButton button, bool pressed) { MouseEvent event; event.kind = MouseEventKind::button; event.button = button; @@ -472,14 +472,14 @@ namespace lvh { return submit(event); } - Status Mouse::vertical_scroll(std::int32_t distance) { + OperationStatus Mouse::vertical_scroll(std::int32_t distance) { MouseEvent event; event.kind = MouseEventKind::vertical_scroll; event.high_resolution_scroll = distance; return submit(event); } - Status Mouse::horizontal_scroll(std::int32_t distance) { + OperationStatus Mouse::horizontal_scroll(std::int32_t distance) { MouseEvent event; event.kind = MouseEventKind::horizontal_scroll; event.high_resolution_scroll = distance; @@ -550,7 +550,7 @@ namespace lvh { state_->gamepads.emplace_back(device); } - return {Status::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; + return {OperationStatus::success(), std::unique_ptr {new Gamepad {std::move(device)}}}; } KeyboardCreationResult Runtime::create_keyboard() { @@ -581,7 +581,7 @@ namespace lvh { state_->keyboards.emplace_back(device); } - return {Status::success(), std::unique_ptr {new Keyboard {std::move(device)}}}; + return {OperationStatus::success(), std::unique_ptr {new Keyboard {std::move(device)}}}; } MouseCreationResult Runtime::create_mouse() { @@ -612,7 +612,7 @@ namespace lvh { state_->mice.emplace_back(device); } - return {Status::success(), std::unique_ptr {new Mouse {std::move(device)}}}; + return {OperationStatus::success(), std::unique_ptr {new Mouse {std::move(device)}}}; } std::size_t Runtime::active_device_count() const { diff --git a/src/core/types.cpp b/src/core/types.cpp index 3157f84..42f32b4 100644 --- a/src/core/types.cpp +++ b/src/core/types.cpp @@ -11,34 +11,34 @@ namespace lvh { - Status::Status(): + OperationStatus::OperationStatus(): code_ {ErrorCode::ok}, message_ {} {} - Status::Status(ErrorCode code, std::string message): + OperationStatus::OperationStatus(ErrorCode code, std::string message): code_ {code}, message_ {std::move(message)} {} - Status Status::success() { + OperationStatus OperationStatus::success() { return {}; } - Status Status::failure(ErrorCode code, std::string message) { + OperationStatus OperationStatus::failure(ErrorCode code, std::string message) { if (code == ErrorCode::ok) { return {}; } return {code, std::move(message)}; } - bool Status::ok() const { + bool OperationStatus::ok() const { return code_ == ErrorCode::ok; } - ErrorCode Status::code() const { + ErrorCode OperationStatus::code() const { return code_; } - const std::string &Status::message() const { + const std::string &OperationStatus::message() const { return message_; } diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 39771f1..3a44a38 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -39,11 +39,6 @@ #include #include #include - - // Xlib defines Status as a macro, which collides with lvh::Status. - #if defined(Status) - #undef Status - #endif #endif // local includes @@ -92,8 +87,8 @@ namespace lvh::detail { return std::error_code(error, std::generic_category()).message(); } - Status system_error_status(ErrorCode code, const std::string &operation, int error) { - return Status::failure(code, operation + ": " + errno_message(error)); + OperationStatus system_error_status(ErrorCode code, const std::string &operation, int error) { + return OperationStatus::failure(code, operation + ": " + errno_message(error)); } bool can_access_uhid() { @@ -129,7 +124,7 @@ namespace lvh::detail { destination[length] = 0; } - Status ioctl_status(const std::string &operation) { + OperationStatus ioctl_status(const std::string &operation) { return system_error_status(ErrorCode::backend_failure, operation, errno); } @@ -448,21 +443,21 @@ namespace lvh::detail { } protected: - Status emit_event(std::uint16_t type, std::uint16_t code, std::int32_t value) { + OperationStatus emit_event(std::uint16_t type, std::uint16_t code, std::int32_t value) { std::lock_guard lock {write_mutex_}; return emit_event_locked(type, code, value); } - Status sync() { + OperationStatus sync() { return emit_event(EV_SYN, SYN_REPORT, 0); } - Status close_uinput(const std::string &description) { + OperationStatus close_uinput(const std::string &description) { if (!open_.exchange(false)) { - return Status::success(); + return OperationStatus::success(); } - auto status = Status::success(); + auto status = OperationStatus::success(); if (fd_ >= 0) { if (system_ioctl(fd_, UI_DEV_DESTROY) < 0) { status = ioctl_status("failed to destroy " + description); @@ -485,9 +480,9 @@ namespace lvh::detail { } private: - Status emit_event_locked(std::uint16_t type, std::uint16_t code, std::int32_t value) { + OperationStatus emit_event_locked(std::uint16_t type, std::uint16_t code, std::int32_t value) { if (fd_ < 0) { - return Status::failure(ErrorCode::device_closed, "uinput device is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "uinput device is closed"); } input_event event {}; @@ -500,10 +495,10 @@ namespace lvh::detail { return system_error_status(ErrorCode::backend_failure, "failed to write uinput event", errno); } if (static_cast(result) != sizeof(event)) { - return Status::failure(ErrorCode::backend_failure, "short write while sending uinput event"); + return OperationStatus::failure(ErrorCode::backend_failure, "short write while sending uinput event"); } - return Status::success(); + return OperationStatus::success(); } int fd_ = -1; @@ -511,7 +506,7 @@ namespace lvh::detail { std::mutex write_mutex_; }; - Status write_uinput_user_device(int fd, const DeviceProfile &profile, DeviceId id) { + OperationStatus write_uinput_user_device(int fd, const DeviceProfile &profile, DeviceId id) { uinput_user_dev device {}; copy_string(device.name, profile.name); device.id.bustype = to_uinput_bus(profile.bus_type); @@ -532,17 +527,17 @@ namespace lvh::detail { return system_error_status(ErrorCode::backend_failure, "failed to write uinput device definition", errno); } if (static_cast(result) != sizeof(device)) { - return Status::failure(ErrorCode::backend_failure, "short write while creating uinput device"); + return OperationStatus::failure(ErrorCode::backend_failure, "short write while creating uinput device"); } if (system_ioctl(fd, UI_DEV_CREATE) < 0) { return ioctl_status("failed to create uinput device " + std::to_string(id)); } - return Status::success(); + return OperationStatus::success(); } - Status enable_uinput_keyboard(int fd) { + OperationStatus enable_uinput_keyboard(int fd) { if (system_ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { return ioctl_status("failed to enable uinput keyboard key events"); } @@ -553,10 +548,10 @@ namespace lvh::detail { } } - return Status::success(); + return OperationStatus::success(); } - Status enable_uinput_mouse(int fd) { + OperationStatus enable_uinput_mouse(int fd) { if (system_ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { return ioctl_status("failed to enable uinput mouse button events"); } @@ -606,7 +601,7 @@ namespace lvh::detail { return ioctl_status("failed to enable uinput absolute Y axis"); } - return Status::success(); + return OperationStatus::success(); } /** @@ -621,21 +616,21 @@ namespace lvh::detail { static_cast(close()); } - Status create(DeviceId id, const CreateKeyboardOptions &options) { + OperationStatus create(DeviceId id, const CreateKeyboardOptions &options) { if (const auto status = enable_uinput_keyboard(file_descriptor()); !status.ok()) { return status; } return write_uinput_user_device(file_descriptor(), options.profile, id); } - Status submit(const KeyboardEvent &event) override { + OperationStatus submit(const KeyboardEvent &event) override { if (!is_open()) { - return Status::failure(ErrorCode::device_closed, "uinput keyboard is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "uinput keyboard is closed"); } const auto linux_key = key_code_to_linux(event.key_code); if (linux_key < 0) { - return Status::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by the Linux backend"); + return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by the Linux backend"); } if (const auto status = emit_event(EV_KEY, static_cast(linux_key), event.pressed ? 1 : 0); !status.ok()) { @@ -644,7 +639,7 @@ namespace lvh::detail { return sync(); } - Status type_text(const KeyboardTextEvent &event) override { + OperationStatus type_text(const KeyboardTextEvent &event) override { for (const auto codepoint : decode_utf8(event.text)) { const auto hex = uppercase_hex(codepoint); @@ -685,10 +680,10 @@ namespace lvh::detail { } } - return Status::success(); + return OperationStatus::success(); } - Status close() override { + OperationStatus close() override { return close_uinput("uinput keyboard"); } }; @@ -705,16 +700,16 @@ namespace lvh::detail { static_cast(close()); } - Status create(DeviceId id, const CreateMouseOptions &options) { + OperationStatus create(DeviceId id, const CreateMouseOptions &options) { if (const auto status = enable_uinput_mouse(file_descriptor()); !status.ok()) { return status; } return write_uinput_user_device(file_descriptor(), options.profile, id); } - Status submit(const MouseEvent &event) override { + OperationStatus submit(const MouseEvent &event) override { if (!is_open()) { - return Status::failure(ErrorCode::device_closed, "uinput mouse is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "uinput mouse is closed"); } switch (event.kind) { @@ -730,15 +725,15 @@ namespace lvh::detail { return submit_horizontal_scroll(event.high_resolution_scroll); } - return Status::failure(ErrorCode::invalid_argument, "unsupported mouse event kind"); + return OperationStatus::failure(ErrorCode::invalid_argument, "unsupported mouse event kind"); } - Status close() override { + OperationStatus close() override { return close_uinput("uinput mouse"); } private: - Status submit_relative_motion(const MouseEvent &event) { + OperationStatus submit_relative_motion(const MouseEvent &event) { if (event.x != 0) { if (const auto status = emit_event(EV_REL, REL_X, event.x); !status.ok()) { return status; @@ -752,7 +747,7 @@ namespace lvh::detail { return sync(); } - Status submit_absolute_motion(const MouseEvent &event) { + OperationStatus submit_absolute_motion(const MouseEvent &event) { if (const auto status = emit_event(EV_ABS, ABS_X, scale_absolute_axis(event.x, event.width)); !status.ok()) { return status; } @@ -762,14 +757,14 @@ namespace lvh::detail { return sync(); } - Status submit_button(const MouseEvent &event) { + OperationStatus submit_button(const MouseEvent &event) { if (const auto status = emit_event(EV_KEY, static_cast(mouse_button_to_linux(event.button)), event.pressed ? 1 : 0); !status.ok()) { return status; } return sync(); } - Status submit_vertical_scroll(std::int32_t distance) { + OperationStatus submit_vertical_scroll(std::int32_t distance) { #if defined(REL_WHEEL_HI_RES) if (const auto status = emit_event(EV_REL, REL_WHEEL_HI_RES, distance); !status.ok()) { return status; @@ -782,7 +777,7 @@ namespace lvh::detail { return sync(); } - Status submit_horizontal_scroll(std::int32_t distance) { + OperationStatus submit_horizontal_scroll(std::int32_t distance) { #if defined(REL_HWHEEL_HI_RES) if (const auto status = emit_event(EV_REL, REL_HWHEEL_HI_RES, distance); !status.ok()) { return status; @@ -958,39 +953,39 @@ namespace lvh::detail { static_cast(close()); } - Status create() { + OperationStatus create() { display_ = XOpenDisplay(nullptr); if (display_ == nullptr) { - return Status::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest keyboard fallback"); + return OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest keyboard fallback"); } if (!query_xtest(display_)) { - return Status::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); + return OperationStatus::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); } - return Status::success(); + return OperationStatus::success(); } - Status submit(const KeyboardEvent &event) override { + OperationStatus submit(const KeyboardEvent &event) override { if (display_ == nullptr) { - return Status::failure(ErrorCode::device_closed, "XTest keyboard is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "XTest keyboard is closed"); } const auto keysym = key_code_to_keysym(event.key_code); if (keysym == NoSymbol) { - return Status::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by XTest fallback"); + return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by XTest fallback"); } const auto keycode = XKeysymToKeycode(display_, keysym); if (keycode == 0) { - return Status::failure(ErrorCode::invalid_argument, "keyboard key code has no X11 keycode"); + return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code has no X11 keycode"); } XTestFakeKeyEvent(display_, keycode, event.pressed ? True : False, CurrentTime); XFlush(display_); - return Status::success(); + return OperationStatus::success(); } - Status type_text(const KeyboardTextEvent &event) override { + OperationStatus type_text(const KeyboardTextEvent &event) override { for (const auto codepoint : decode_utf8(event.text)) { const auto hex = uppercase_hex(codepoint); @@ -1031,15 +1026,15 @@ namespace lvh::detail { } } - return Status::success(); + return OperationStatus::success(); } - Status close() override { + OperationStatus close() override { if (display_ != nullptr) { XCloseDisplay(display_); display_ = nullptr; } - return Status::success(); + return OperationStatus::success(); } private: @@ -1057,21 +1052,21 @@ namespace lvh::detail { static_cast(close()); } - Status create() { + OperationStatus create() { display_ = XOpenDisplay(nullptr); if (display_ == nullptr) { - return Status::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest mouse fallback"); + return OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest mouse fallback"); } if (!query_xtest(display_)) { - return Status::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); + return OperationStatus::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); } - return Status::success(); + return OperationStatus::success(); } - Status submit(const MouseEvent &event) override { + OperationStatus submit(const MouseEvent &event) override { if (display_ == nullptr) { - return Status::failure(ErrorCode::device_closed, "XTest mouse is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "XTest mouse is closed"); } switch (event.kind) { @@ -1093,15 +1088,15 @@ namespace lvh::detail { } XFlush(display_); - return Status::success(); + return OperationStatus::success(); } - Status close() override { + OperationStatus close() override { if (display_ != nullptr) { XCloseDisplay(display_); display_ = nullptr; } - return Status::success(); + return OperationStatus::success(); } private: @@ -1148,12 +1143,12 @@ namespace lvh::detail { static_cast(close()); } - Status create(DeviceId id, const CreateGamepadOptions &options) { + OperationStatus create(DeviceId id, const CreateGamepadOptions &options) { uhid_event event {}; auto &request = event.u.create2; if (options.profile.report_descriptor.size() > sizeof(request.rd_data)) { - return Status::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID"); + return OperationStatus::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID"); } event.type = UHID_CREATE2; @@ -1176,17 +1171,17 @@ namespace lvh::detail { reader_ = std::thread {[this]() { read_loop(); }}; - return Status::success(); + return OperationStatus::success(); } - Status submit(const std::vector &report) override { + OperationStatus submit(const std::vector &report) override { if (!open_) { - return Status::failure(ErrorCode::device_closed, "UHID gamepad is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed"); } uhid_event event {}; if (report.size() > sizeof(event.u.input2.data)) { - return Status::failure(ErrorCode::invalid_argument, "HID input report is too large for UHID"); + return OperationStatus::failure(ErrorCode::invalid_argument, "HID input report is too large for UHID"); } event.type = UHID_INPUT2; @@ -1200,14 +1195,14 @@ namespace lvh::detail { output_callback_ = std::move(callback); } - Status close() override { + OperationStatus close() override { if (!open_.exchange(false)) { - return Status::success(); + return OperationStatus::success(); } running_ = false; - auto status = Status::success(); + auto status = OperationStatus::success(); if (fd_ >= 0) { uhid_event event {}; event.type = UHID_DESTROY; @@ -1229,10 +1224,10 @@ namespace lvh::detail { } private: - Status write_event(const uhid_event &event) { + OperationStatus write_event(const uhid_event &event) { std::lock_guard lock {write_mutex_}; if (fd_ < 0) { - return Status::failure(ErrorCode::device_closed, "UHID file descriptor is closed"); + return OperationStatus::failure(ErrorCode::device_closed, "UHID file descriptor is closed"); } const auto result = system_write(fd_, &event, sizeof(event)); @@ -1240,10 +1235,10 @@ namespace lvh::detail { return system_error_status(ErrorCode::backend_failure, "failed to write UHID event", errno); } if (static_cast(result) != sizeof(event)) { - return Status::failure(ErrorCode::backend_failure, "short write while sending UHID event"); + return OperationStatus::failure(ErrorCode::backend_failure, "short write while sending UHID event"); } - return Status::success(); + return OperationStatus::success(); } void read_loop() { @@ -1379,7 +1374,7 @@ namespace lvh::detail { return {status, nullptr}; } - return {Status::success(), std::move(gamepad)}; + return {OperationStatus::success(), std::move(gamepad)}; } BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) override { @@ -1398,7 +1393,7 @@ namespace lvh::detail { return {status, nullptr}; } - return {Status::success(), std::move(keyboard)}; + return {OperationStatus::success(), std::move(keyboard)}; } BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { @@ -1417,7 +1412,7 @@ namespace lvh::detail { return {status, nullptr}; } - return {Status::success(), std::move(mouse)}; + return {OperationStatus::success(), std::move(mouse)}; } private: @@ -1427,9 +1422,9 @@ namespace lvh::detail { if (const auto status = keyboard->create(); !status.ok()) { return {status, nullptr}; } - return {Status::success(), std::move(keyboard)}; + return {OperationStatus::success(), std::move(keyboard)}; #else - return {Status::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; + return {OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; #endif } @@ -1439,9 +1434,9 @@ namespace lvh::detail { if (const auto status = mouse->create(); !status.ok()) { return {status, nullptr}; } - return {Status::success(), std::move(mouse)}; + return {OperationStatus::success(), std::move(mouse)}; #else - return {Status::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; + return {OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; #endif } diff --git a/src/platform/unsupported_backend.cpp b/src/platform/unsupported_backend.cpp index 43b27a2..6b7e097 100644 --- a/src/platform/unsupported_backend.cpp +++ b/src/platform/unsupported_backend.cpp @@ -26,18 +26,18 @@ namespace lvh::detail { } BackendGamepadCreationResult create_gamepad(DeviceId /*id*/, const CreateGamepadOptions & /*options*/) override { - return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; } BackendKeyboardCreationResult create_keyboard( DeviceId /*id*/, const CreateKeyboardOptions & /*options*/ ) override { - return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; } BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions & /*options*/) override { - return {Status::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; } private: diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index c82b755..e9044b9 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -42,7 +42,7 @@ namespace lvh::detail::test { /** * @brief Submit operation status. */ - Status status; + OperationStatus status; /** * @brief Events written to the pipe. @@ -57,17 +57,17 @@ namespace lvh::detail::test { /** * @brief Create operation status. */ - Status create_status; + OperationStatus create_status; /** * @brief Submit operation status. */ - Status submit_status; + OperationStatus submit_status; /** * @brief Close operation status. */ - Status close_status; + OperationStatus close_status; /** * @brief Whether the peer observed a create event. @@ -117,32 +117,32 @@ namespace lvh::detail::test { /** * @brief Gamepad creation status. */ - Status gamepad_status; + OperationStatus gamepad_status; /** * @brief Gamepad close status. */ - Status gamepad_close_status; + OperationStatus gamepad_close_status; /** * @brief Keyboard creation status. */ - Status keyboard_status; + OperationStatus keyboard_status; /** * @brief Keyboard close status. */ - Status keyboard_close_status; + OperationStatus keyboard_close_status; /** * @brief Mouse creation status. */ - Status mouse_status; + OperationStatus mouse_status; /** * @brief Mouse close status. */ - Status mouse_close_status; + OperationStatus mouse_close_status; }; /** @@ -246,7 +246,7 @@ namespace lvh::detail::test { * @param descriptor_size Descriptor size to use. * @return Creation status. */ - Status linux_uhid_create_with_descriptor_size(std::size_t descriptor_size); + OperationStatus linux_uhid_create_with_descriptor_size(std::size_t descriptor_size); /** * @brief Try submitting a UHID input report on an invalid file descriptor. @@ -254,21 +254,21 @@ namespace lvh::detail::test { * @param report_size Input report size to use. * @return Submit status. */ - Status linux_uhid_submit_report_size(std::size_t report_size); + OperationStatus linux_uhid_submit_report_size(std::size_t report_size); /** * @brief Try submitting a UHID input report after closing the backend device. * * @return Submit status. */ - Status linux_uhid_submit_after_close(); + OperationStatus linux_uhid_submit_after_close(); /** * @brief Try creating a uinput keyboard on an invalid file descriptor. * * @return Creation status. */ - Status linux_uinput_keyboard_create_invalid_fd(); + OperationStatus linux_uinput_keyboard_create_invalid_fd(); /** * @brief Submit a keyboard event to a uinput keyboard on an invalid file descriptor. @@ -276,7 +276,7 @@ namespace lvh::detail::test { * @param event Keyboard event. * @return Submit status. */ - Status linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event); + OperationStatus linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event); /** * @brief Submit text to a uinput keyboard on an invalid file descriptor. @@ -284,14 +284,14 @@ namespace lvh::detail::test { * @param text UTF-8 text. * @return Submit status. */ - Status linux_uinput_keyboard_type_text_invalid_fd(const std::string &text); + OperationStatus linux_uinput_keyboard_type_text_invalid_fd(const std::string &text); /** * @brief Try submitting a keyboard event after closing the backend device. * * @return Submit status. */ - Status linux_uinput_keyboard_submit_after_close(); + OperationStatus linux_uinput_keyboard_submit_after_close(); /** * @brief Submit a keyboard event to a pipe-backed uinput keyboard. @@ -306,21 +306,21 @@ namespace lvh::detail::test { * * @return Write status. */ - Status linux_uinput_user_device_invalid_fd(); + OperationStatus linux_uinput_user_device_invalid_fd(); /** * @brief Try writing a uinput device definition to a pipe. * * @return Write status. */ - Status linux_uinput_user_device_pipe(); + OperationStatus linux_uinput_user_device_pipe(); /** * @brief Try creating a uinput mouse on an invalid file descriptor. * * @return Creation status. */ - Status linux_uinput_mouse_create_invalid_fd(); + OperationStatus linux_uinput_mouse_create_invalid_fd(); /** * @brief Submit a mouse event to a uinput mouse on an invalid file descriptor. @@ -328,14 +328,14 @@ namespace lvh::detail::test { * @param event Mouse event. * @return Submit status. */ - Status linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event); + OperationStatus linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event); /** * @brief Try submitting a mouse event after closing the backend device. * * @return Submit status. */ - Status linux_uinput_mouse_submit_after_close(); + OperationStatus linux_uinput_mouse_submit_after_close(); /** * @brief Submit a mouse event to a pipe-backed uinput mouse. @@ -371,112 +371,112 @@ namespace lvh::detail::test { * * @return Creation status. */ - Status linux_backend_gamepad_fake_open_failure(); + OperationStatus linux_backend_gamepad_fake_open_failure(); /** * @brief Try creating a Linux backend gamepad while fake UHID creation fails. * * @return Creation status. */ - Status linux_backend_gamepad_fake_create_failure(); + OperationStatus linux_backend_gamepad_fake_create_failure(); /** * @brief Try creating a Linux backend keyboard while fake open fails. * * @return Creation status. */ - Status linux_backend_keyboard_fake_open_failure(); + OperationStatus linux_backend_keyboard_fake_open_failure(); /** * @brief Try creating a Linux backend keyboard while fake uinput creation fails. * * @return Creation status. */ - Status linux_backend_keyboard_fake_create_failure(); + OperationStatus linux_backend_keyboard_fake_create_failure(); /** * @brief Create a Linux backend keyboard through a fake successful fallback after uinput creation fails. * * @return Creation status. */ - Status linux_backend_keyboard_fake_fallback_success(); + OperationStatus linux_backend_keyboard_fake_fallback_success(); /** * @brief Try creating a Linux backend mouse while fake open fails. * * @return Creation status. */ - Status linux_backend_mouse_fake_open_failure(); + OperationStatus linux_backend_mouse_fake_open_failure(); /** * @brief Try creating a Linux backend mouse while fake uinput creation fails. * * @return Creation status. */ - Status linux_backend_mouse_fake_create_failure(); + OperationStatus linux_backend_mouse_fake_create_failure(); /** * @brief Create a Linux backend mouse through a fake successful fallback after uinput creation fails. * * @return Creation status. */ - Status linux_backend_mouse_fake_fallback_success(); + OperationStatus linux_backend_mouse_fake_fallback_success(); /** * @brief Try submitting a UHID input report while fake write fails. * * @return Submit status. */ - Status linux_uhid_submit_fake_write_failure(); + OperationStatus linux_uhid_submit_fake_write_failure(); /** * @brief Try submitting a UHID input report while fake write is short. * * @return Submit status. */ - Status linux_uhid_submit_fake_short_write(); + OperationStatus linux_uhid_submit_fake_short_write(); /** * @brief Try closing a UHID gamepad while fake destroy write fails. * * @return Close status. */ - Status linux_uhid_close_fake_write_failure(); + OperationStatus linux_uhid_close_fake_write_failure(); /** * @brief Try closing a UHID gamepad while fake close fails. * * @return Close status. */ - Status linux_uhid_close_fake_close_failure(); + OperationStatus linux_uhid_close_fake_close_failure(); /** * @brief Exercise UHID read-loop timeout and retry branches using fake poll/read syscalls. * * @return Close status after the scripted read loop exits. */ - Status linux_uhid_read_loop_fake_retry_branches(); + OperationStatus linux_uhid_read_loop_fake_retry_branches(); /** * @brief Exercise UHID read-loop poll error branches using fake poll syscalls. * * @return Close status after the scripted read loop exits. */ - Status linux_uhid_read_loop_fake_poll_errors(); + OperationStatus linux_uhid_read_loop_fake_poll_errors(); /** * @brief Exercise UHID read-loop read error branches using fake read syscalls. * * @return Close status after the scripted read loop exits. */ - Status linux_uhid_read_loop_fake_read_error(); + OperationStatus linux_uhid_read_loop_fake_read_error(); /** * @brief Exercise UHID read-loop output handling when no callback is registered. * * @return Close status after the scripted read loop exits. */ - Status linux_uhid_read_loop_fake_output_without_callback(); + OperationStatus linux_uhid_read_loop_fake_output_without_callback(); /** * @brief Try creating a uinput keyboard while a fake ioctl call fails. @@ -484,49 +484,49 @@ namespace lvh::detail::test { * @param fail_ioctl_call One-based ioctl call to fail. * @return Creation status. */ - Status linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call); + OperationStatus linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call); /** * @brief Try writing a uinput device definition while fake write is short. * * @return Write status. */ - Status linux_uinput_user_device_fake_short_write(); + OperationStatus linux_uinput_user_device_fake_short_write(); /** * @brief Try writing a uinput device definition while fake device creation ioctl fails. * * @return Write status. */ - Status linux_uinput_user_device_fake_create_failure(); + OperationStatus linux_uinput_user_device_fake_create_failure(); /** * @brief Submit a keyboard event while fake write fails. * * @return Submit status. */ - Status linux_uinput_keyboard_submit_fake_write_failure(); + OperationStatus linux_uinput_keyboard_submit_fake_write_failure(); /** * @brief Submit a keyboard event while fake write is short. * * @return Submit status. */ - Status linux_uinput_keyboard_submit_fake_short_write(); + OperationStatus linux_uinput_keyboard_submit_fake_short_write(); /** * @brief Submit text through a fake successful uinput keyboard. * * @return Submit status. */ - Status linux_uinput_keyboard_type_text_fake_success(); + OperationStatus linux_uinput_keyboard_type_text_fake_success(); /** * @brief Close a uinput keyboard while fake close fails. * * @return Close status. */ - Status linux_uinput_keyboard_close_fake_close_failure(); + OperationStatus linux_uinput_keyboard_close_fake_close_failure(); /** * @brief Try creating a uinput mouse while a fake ioctl call fails. @@ -534,7 +534,7 @@ namespace lvh::detail::test { * @param fail_ioctl_call One-based ioctl call to fail. * @return Creation status. */ - Status linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call); + OperationStatus linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call); /** * @brief Submit a mouse event while fake write fails. @@ -542,7 +542,7 @@ namespace lvh::detail::test { * @param event Mouse event to submit. * @return Submit status. */ - Status linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event); + OperationStatus linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event); /** * @brief Submit a mouse event while fake write is short. @@ -550,6 +550,6 @@ namespace lvh::detail::test { * @param event Mouse event to submit. * @return Submit status. */ - Status linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event); + OperationStatus linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event); } // namespace lvh::detail::test diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 84fd76b..afcc97d 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -30,12 +30,9 @@ #if defined(LIBVIRTUALHID_HAVE_XTEST) #include + #include #include - - // Xlib defines Status as a macro, which collides with lvh::Status declarations. - #if defined(Status) - #undef Status - #endif + #include #endif #endif @@ -257,6 +254,16 @@ int lvh_linux_test_xtest_fake_relative_motion_event(Display *, int, int, unsigne #define write lvh_linux_test_write #if defined(LIBVIRTUALHID_HAVE_XTEST) + #if defined(DefaultScreen) + #undef DefaultScreen + #endif + #if defined(DisplayHeight) + #undef DisplayHeight + #endif + #if defined(DisplayWidth) + #undef DisplayWidth + #endif + #define DefaultScreen lvh_linux_test_default_screen #define DisplayHeight lvh_linux_test_display_height #define DisplayWidth lvh_linux_test_display_width @@ -372,7 +379,7 @@ namespace lvh::detail::test { return false; } - Status run_fake_uhid_read_loop(LinuxTestSyscalls &syscalls, int expected_poll_calls) { + OperationStatus run_fake_uhid_read_loop(LinuxTestSyscalls &syscalls, int expected_poll_calls) { syscalls.override_write = true; ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; @@ -393,7 +400,7 @@ namespace lvh::detail::test { const auto saw_expected_polls = wait_for_poll_calls(syscalls, expected_poll_calls); const auto close_status = gamepad.close(); if (!saw_expected_polls) { - return Status::failure(ErrorCode::backend_failure, "fake UHID read loop did not consume the scripted poll calls"); + return OperationStatus::failure(ErrorCode::backend_failure, "fake UHID read loop did not consume the scripted poll calls"); } return close_status; } @@ -452,7 +459,7 @@ namespace lvh::detail::test { return sizeof(event.u.input2.data); } - Status linux_uhid_create_with_descriptor_size(std::size_t descriptor_size) { + OperationStatus linux_uhid_create_with_descriptor_size(std::size_t descriptor_size) { auto profile = profiles::generic_gamepad(); profile.report_descriptor.assign(descriptor_size, 0); @@ -463,18 +470,18 @@ namespace lvh::detail::test { return gamepad.create(1, options); } - Status linux_uhid_submit_report_size(std::size_t report_size) { + OperationStatus linux_uhid_submit_report_size(std::size_t report_size) { UhidGamepad gamepad {-1}; return gamepad.submit(std::vector(report_size, 0)); } - Status linux_uhid_submit_after_close() { + OperationStatus linux_uhid_submit_after_close() { UhidGamepad gamepad {-1}; static_cast(gamepad.close()); return gamepad.submit({0}); } - Status linux_uinput_keyboard_create_invalid_fd() { + OperationStatus linux_uinput_keyboard_create_invalid_fd() { CreateKeyboardOptions options; options.profile = profiles::keyboard(); @@ -482,17 +489,17 @@ namespace lvh::detail::test { return keyboard.create(1, options); } - Status linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event) { + OperationStatus linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event) { UinputKeyboard keyboard {-1}; return keyboard.submit(event); } - Status linux_uinput_keyboard_type_text_invalid_fd(const std::string &text) { + OperationStatus linux_uinput_keyboard_type_text_invalid_fd(const std::string &text) { UinputKeyboard keyboard {-1}; return keyboard.type_text({.text = text}); } - Status linux_uinput_keyboard_submit_after_close() { + OperationStatus linux_uinput_keyboard_submit_after_close() { UinputKeyboard keyboard {-1}; static_cast(keyboard.close()); return keyboard.submit({.key_code = 0x41, .pressed = true}); @@ -512,11 +519,11 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } - Status linux_uinput_user_device_invalid_fd() { + OperationStatus linux_uinput_user_device_invalid_fd() { return write_uinput_user_device(-1, profiles::mouse(), 1); } - Status linux_uinput_user_device_pipe() { + OperationStatus linux_uinput_user_device_pipe() { int descriptors[2] {-1, -1}; if (::pipe(descriptors) != 0) { return system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno); @@ -528,7 +535,7 @@ namespace lvh::detail::test { return status; } - Status linux_uinput_mouse_create_invalid_fd() { + OperationStatus linux_uinput_mouse_create_invalid_fd() { CreateMouseOptions options; options.profile = profiles::mouse(); @@ -536,12 +543,12 @@ namespace lvh::detail::test { return mouse.create(1, options); } - Status linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event) { + OperationStatus linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event) { UinputMouse mouse {-1}; return mouse.submit(event); } - Status linux_uinput_mouse_submit_after_close() { + OperationStatus linux_uinput_mouse_submit_after_close() { UinputMouse mouse {-1}; static_cast(mouse.close()); return mouse.submit({.kind = MouseEventKind::relative_motion, .x = 1, .y = 1}); @@ -692,7 +699,7 @@ namespace lvh::detail::test { return backend.capabilities(); } - Status linux_backend_gamepad_fake_open_failure() { + OperationStatus linux_backend_gamepad_fake_open_failure() { LinuxTestSyscalls syscalls; syscalls.override_access = true; syscalls.override_open = true; @@ -706,7 +713,7 @@ namespace lvh::detail::test { return backend.create_gamepad(1, options).status; } - Status linux_backend_gamepad_fake_create_failure() { + OperationStatus linux_backend_gamepad_fake_create_failure() { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.fail_write_call = 1; @@ -719,7 +726,7 @@ namespace lvh::detail::test { return backend.create_gamepad(1, options).status; } - Status linux_backend_keyboard_fake_open_failure() { + OperationStatus linux_backend_keyboard_fake_open_failure() { LinuxTestSyscalls syscalls; syscalls.override_access = true; syscalls.override_open = true; @@ -733,7 +740,7 @@ namespace lvh::detail::test { return backend.create_keyboard(1, options).status; } - Status linux_backend_keyboard_fake_create_failure() { + OperationStatus linux_backend_keyboard_fake_create_failure() { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.fail_ioctl_call = 1; @@ -746,7 +753,7 @@ namespace lvh::detail::test { return backend.create_keyboard(1, options).status; } - Status linux_backend_keyboard_fake_fallback_success() { + OperationStatus linux_backend_keyboard_fake_fallback_success() { #if defined(LIBVIRTUALHID_HAVE_XTEST) LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); @@ -763,11 +770,11 @@ namespace lvh::detail::test { } return keyboard.keyboard->close(); #else - return Status::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); + return OperationStatus::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); #endif } - Status linux_backend_mouse_fake_open_failure() { + OperationStatus linux_backend_mouse_fake_open_failure() { LinuxTestSyscalls syscalls; syscalls.override_access = true; syscalls.override_open = true; @@ -781,7 +788,7 @@ namespace lvh::detail::test { return backend.create_mouse(1, options).status; } - Status linux_backend_mouse_fake_create_failure() { + OperationStatus linux_backend_mouse_fake_create_failure() { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); syscalls.fail_ioctl_call = 1; @@ -794,7 +801,7 @@ namespace lvh::detail::test { return backend.create_mouse(1, options).status; } - Status linux_backend_mouse_fake_fallback_success() { + OperationStatus linux_backend_mouse_fake_fallback_success() { #if defined(LIBVIRTUALHID_HAVE_XTEST) LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); @@ -811,11 +818,11 @@ namespace lvh::detail::test { } return mouse.mouse->close(); #else - return Status::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); + return OperationStatus::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); #endif } - Status linux_uhid_submit_fake_write_failure() { + OperationStatus linux_uhid_submit_fake_write_failure() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.fail_write_call = 1; @@ -825,7 +832,7 @@ namespace lvh::detail::test { return gamepad.submit({0}); } - Status linux_uhid_submit_fake_short_write() { + OperationStatus linux_uhid_submit_fake_short_write() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.short_write_call = 1; @@ -835,7 +842,7 @@ namespace lvh::detail::test { return gamepad.submit({0}); } - Status linux_uhid_close_fake_write_failure() { + OperationStatus linux_uhid_close_fake_write_failure() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.fail_write_call = 1; @@ -845,7 +852,7 @@ namespace lvh::detail::test { return gamepad.close(); } - Status linux_uhid_close_fake_close_failure() { + OperationStatus linux_uhid_close_fake_close_failure() { LinuxTestSyscalls syscalls; syscalls.override_write = true; ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; @@ -854,7 +861,7 @@ namespace lvh::detail::test { return gamepad.close(); } - Status linux_uhid_read_loop_fake_retry_branches() { + OperationStatus linux_uhid_read_loop_fake_retry_branches() { LinuxTestSyscalls syscalls; syscalls.override_poll = true; syscalls.poll_results = {-1, 0, 1, 1, 1}; @@ -866,7 +873,7 @@ namespace lvh::detail::test { return run_fake_uhid_read_loop(syscalls, 5); } - Status linux_uhid_read_loop_fake_poll_errors() { + OperationStatus linux_uhid_read_loop_fake_poll_errors() { LinuxTestSyscalls syscall_failure; syscall_failure.override_poll = true; syscall_failure.poll_results = {-1}; @@ -882,7 +889,7 @@ namespace lvh::detail::test { return run_fake_uhid_read_loop(event_failure, 1); } - Status linux_uhid_read_loop_fake_read_error() { + OperationStatus linux_uhid_read_loop_fake_read_error() { LinuxTestSyscalls syscalls; syscalls.override_poll = true; syscalls.poll_results = {1}; @@ -893,7 +900,7 @@ namespace lvh::detail::test { return run_fake_uhid_read_loop(syscalls, 1); } - Status linux_uhid_read_loop_fake_output_without_callback() { + OperationStatus linux_uhid_read_loop_fake_output_without_callback() { LinuxTestSyscalls syscalls; syscalls.override_poll = true; syscalls.poll_results = {1, 1}; @@ -904,7 +911,7 @@ namespace lvh::detail::test { return run_fake_uhid_read_loop(syscalls, 2); } - Status linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call) { + OperationStatus linux_uinput_keyboard_create_fake_ioctl_failure(int fail_ioctl_call) { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.override_ioctl = true; @@ -918,7 +925,7 @@ namespace lvh::detail::test { return keyboard.create(1, options); } - Status linux_uinput_user_device_fake_short_write() { + OperationStatus linux_uinput_user_device_fake_short_write() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.short_write_call = 1; @@ -927,7 +934,7 @@ namespace lvh::detail::test { return write_uinput_user_device(fake_fd, profiles::mouse(), 1); } - Status linux_uinput_user_device_fake_create_failure() { + OperationStatus linux_uinput_user_device_fake_create_failure() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.override_ioctl = true; @@ -937,7 +944,7 @@ namespace lvh::detail::test { return write_uinput_user_device(fake_fd, profiles::mouse(), 1); } - Status linux_uinput_keyboard_submit_fake_write_failure() { + OperationStatus linux_uinput_keyboard_submit_fake_write_failure() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.fail_write_call = 1; @@ -948,7 +955,7 @@ namespace lvh::detail::test { return keyboard.submit({.key_code = 0x41, .pressed = true}); } - Status linux_uinput_keyboard_submit_fake_short_write() { + OperationStatus linux_uinput_keyboard_submit_fake_short_write() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.short_write_call = 1; @@ -959,7 +966,7 @@ namespace lvh::detail::test { return keyboard.submit({.key_code = 0x41, .pressed = true}); } - Status linux_uinput_keyboard_type_text_fake_success() { + OperationStatus linux_uinput_keyboard_type_text_fake_success() { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.override_ioctl = true; @@ -969,7 +976,7 @@ namespace lvh::detail::test { return keyboard.type_text({.text = "A"}); } - Status linux_uinput_keyboard_close_fake_close_failure() { + OperationStatus linux_uinput_keyboard_close_fake_close_failure() { LinuxTestSyscalls syscalls; syscalls.override_ioctl = true; ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; @@ -978,7 +985,7 @@ namespace lvh::detail::test { return keyboard.close(); } - Status linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call) { + OperationStatus linux_uinput_mouse_create_fake_ioctl_failure(int fail_ioctl_call) { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.override_ioctl = true; @@ -992,7 +999,7 @@ namespace lvh::detail::test { return mouse.create(1, options); } - Status linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event) { + OperationStatus linux_uinput_mouse_submit_fake_write_failure(const MouseEvent &event) { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.fail_write_call = 1; @@ -1003,7 +1010,7 @@ namespace lvh::detail::test { return mouse.submit(event); } - Status linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event) { + OperationStatus linux_uinput_mouse_submit_fake_short_write(const MouseEvent &event) { LinuxTestSyscalls syscalls; syscalls.override_write = true; syscalls.short_write_call = 1; From 29116b71b4690acac450dd83f9fdb55eead1094c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:49:44 -0400 Subject: [PATCH 26/28] Adjust test expectations for XTEST feature Update tests/unit/test_linux_backend.cpp to conditionally check for keyboard, mouse and XTEST fallback support when LIBVIRTUALHID_HAVE_XTEST is defined. Also reorder the output_reports expectation. This makes the fake Linux backend test adapt to builds with or without XTEST enabled so test expectations match configuration. --- tests/unit/test_linux_backend.cpp | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index ab7475a..a9ae500 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -339,9 +339,16 @@ TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { const auto unavailable = lvh::detail::test::linux_backend_fake_unavailable_capabilities(); EXPECT_FALSE(unavailable.supports_virtual_hid); EXPECT_FALSE(unavailable.supports_gamepad); + EXPECT_FALSE(unavailable.supports_output_reports); + #if defined(LIBVIRTUALHID_HAVE_XTEST) + EXPECT_TRUE(unavailable.supports_keyboard); + EXPECT_TRUE(unavailable.supports_mouse); + EXPECT_TRUE(unavailable.supports_xtest_fallback); + #else EXPECT_FALSE(unavailable.supports_keyboard); EXPECT_FALSE(unavailable.supports_mouse); - EXPECT_FALSE(unavailable.supports_output_reports); + EXPECT_FALSE(unavailable.supports_xtest_fallback); + #endif EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_open_failure().code(), lvh::ErrorCode::backend_unavailable); EXPECT_EQ(lvh::detail::test::linux_backend_gamepad_fake_create_failure().code(), lvh::ErrorCode::backend_failure); From 1315acbce373e218122de17320744ee4e08b1ae7 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:10:51 -0400 Subject: [PATCH 27/28] Add touchscreen/trackpad/pen-tablet device support Introduce first-class touchscreen, trackpad, and pen-tablet device types across the library. Public API: new DeviceType entries, DeviceNode reporting, Create*Options, creation results, and Runtime create_touchscreen/create_trackpad/create_pen_tablet overloads. Runtime/VirtualDevice: new Touchscreen/Trackpad/PenTablet handle classes with submit APIs (place_contact/release_contact/button/place_tool/button) and device_nodes accessors. Backend: new BackendTouchscreen/BackendTrackpad/BackendPenTablet interfaces, backend creation results, device_nodes hooks, and in-memory Fake* backends for tests; default in-memory backend capability flags updated. Profiles/reports: add DualSense USB/Bluetooth profiles, USB descriptor, report packing/parsing helpers, and parse_output_reports API; also add generic touchscreen/trackpad/pen-tablet built-in profiles. Types: add DeviceNode/DeviceNodeKind, touch/pen structures, battery/motion types, gamepad touch contact, auto-repeat interval, and various enums/fields required by new devices and DualSense features. Docs/tests: update README with Phase 2B notes and new device types; update unit tests accordingly. --- README.md | 41 + include/libvirtualhid/profiles.hpp | 37 +- include/libvirtualhid/report.hpp | 9 + include/libvirtualhid/runtime.hpp | 451 ++++++++++ include/libvirtualhid/types.hpp | 306 +++++++ src/core/backend.cpp | 79 ++ src/core/backend.hpp | 288 +++++++ src/core/profiles.cpp | 347 +++++++- src/core/report.cpp | 262 +++++- src/core/runtime.cpp | 542 +++++++++++- src/platform/linux/uhid_backend.cpp | 802 +++++++++++++++++- src/platform/unsupported_backend.cpp | 18 + .../fixtures/linux_backend_test_hooks.hpp | 24 + tests/fixtures/linux_backend_test_hooks.cpp | 60 ++ tests/unit/test_linux_backend.cpp | 66 ++ tests/unit/test_profiles.cpp | 20 +- tests/unit/test_report.cpp | 63 ++ tests/unit/test_runtime.cpp | 65 ++ 18 files changed, 3442 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index cf97d09..6591f95 100644 --- a/README.md +++ b/README.md @@ -212,8 +212,13 @@ Expected core types: - `Keyboard`: key press/release and UTF-8 text submission. - `Mouse`: relative motion, absolute motion, button, vertical scroll, and horizontal scroll submission. +- `Touchscreen`: direct multi-touch contacts for touch displays. +- `Trackpad`: indirect multi-touch contacts and click state for touchpads. +- `PenTablet`: tablet tool, pressure, distance, tilt, and pen button state. - `DeviceProfile`: VID/PID, product strings, bus type, HID descriptor, report layout, and platform capability metadata. +- `DeviceNode`: platform-reported device nodes and sysfs paths for consumers + that must hand created devices to SDL, libinput, HIDAPI, or diagnostics. - `GamepadState`: normalized buttons, axes, triggers, hats, motion sensors, and optional touchpad data. - `GamepadOutput`: normalized rumble, haptics, LEDs, adaptive triggers, and raw @@ -247,10 +252,21 @@ the requirements expressed in terms that apply to other consumers: - [x] Keyboard and mouse APIs should map cleanly to common relative mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, and Unicode paths. +- [ ] Linux keyboard support must include configurable auto-repeat for held keys + so streaming hosts can preserve input behavior previously covered by + inputtino. +- [ ] Linux devices must expose created device nodes and relevant sysfs paths + for consumers and diagnostics that need to inspect or pass those paths onward. - [x] Linux fallback behavior should match streaming-host operational expectations: prefer real virtual devices through `uhid`/`uinput`; only use XTest for keyboard/mouse when virtual device creation fails and X11 is available. +- [ ] Linux gamepad support must reach inputtino parity before replacement: + real DualSense UHID descriptors, GET_REPORT replies, periodic input reports, + touchpad, motion, battery, RGB LED, adaptive trigger callbacks, CRC handling, + and uinput force-feedback handling where a uinput gamepad path is used. +- [ ] Linux pointer support must cover touchscreen, trackpad, and pen tablet + virtual devices with libinput-observable behavior. - [x] The library must not own a consumer's network protocol, client packet parsing, configuration system, or feedback queue. It should expose the device primitives consumers need to keep that ownership in their applications. @@ -327,6 +343,31 @@ third-party/googletest/ GoogleTest submodule through SDL2 for gamepads and libinput for keyboard/mouse. - [x] Document required Linux permissions and sample udev rules. +### Phase 2B: Linux inputtino Parity + +- [ ] Replace the generic DualSense profile behavior with a real UHID DualSense + backend path, including USB/Bluetooth descriptors, MAC/uniq identity, + calibration, pairing, firmware, CRC, and periodic report handling. +- [ ] Add DualSense input state for motion sensors, touchpad contacts, battery + state, and profile-specific buttons without leaking Linux-specific details + into consumers. +- [ ] Parse DualSense output reports into rumble, RGB LED, adaptive trigger, and + raw-report callbacks. +- [ ] Expose created device nodes and sysfs paths through the platform-neutral + public API. +- [ ] Add configurable keyboard auto-repeat for held keys. +- [ ] Add touchscreen, trackpad, and pen tablet public device types and Linux + uinput/libevdev backend implementations. +- [ ] Add uinput force-feedback event handling for any uinput-backed gamepad + path, including uploaded effect tracking and gain handling. +- [ ] Prefer libevdev for uinput device construction where it removes fragile + direct ioctl setup, while keeping the public API unchanged. +- [ ] Expand Linux consumer tests so SDL2 validates controller-specific behavior + and libinput validates keyboard, mouse, touchscreen, trackpad, and pen tablet + events. +- [ ] Defer C, Python, and Rust bindings until after the platform API is stable, + likely after macOS support lands. + ### Phase 3: Windows MVP - [ ] Build a UMDF2 HID minidriver package with CMake/WDK integration. diff --git a/include/libvirtualhid/profiles.hpp b/include/libvirtualhid/profiles.hpp index a9c6712..e8f8da9 100644 --- a/include/libvirtualhid/profiles.hpp +++ b/include/libvirtualhid/profiles.hpp @@ -44,10 +44,24 @@ namespace lvh::profiles { /** * @brief Create the PlayStation DualSense-compatible gamepad profile. * - * @return DualSense-compatible device profile. + * @return Default DualSense-compatible device profile. */ DeviceProfile dualsense(); + /** + * @brief Create the USB PlayStation DualSense-compatible gamepad profile. + * + * @return USB DualSense-compatible device profile. + */ + DeviceProfile dualsense_usb(); + + /** + * @brief Create the Bluetooth PlayStation DualSense-compatible gamepad profile. + * + * @return Bluetooth DualSense-compatible device profile. + */ + DeviceProfile dualsense_bluetooth(); + /** * @brief Create the Nintendo Switch Pro-compatible gamepad profile. * @@ -69,6 +83,27 @@ namespace lvh::profiles { */ DeviceProfile mouse(); + /** + * @brief Create the generic touchscreen profile. + * + * @return Generic touchscreen device profile. + */ + DeviceProfile touchscreen(); + + /** + * @brief Create the generic trackpad profile. + * + * @return Generic trackpad device profile. + */ + DeviceProfile trackpad(); + + /** + * @brief Create the generic pen tablet profile. + * + * @return Generic pen tablet device profile. + */ + DeviceProfile pen_tablet(); + /** * @brief Look up a built-in gamepad profile by kind. * diff --git a/include/libvirtualhid/report.hpp b/include/libvirtualhid/report.hpp index 56ce466..c1ed40a 100644 --- a/include/libvirtualhid/report.hpp +++ b/include/libvirtualhid/report.hpp @@ -79,4 +79,13 @@ namespace lvh::reports { */ GamepadOutput parse_output_report(const DeviceProfile &profile, const std::vector &report); + /** + * @brief Parse a backend output report into zero or more profile-neutral output events. + * + * @param profile Device profile used for report identity and capabilities. + * @param report Raw HID output report bytes. + * @return Parsed gamepad outputs. Unrecognized reports are returned as one raw-report event. + */ + std::vector parse_output_reports(const DeviceProfile &profile, const std::vector &report); + } // namespace lvh::reports diff --git a/include/libvirtualhid/runtime.hpp b/include/libvirtualhid/runtime.hpp index 5a615f8..87ede22 100644 --- a/include/libvirtualhid/runtime.hpp +++ b/include/libvirtualhid/runtime.hpp @@ -18,6 +18,9 @@ namespace lvh { struct GamepadDevice; struct KeyboardDevice; struct MouseDevice; + struct TouchscreenDevice; + struct TrackpadDevice; + struct PenTabletDevice; class RuntimeState; } // namespace detail @@ -52,6 +55,13 @@ namespace lvh { */ virtual bool is_open() const = 0; + /** + * @brief Get platform-visible nodes associated with the device. + * + * @return Device nodes and diagnostic paths currently known to the backend. + */ + virtual std::vector device_nodes() const = 0; + /** * @brief Close the virtual device. * @@ -119,6 +129,11 @@ namespace lvh { */ bool is_open() const override; + /** + * @copydoc VirtualDevice::device_nodes + */ + std::vector device_nodes() const override; + /** * @copydoc VirtualDevice::close */ @@ -228,6 +243,11 @@ namespace lvh { */ bool is_open() const override; + /** + * @copydoc VirtualDevice::device_nodes + */ + std::vector device_nodes() const override; + /** * @copydoc VirtualDevice::close */ @@ -339,6 +359,11 @@ namespace lvh { */ bool is_open() const override; + /** + * @copydoc VirtualDevice::device_nodes + */ + std::vector device_nodes() const override; + /** * @copydoc VirtualDevice::close */ @@ -419,6 +444,315 @@ namespace lvh { std::shared_ptr device_; }; + /** + * @brief Virtual touchscreen device handle. + */ + class Touchscreen final: public VirtualDevice { + public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ + Touchscreen(const Touchscreen &) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This touchscreen handle. + */ + Touchscreen &operator=(const Touchscreen &) = delete; + + /** + * @brief Move construct a touchscreen handle. + * + * @param other Handle to move from. + */ + Touchscreen(Touchscreen &&other) noexcept; + + /** + * @brief Move assign a touchscreen handle. + * + * @param other Handle to move from. + * @return This touchscreen handle. + */ + Touchscreen &operator=(Touchscreen &&other) noexcept; + + /** + * @brief Destroy the touchscreen handle. + */ + ~Touchscreen() override; + + /** + * @copydoc VirtualDevice::device_id + */ + DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ + const DeviceProfile &profile() const override; + + /** + * @copydoc VirtualDevice::is_open + */ + bool is_open() const override; + + /** + * @copydoc VirtualDevice::device_nodes + */ + std::vector device_nodes() const override; + + /** + * @copydoc VirtualDevice::close + */ + OperationStatus close() override; + + /** + * @brief Place or move a touch contact. + * + * @param contact Touch contact state. + * @return Submit operation status. + */ + OperationStatus place_contact(const TouchContact &contact); + + /** + * @brief Release a touch contact. + * + * @param contact_id Consumer-stable contact identifier. + * @return Submit operation status. + */ + OperationStatus release_contact(std::int32_t contact_id); + + /** + * @brief Get the most recently submitted touch contact. + * + * @return Last submitted touch contact. + */ + TouchContact last_submitted_contact() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ + std::size_t submit_count() const; + + private: + friend class Runtime; + + explicit Touchscreen(std::shared_ptr device); + + std::shared_ptr device_; + }; + + /** + * @brief Virtual trackpad device handle. + */ + class Trackpad final: public VirtualDevice { + public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ + Trackpad(const Trackpad &) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This trackpad handle. + */ + Trackpad &operator=(const Trackpad &) = delete; + + /** + * @brief Move construct a trackpad handle. + * + * @param other Handle to move from. + */ + Trackpad(Trackpad &&other) noexcept; + + /** + * @brief Move assign a trackpad handle. + * + * @param other Handle to move from. + * @return This trackpad handle. + */ + Trackpad &operator=(Trackpad &&other) noexcept; + + /** + * @brief Destroy the trackpad handle. + */ + ~Trackpad() override; + + /** + * @copydoc VirtualDevice::device_id + */ + DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ + const DeviceProfile &profile() const override; + + /** + * @copydoc VirtualDevice::is_open + */ + bool is_open() const override; + + /** + * @copydoc VirtualDevice::device_nodes + */ + std::vector device_nodes() const override; + + /** + * @copydoc VirtualDevice::close + */ + OperationStatus close() override; + + /** + * @brief Place or move a trackpad contact. + * + * @param contact Touch contact state. + * @return Submit operation status. + */ + OperationStatus place_contact(const TouchContact &contact); + + /** + * @brief Release a trackpad contact. + * + * @param contact_id Consumer-stable contact identifier. + * @return Submit operation status. + */ + OperationStatus release_contact(std::int32_t contact_id); + + /** + * @brief Submit a physical trackpad button transition. + * + * @param pressed Whether the primary trackpad button is pressed. + * @return Submit operation status. + */ + OperationStatus button(bool pressed); + + /** + * @brief Get the most recently submitted touch contact. + * + * @return Last submitted touch contact. + */ + TouchContact last_submitted_contact() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ + std::size_t submit_count() const; + + private: + friend class Runtime; + + explicit Trackpad(std::shared_ptr device); + + std::shared_ptr device_; + }; + + /** + * @brief Virtual pen tablet device handle. + */ + class PenTablet final: public VirtualDevice { + public: + /** + * @brief Copy construction is disabled because the handle owns device lifetime. + */ + PenTablet(const PenTablet &) = delete; + + /** + * @brief Copy assignment is disabled because the handle owns device lifetime. + * + * @return This pen tablet handle. + */ + PenTablet &operator=(const PenTablet &) = delete; + + /** + * @brief Move construct a pen tablet handle. + * + * @param other Handle to move from. + */ + PenTablet(PenTablet &&other) noexcept; + + /** + * @brief Move assign a pen tablet handle. + * + * @param other Handle to move from. + * @return This pen tablet handle. + */ + PenTablet &operator=(PenTablet &&other) noexcept; + + /** + * @brief Destroy the pen tablet handle. + */ + ~PenTablet() override; + + /** + * @copydoc VirtualDevice::device_id + */ + DeviceId device_id() const override; + + /** + * @copydoc VirtualDevice::profile + */ + const DeviceProfile &profile() const override; + + /** + * @copydoc VirtualDevice::is_open + */ + bool is_open() const override; + + /** + * @copydoc VirtualDevice::device_nodes + */ + std::vector device_nodes() const override; + + /** + * @copydoc VirtualDevice::close + */ + OperationStatus close() override; + + /** + * @brief Place or move the active tablet tool. + * + * @param state Tool state. + * @return Submit operation status. + */ + OperationStatus place_tool(const PenToolState &state); + + /** + * @brief Submit a tablet button transition. + * + * @param button Button to update. + * @param pressed Whether the button is pressed. + * @return Submit operation status. + */ + OperationStatus button(PenButton button, bool pressed); + + /** + * @brief Get the most recently submitted tool state. + * + * @return Last submitted tool state. + */ + PenToolState last_submitted_tool() const; + + /** + * @brief Get the number of successful submit operations. + * + * @return Submit count. + */ + std::size_t submit_count() const; + + private: + friend class Runtime; + + explicit PenTablet(std::shared_ptr device); + + std::shared_ptr device_; + }; + /** * @brief Result returned by gamepad creation. */ @@ -491,6 +825,78 @@ namespace lvh { } }; + /** + * @brief Result returned by touchscreen creation. + */ + struct TouchscreenCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Created touchscreen handle when creation succeeds. + */ + std::unique_ptr touchscreen; + + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && touchscreen != nullptr; + } + }; + + /** + * @brief Result returned by trackpad creation. + */ + struct TrackpadCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Created trackpad handle when creation succeeds. + */ + std::unique_ptr trackpad; + + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && trackpad != nullptr; + } + }; + + /** + * @brief Result returned by pen tablet creation. + */ + struct PenTabletCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Created pen tablet handle when creation succeeds. + */ + std::unique_ptr pen_tablet; + + /** + * @brief Check whether creation succeeded and produced a handle. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && pen_tablet != nullptr; + } + }; + /** * @brief Runtime that owns backend state and creates virtual devices. */ @@ -596,6 +1002,51 @@ namespace lvh { */ MouseCreationResult create_mouse(const CreateMouseOptions &options); + /** + * @brief Create a touchscreen with the built-in touchscreen profile. + * + * @return Touchscreen creation result. + */ + TouchscreenCreationResult create_touchscreen(); + + /** + * @brief Create a touchscreen from full creation options. + * + * @param options Touchscreen creation options. + * @return Touchscreen creation result. + */ + TouchscreenCreationResult create_touchscreen(const CreateTouchscreenOptions &options); + + /** + * @brief Create a trackpad with the built-in trackpad profile. + * + * @return Trackpad creation result. + */ + TrackpadCreationResult create_trackpad(); + + /** + * @brief Create a trackpad from full creation options. + * + * @param options Trackpad creation options. + * @return Trackpad creation result. + */ + TrackpadCreationResult create_trackpad(const CreateTrackpadOptions &options); + + /** + * @brief Create a pen tablet with the built-in pen tablet profile. + * + * @return Pen tablet creation result. + */ + PenTabletCreationResult create_pen_tablet(); + + /** + * @brief Create a pen tablet from full creation options. + * + * @param options Pen tablet creation options. + * @return Pen tablet creation result. + */ + PenTabletCreationResult create_pen_tablet(const CreatePenTabletOptions &options); + /** * @brief Get the number of open devices owned by the runtime. * diff --git a/include/libvirtualhid/types.hpp b/include/libvirtualhid/types.hpp index ae48d95..cde9b7a 100644 --- a/include/libvirtualhid/types.hpp +++ b/include/libvirtualhid/types.hpp @@ -5,9 +5,11 @@ #pragma once // standard includes +#include #include #include #include +#include #include #include @@ -140,6 +142,21 @@ namespace lvh { */ bool supports_mouse = false; + /** + * @brief Whether the backend can create touchscreen devices. + */ + bool supports_touchscreen = false; + + /** + * @brief Whether the backend can create trackpad devices. + */ + bool supports_trackpad = false; + + /** + * @brief Whether the backend can create pen tablet devices. + */ + bool supports_pen_tablet = false; + /** * @brief Whether the backend can deliver output reports to callers. */ @@ -156,6 +173,32 @@ namespace lvh { bool requires_installed_driver = false; }; + /** + * @brief Platform device-node categories reported by virtual devices. + */ + enum class DeviceNodeKind { + input_event, ///< Linux `/dev/input/event*` node or equivalent. + joystick, ///< Linux `/dev/input/js*` node or equivalent. + hidraw, ///< Linux `/dev/hidraw*` node or equivalent. + sysfs, ///< Linux sysfs path or equivalent diagnostic path. + other, ///< Other platform-specific device path. + }; + + /** + * @brief Platform-visible node or path associated with a virtual device. + */ + struct DeviceNode { + /** + * @brief Node category. + */ + DeviceNodeKind kind = DeviceNodeKind::other; + + /** + * @brief Platform path for this node. + */ + std::string path; + }; + /** * @brief Device categories supported by the public profile model. */ @@ -163,6 +206,9 @@ namespace lvh { gamepad, ///< Game controller device. keyboard, ///< Keyboard device. mouse, ///< Mouse or pointer device. + touchscreen, ///< Direct touch display device. + trackpad, ///< Indirect touchpad device. + pen_tablet, ///< Pen tablet device. }; /** @@ -370,6 +416,11 @@ namespace lvh { */ DeviceProfile profile; + /** + * @brief Held-key repeat interval in milliseconds, or `0` to disable repeat. + */ + std::uint32_t auto_repeat_interval_ms = 50; + /** * @brief Consumer-defined stable identity string. */ @@ -391,6 +442,51 @@ namespace lvh { std::string stable_id; }; + /** + * @brief Full touchscreen creation request. + */ + struct CreateTouchscreenOptions { + /** + * @brief Device profile to instantiate. + */ + DeviceProfile profile; + + /** + * @brief Consumer-defined stable identity string. + */ + std::string stable_id; + }; + + /** + * @brief Full trackpad creation request. + */ + struct CreateTrackpadOptions { + /** + * @brief Device profile to instantiate. + */ + DeviceProfile profile; + + /** + * @brief Consumer-defined stable identity string. + */ + std::string stable_id; + }; + + /** + * @brief Full pen tablet creation request. + */ + struct CreatePenTabletOptions { + /** + * @brief Device profile to instantiate. + */ + DeviceProfile profile; + + /** + * @brief Consumer-defined stable identity string. + */ + std::string stable_id; + }; + /** * @brief Logical gamepad buttons accepted by the common gamepad state model. */ @@ -472,6 +568,79 @@ namespace lvh { float y = 0.0F; }; + /** + * @brief Normalized three-axis sensor state. + */ + struct Vector3 { + /** + * @brief X-axis value. + */ + float x = 0.0F; + + /** + * @brief Y-axis value. + */ + float y = 0.0F; + + /** + * @brief Z-axis value. + */ + float z = 0.0F; + }; + + /** + * @brief Common gamepad battery states. + */ + enum class GamepadBatteryState : std::uint8_t { + unknown, ///< Battery state is unknown. + discharging, ///< Battery is discharging. + charging, ///< Battery is charging. + full, ///< Battery is fully charged. + voltage_or_temperature_error, ///< Battery reports voltage or temperature outside the supported range. + temperature_error, ///< Battery reports a temperature error. + charging_error, ///< Battery reports a charging error. + }; + + /** + * @brief Gamepad battery charge metadata. + */ + struct GamepadBattery { + /** + * @brief Current battery state. + */ + GamepadBatteryState state = GamepadBatteryState::unknown; + + /** + * @brief Battery percentage in the inclusive range `[0, 100]`. + */ + std::uint8_t percentage = 100; + }; + + /** + * @brief Touchpad contact carried by a gamepad report. + */ + struct GamepadTouchContact { + /** + * @brief Consumer-stable contact identifier. + */ + std::uint8_t id = 0; + + /** + * @brief Whether this contact is active. + */ + bool active = false; + + /** + * @brief Normalized X coordinate in the inclusive range `[0.0, 1.0]`. + */ + float x = 0.0F; + + /** + * @brief Normalized Y coordinate in the inclusive range `[0.0, 1.0]`. + */ + float y = 0.0F; + }; + /** * @brief Common gamepad input state accepted by libvirtualhid. */ @@ -500,6 +669,26 @@ namespace lvh { * @brief Right trigger value in the inclusive range `[0.0, 1.0]`. */ float right_trigger = 0.0F; + + /** + * @brief Accelerometer data in meters per second squared, when available. + */ + std::optional acceleration; + + /** + * @brief Gyroscope data in degrees per second, when available. + */ + std::optional gyroscope; + + /** + * @brief Battery metadata, when available. + */ + std::optional battery; + + /** + * @brief Gamepad touchpad contacts. + */ + std::array touchpad_contacts {}; }; /** @@ -603,6 +792,98 @@ namespace lvh { std::int32_t high_resolution_scroll = 0; }; + /** + * @brief Touch contact event for touchscreen and trackpad devices. + */ + struct TouchContact { + /** + * @brief Consumer-stable contact identifier. + */ + std::int32_t id = 0; + + /** + * @brief Normalized X coordinate in the inclusive range `[0.0, 1.0]`. + */ + float x = 0.0F; + + /** + * @brief Normalized Y coordinate in the inclusive range `[0.0, 1.0]`. + */ + float y = 0.0F; + + /** + * @brief Normalized pressure in the inclusive range `[0.0, 1.0]`. + */ + float pressure = 0.0F; + + /** + * @brief Contact orientation in degrees, typically in the inclusive range `[-90, 90]`. + */ + std::int32_t orientation = 0; + }; + + /** + * @brief Pen tablet tool categories. + */ + enum class PenToolType : std::uint8_t { + pen, ///< Pen tool. + eraser, ///< Eraser tool. + brush, ///< Brush tool. + pencil, ///< Pencil tool. + airbrush, ///< Airbrush tool. + touch, ///< Direct touch tool. + unchanged, ///< Keep the previously selected tool. + }; + + /** + * @brief Pen tablet buttons. + */ + enum class PenButton : std::uint8_t { + primary, ///< Primary stylus button. + secondary, ///< Secondary stylus button. + tertiary, ///< Tertiary stylus button. + }; + + /** + * @brief Pen tablet tool position and analog state. + */ + struct PenToolState { + /** + * @brief Tool category. + */ + PenToolType tool = PenToolType::pen; + + /** + * @brief Normalized X coordinate in the inclusive range `[0.0, 1.0]`. + */ + float x = 0.0F; + + /** + * @brief Normalized Y coordinate in the inclusive range `[0.0, 1.0]`. + */ + float y = 0.0F; + + /** + * @brief Normalized pressure in the inclusive range `[0.0, 1.0]`, or negative to leave pressure unchanged. + */ + float pressure = -1.0F; + + /** + * @brief Normalized distance in the inclusive range `[0.0, 1.0]`, or negative to leave distance unchanged. + */ + float distance = -1.0F; + + /** + * @brief X-axis tilt in degrees. + */ + float tilt_x = 0.0F; + + /** + * @brief Y-axis tilt in degrees. + */ + float tilt_y = 0.0F; + }; + /** * @brief Output report categories delivered by a gamepad backend. */ @@ -647,6 +928,31 @@ namespace lvh { */ std::uint8_t blue = 0; + /** + * @brief Adaptive trigger event flags from a profile-specific output report. + */ + std::uint8_t adaptive_trigger_flags = 0; + + /** + * @brief Profile-specific left trigger effect type. + */ + std::uint8_t left_trigger_effect_type = 0; + + /** + * @brief Profile-specific right trigger effect type. + */ + std::uint8_t right_trigger_effect_type = 0; + + /** + * @brief Profile-specific left trigger effect payload. + */ + std::array left_trigger_effect {}; + + /** + * @brief Profile-specific right trigger effect payload. + */ + std::array right_trigger_effect {}; + /** * @brief Raw output report payload. */ diff --git a/src/core/backend.cpp b/src/core/backend.cpp index b0bbce2..bc6c5a5 100644 --- a/src/core/backend.cpp +++ b/src/core/backend.cpp @@ -67,6 +67,64 @@ namespace lvh::detail { } }; + /** + * @brief In-memory touchscreen backend used for portable tests. + */ + class FakeTouchscreen final: public BackendTouchscreen { + public: + OperationStatus place_contact(const TouchContact & /*contact*/) override { + return OperationStatus::success(); + } + + OperationStatus release_contact(std::int32_t /*contact_id*/) override { + return OperationStatus::success(); + } + + OperationStatus close() override { + return OperationStatus::success(); + } + }; + + /** + * @brief In-memory trackpad backend used for portable tests. + */ + class FakeTrackpad final: public BackendTrackpad { + public: + OperationStatus place_contact(const TouchContact & /*contact*/) override { + return OperationStatus::success(); + } + + OperationStatus release_contact(std::int32_t /*contact_id*/) override { + return OperationStatus::success(); + } + + OperationStatus button(bool /*pressed*/) override { + return OperationStatus::success(); + } + + OperationStatus close() override { + return OperationStatus::success(); + } + }; + + /** + * @brief In-memory pen tablet backend used for portable tests. + */ + class FakePenTablet final: public BackendPenTablet { + public: + OperationStatus place_tool(const PenToolState & /*state*/) override { + return OperationStatus::success(); + } + + OperationStatus button(PenButton /*button*/, bool /*pressed*/) override { + return OperationStatus::success(); + } + + OperationStatus close() override { + return OperationStatus::success(); + } + }; + /** * @brief In-memory backend used by default for API validation. */ @@ -77,6 +135,9 @@ namespace lvh::detail { capabilities_.supports_gamepad = true; capabilities_.supports_keyboard = true; capabilities_.supports_mouse = true; + capabilities_.supports_touchscreen = true; + capabilities_.supports_trackpad = true; + capabilities_.supports_pen_tablet = true; capabilities_.supports_output_reports = true; } @@ -99,6 +160,24 @@ namespace lvh::detail { return {OperationStatus::success(), std::make_unique()}; } + BackendTouchscreenCreationResult create_touchscreen( + DeviceId /*id*/, + const CreateTouchscreenOptions & /*options*/ + ) override { + return {OperationStatus::success(), std::make_unique()}; + } + + BackendTrackpadCreationResult create_trackpad(DeviceId /*id*/, const CreateTrackpadOptions & /*options*/) override { + return {OperationStatus::success(), std::make_unique()}; + } + + BackendPenTabletCreationResult create_pen_tablet( + DeviceId /*id*/, + const CreatePenTabletOptions & /*options*/ + ) override { + return {OperationStatus::success(), std::make_unique()}; + } + private: BackendCapabilities capabilities_; }; diff --git a/src/core/backend.hpp b/src/core/backend.hpp index 5bc0509..2be5e8b 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -37,6 +37,15 @@ namespace lvh::detail { */ virtual OperationStatus submit(const std::vector &report) = 0; + /** + * @brief Get platform-visible nodes associated with this backend device. + * + * @return Device nodes and diagnostic paths. + */ + virtual std::vector device_nodes() const { + return {}; + } + /** * @brief Register a callback for backend output reports. * @@ -86,6 +95,15 @@ namespace lvh::detail { */ virtual OperationStatus type_text(const KeyboardTextEvent &event) = 0; + /** + * @brief Get platform-visible nodes associated with this backend device. + * + * @return Device nodes and diagnostic paths. + */ + virtual std::vector device_nodes() const { + return {}; + } + /** * @brief Close the backend device. * @@ -120,6 +138,15 @@ namespace lvh::detail { */ virtual OperationStatus submit(const MouseEvent &event) = 0; + /** + * @brief Get platform-visible nodes associated with this backend device. + * + * @return Device nodes and diagnostic paths. + */ + virtual std::vector device_nodes() const { + return {}; + } + /** * @brief Close the backend device. * @@ -131,6 +158,168 @@ namespace lvh::detail { BackendMouse() = default; }; + /** + * @brief Backend-owned touchscreen device implementation. + */ + class BackendTouchscreen { + public: + BackendTouchscreen(const BackendTouchscreen &) = delete; + BackendTouchscreen &operator=(const BackendTouchscreen &) = delete; + BackendTouchscreen(BackendTouchscreen &&) noexcept = delete; + BackendTouchscreen &operator=(BackendTouchscreen &&) noexcept = delete; + + /** + * @brief Destroy the backend touchscreen. + */ + virtual ~BackendTouchscreen() = default; + + /** + * @brief Place or move a touch contact. + * + * @param contact Touch contact state. + * @return Submit status. + */ + virtual OperationStatus place_contact(const TouchContact &contact) = 0; + + /** + * @brief Release a touch contact. + * + * @param contact_id Consumer-stable contact identifier. + * @return Submit status. + */ + virtual OperationStatus release_contact(std::int32_t contact_id) = 0; + + /** + * @brief Get platform-visible nodes associated with this backend device. + * + * @return Device nodes and diagnostic paths. + */ + virtual std::vector device_nodes() const { + return {}; + } + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual OperationStatus close() = 0; + + protected: + BackendTouchscreen() = default; + }; + + /** + * @brief Backend-owned trackpad device implementation. + */ + class BackendTrackpad { + public: + BackendTrackpad(const BackendTrackpad &) = delete; + BackendTrackpad &operator=(const BackendTrackpad &) = delete; + BackendTrackpad(BackendTrackpad &&) noexcept = delete; + BackendTrackpad &operator=(BackendTrackpad &&) noexcept = delete; + + /** + * @brief Destroy the backend trackpad. + */ + virtual ~BackendTrackpad() = default; + + /** + * @brief Place or move a trackpad contact. + * + * @param contact Touch contact state. + * @return Submit status. + */ + virtual OperationStatus place_contact(const TouchContact &contact) = 0; + + /** + * @brief Release a trackpad contact. + * + * @param contact_id Consumer-stable contact identifier. + * @return Submit status. + */ + virtual OperationStatus release_contact(std::int32_t contact_id) = 0; + + /** + * @brief Submit a trackpad button transition. + * + * @param pressed Whether the primary trackpad button is pressed. + * @return Submit status. + */ + virtual OperationStatus button(bool pressed) = 0; + + /** + * @brief Get platform-visible nodes associated with this backend device. + * + * @return Device nodes and diagnostic paths. + */ + virtual std::vector device_nodes() const { + return {}; + } + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual OperationStatus close() = 0; + + protected: + BackendTrackpad() = default; + }; + + /** + * @brief Backend-owned pen tablet device implementation. + */ + class BackendPenTablet { + public: + BackendPenTablet(const BackendPenTablet &) = delete; + BackendPenTablet &operator=(const BackendPenTablet &) = delete; + BackendPenTablet(BackendPenTablet &&) noexcept = delete; + BackendPenTablet &operator=(BackendPenTablet &&) noexcept = delete; + + /** + * @brief Destroy the backend pen tablet. + */ + virtual ~BackendPenTablet() = default; + + /** + * @brief Place or move the active tablet tool. + * + * @param state Tool state. + * @return Submit status. + */ + virtual OperationStatus place_tool(const PenToolState &state) = 0; + + /** + * @brief Submit a tablet button transition. + * + * @param button Button to update. + * @param pressed Whether the button is pressed. + * @return Submit status. + */ + virtual OperationStatus button(PenButton button, bool pressed) = 0; + + /** + * @brief Get platform-visible nodes associated with this backend device. + * + * @return Device nodes and diagnostic paths. + */ + virtual std::vector device_nodes() const { + return {}; + } + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual OperationStatus close() = 0; + + protected: + BackendPenTablet() = default; + }; + /** * @brief Result returned by an internal backend gamepad creation request. */ @@ -203,6 +392,78 @@ namespace lvh::detail { } }; + /** + * @brief Result returned by an internal backend touchscreen creation request. + */ + struct BackendTouchscreenCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Backend device when creation succeeds. + */ + std::unique_ptr touchscreen; + + /** + * @brief Check whether creation succeeded. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && touchscreen != nullptr; + } + }; + + /** + * @brief Result returned by an internal backend trackpad creation request. + */ + struct BackendTrackpadCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Backend device when creation succeeds. + */ + std::unique_ptr trackpad; + + /** + * @brief Check whether creation succeeded. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && trackpad != nullptr; + } + }; + + /** + * @brief Result returned by an internal backend pen tablet creation request. + */ + struct BackendPenTabletCreationResult { + /** + * @brief Creation status. + */ + OperationStatus status; + + /** + * @brief Backend device when creation succeeds. + */ + std::unique_ptr pen_tablet; + + /** + * @brief Check whether creation succeeded. + * + * @return `true` when creation succeeded. + */ + explicit operator bool() const { + return status.ok() && pen_tablet != nullptr; + } + }; + /** * @brief Runtime-selected backend implementation. */ @@ -252,6 +513,33 @@ namespace lvh::detail { */ virtual BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) = 0; + /** + * @brief Create a backend touchscreen device. + * + * @param id Runtime-assigned device id. + * @param options Touchscreen creation options. + * @return Backend touchscreen creation result. + */ + virtual BackendTouchscreenCreationResult create_touchscreen(DeviceId id, const CreateTouchscreenOptions &options) = 0; + + /** + * @brief Create a backend trackpad device. + * + * @param id Runtime-assigned device id. + * @param options Trackpad creation options. + * @return Backend trackpad creation result. + */ + virtual BackendTrackpadCreationResult create_trackpad(DeviceId id, const CreateTrackpadOptions &options) = 0; + + /** + * @brief Create a backend pen tablet device. + * + * @param id Runtime-assigned device id. + * @param options Pen tablet creation options. + * @return Backend pen tablet creation result. + */ + virtual BackendPenTabletCreationResult create_pen_tablet(DeviceId id, const CreatePenTabletOptions &options) = 0; + protected: Backend() = default; }; diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index faf38fc..188fb31 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -6,6 +6,7 @@ // standard includes #include #include +#include #include // local includes @@ -18,6 +19,10 @@ namespace lvh::profiles { constexpr std::size_t common_output_report_size = 5; + constexpr std::size_t dualsense_usb_input_report_size = 64; + + constexpr std::size_t dualsense_usb_output_report_size = 48; + std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) { std::vector descriptor { 0x05, @@ -142,6 +147,285 @@ namespace lvh::profiles { return descriptor; } + std::vector make_dualsense_usb_report_descriptor() { + // DualSense USB descriptor data is derived from the public reverse-engineered descriptor used by inputtino. + return { + 0x05, + 0x01, + 0x09, + 0x05, + 0xA1, + 0x01, + 0x85, + 0x01, + 0x09, + 0x30, + 0x09, + 0x31, + 0x09, + 0x32, + 0x09, + 0x35, + 0x09, + 0x33, + 0x09, + 0x34, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x06, + 0x81, + 0x02, + 0x06, + 0x00, + 0xFF, + 0x09, + 0x20, + 0x95, + 0x01, + 0x81, + 0x02, + 0x05, + 0x01, + 0x09, + 0x39, + 0x15, + 0x00, + 0x25, + 0x07, + 0x35, + 0x00, + 0x46, + 0x3B, + 0x01, + 0x65, + 0x14, + 0x75, + 0x04, + 0x95, + 0x01, + 0x81, + 0x42, + 0x65, + 0x00, + 0x05, + 0x09, + 0x19, + 0x01, + 0x29, + 0x0F, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x0F, + 0x81, + 0x02, + 0x06, + 0x00, + 0xFF, + 0x09, + 0x21, + 0x95, + 0x0D, + 0x81, + 0x02, + 0x06, + 0x00, + 0xFF, + 0x09, + 0x22, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x34, + 0x81, + 0x02, + 0x85, + 0x02, + 0x09, + 0x23, + 0x95, + 0x2F, + 0x91, + 0x02, + 0x85, + 0x05, + 0x09, + 0x33, + 0x95, + 0x28, + 0xB1, + 0x02, + 0x85, + 0x08, + 0x09, + 0x34, + 0x95, + 0x2F, + 0xB1, + 0x02, + 0x85, + 0x09, + 0x09, + 0x24, + 0x95, + 0x13, + 0xB1, + 0x02, + 0x85, + 0x0A, + 0x09, + 0x25, + 0x95, + 0x1A, + 0xB1, + 0x02, + 0x85, + 0x20, + 0x09, + 0x26, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x21, + 0x09, + 0x27, + 0x95, + 0x04, + 0xB1, + 0x02, + 0x85, + 0x22, + 0x09, + 0x40, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x80, + 0x09, + 0x28, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x81, + 0x09, + 0x29, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x82, + 0x09, + 0x2A, + 0x95, + 0x09, + 0xB1, + 0x02, + 0x85, + 0x83, + 0x09, + 0x2B, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x84, + 0x09, + 0x2C, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x85, + 0x09, + 0x2D, + 0x95, + 0x02, + 0xB1, + 0x02, + 0x85, + 0xA0, + 0x09, + 0x2E, + 0x95, + 0x01, + 0xB1, + 0x02, + 0x85, + 0xE0, + 0x09, + 0x2F, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0xF0, + 0x09, + 0x30, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0xF1, + 0x09, + 0x31, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0xF2, + 0x09, + 0x32, + 0x95, + 0x0F, + 0xB1, + 0x02, + 0x85, + 0xF4, + 0x09, + 0x35, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0xF5, + 0x09, + 0x36, + 0x95, + 0x03, + 0xB1, + 0x02, + 0xC0, + }; + } + DeviceProfile make_gamepad_profile( GamepadProfileKind kind, std::string name, @@ -169,6 +453,31 @@ namespace lvh::profiles { return profile; } + DeviceProfile make_dualsense_profile(BusType bus_type) { + DeviceProfile profile; + profile.device_type = DeviceType::gamepad; + profile.gamepad_kind = GamepadProfileKind::dualsense; + profile.bus_type = bus_type; + profile.vendor_id = 0x054C; + profile.product_id = 0x0CE6; + profile.version = 0x8111; + profile.report_id = 1; + profile.input_report_size = dualsense_usb_input_report_size; + profile.output_report_size = dualsense_usb_output_report_size; + profile.name = "DualSense Wireless Controller"; + profile.manufacturer = "Sony Interactive Entertainment"; + profile.capabilities = { + .supports_rumble = true, + .supports_motion = true, + .supports_touchpad = true, + .supports_rgb_led = true, + .supports_battery = true, + .supports_adaptive_triggers = true, + }; + profile.report_descriptor = make_dualsense_usb_report_descriptor(); + return profile; + } + DeviceProfile make_simple_profile(DeviceType device_type, std::string name, std::uint16_t product_id) { DeviceProfile profile; profile.device_type = device_type; @@ -228,21 +537,17 @@ namespace lvh::profiles { } DeviceProfile dualsense() { - return make_gamepad_profile( - GamepadProfileKind::dualsense, - "DualSense Wireless Controller", - 0x054C, - 0x0CE6, - 0x8111, - { - .supports_rumble = true, - .supports_motion = true, - .supports_touchpad = true, - .supports_rgb_led = true, - .supports_battery = true, - .supports_adaptive_triggers = true, - } - ); + return dualsense_usb(); + } + + DeviceProfile dualsense_usb() { + return make_dualsense_profile(BusType::usb); + } + + DeviceProfile dualsense_bluetooth() { + auto profile = make_dualsense_profile(BusType::bluetooth); + profile.name = "DualSense Wireless Controller"; + return profile; } DeviceProfile switch_pro() { @@ -264,6 +569,18 @@ namespace lvh::profiles { return make_simple_profile(DeviceType::mouse, "libvirtualhid Mouse", 0x0003); } + DeviceProfile touchscreen() { + return make_simple_profile(DeviceType::touchscreen, "libvirtualhid Touchscreen", 0x0004); + } + + DeviceProfile trackpad() { + return make_simple_profile(DeviceType::trackpad, "libvirtualhid Trackpad", 0x0005); + } + + DeviceProfile pen_tablet() { + return make_simple_profile(DeviceType::pen_tablet, "libvirtualhid Pen Tablet", 0x0006); + } + std::optional gamepad_profile(GamepadProfileKind kind) { switch (kind) { case GamepadProfileKind::generic: diff --git a/src/core/report.cpp b/src/core/report.cpp index fa20ab7..277a6c6 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -5,9 +5,13 @@ // standard includes #include +#include #include #include #include +#include +#include +#include // local includes #include @@ -17,6 +21,20 @@ namespace lvh::reports { constexpr std::uint8_t neutral_hat = 8; + constexpr std::uint8_t dualsense_usb_output_report_id = 0x02; + + constexpr std::uint8_t dualsense_bt_output_report_id = 0x31; + + constexpr std::uint8_t dualsense_flag0_rumble = 0x01; + + constexpr std::uint8_t dualsense_flag0_right_trigger = 0x04; + + constexpr std::uint8_t dualsense_flag0_left_trigger = 0x08; + + constexpr std::uint8_t dualsense_flag1_lightbar = 0x04; + + constexpr std::uint8_t dualsense_flag2_compatible_vibration = 0x04; + void append_u16(std::vector &report, std::uint16_t value) { report.push_back(static_cast(value & 0xFFU)); report.push_back(static_cast((value >> 8U) & 0xFFU)); @@ -26,12 +44,34 @@ namespace lvh::reports { append_u16(report, static_cast(value)); } + void write_u16(std::vector &report, std::size_t offset, std::uint16_t value) { + report[offset] = static_cast(value & 0xFFU); + report[offset + 1U] = static_cast((value >> 8U) & 0xFFU); + } + + void write_i16(std::vector &report, std::size_t offset, std::int16_t value) { + write_u16(report, offset, static_cast(value)); + } + std::uint16_t read_u16(const std::vector &report, std::size_t offset) { const auto low = static_cast(report[offset]); const auto high = static_cast(report[offset + 1U]); return static_cast(low | static_cast(high << 8U)); } + std::int16_t scale_i16(float value, float multiplier) { + const auto scaled = std::clamp(value * multiplier, -32768.0F, 32767.0F); + return static_cast(std::lround(scaled)); + } + + std::uint8_t normalize_dualsense_axis(float value) { + return static_cast(std::lround((clamp_axis(value) + 1.0F) * 127.5F)); + } + + std::uint16_t scale_output_byte(std::uint8_t value) { + return static_cast(std::lround((static_cast(value) / 255.0F) * 65535.0F)); + } + std::uint16_t report_button_bits(const ButtonSet &buttons) { std::uint16_t bits = 0; @@ -57,6 +97,189 @@ namespace lvh::reports { return bits; } + std::uint8_t dualsense_battery_state(GamepadBatteryState state) { + switch (state) { + case GamepadBatteryState::discharging: + return 0x00; + case GamepadBatteryState::charging: + return 0x01; + case GamepadBatteryState::full: + return 0x02; + case GamepadBatteryState::voltage_or_temperature_error: + return 0x0A; + case GamepadBatteryState::temperature_error: + return 0x0B; + case GamepadBatteryState::charging_error: + return 0x0F; + case GamepadBatteryState::unknown: + break; + } + + return 0x02; + } + + void write_dualsense_touch_contact( + std::vector &report, + std::size_t offset, + const GamepadTouchContact &contact + ) { + const auto x = static_cast(std::lround(std::clamp(contact.x, 0.0F, 1.0F) * 1919.0F)); + const auto y = static_cast(std::lround(std::clamp(contact.y, 0.0F, 1.0F) * 1079.0F)); + report[offset] = static_cast((contact.id & 0x7FU) | (contact.active ? 0x00U : 0x80U)); + report[offset + 1U] = static_cast(x & 0xFFU); + report[offset + 2U] = static_cast(((x >> 8U) & 0x0FU) | ((y & 0x0FU) << 4U)); + report[offset + 3U] = static_cast((y >> 4U) & 0xFFU); + } + + std::vector pack_dualsense_usb_input_report(const DeviceProfile &profile, const GamepadState &state) { + if (profile.input_report_size < 64U) { + return {}; + } + + const auto normalized = normalize_state(state); + std::vector report(profile.input_report_size, 0); + report[0] = profile.report_id; + report[1] = normalize_dualsense_axis(normalized.left_stick.x); + report[2] = normalize_dualsense_axis(normalized.left_stick.y); + report[3] = normalize_dualsense_axis(normalized.right_stick.x); + report[4] = normalize_dualsense_axis(normalized.right_stick.y); + report[5] = normalize_trigger(normalized.left_trigger); + report[6] = normalize_trigger(normalized.right_trigger); + report[8] = hat_from_buttons(normalized.buttons); + + if (normalized.buttons.test(GamepadButton::x)) { + report[8] |= 0x10; + } + if (normalized.buttons.test(GamepadButton::a)) { + report[8] |= 0x20; + } + if (normalized.buttons.test(GamepadButton::b)) { + report[8] |= 0x40; + } + if (normalized.buttons.test(GamepadButton::y)) { + report[8] |= 0x80; + } + + if (normalized.buttons.test(GamepadButton::left_shoulder)) { + report[9] |= 0x01; + } + if (normalized.buttons.test(GamepadButton::right_shoulder)) { + report[9] |= 0x02; + } + if (normalized.left_trigger > 0.0F) { + report[9] |= 0x04; + } + if (normalized.right_trigger > 0.0F) { + report[9] |= 0x08; + } + if (normalized.buttons.test(GamepadButton::back)) { + report[9] |= 0x10; + } + if (normalized.buttons.test(GamepadButton::start)) { + report[9] |= 0x20; + } + if (normalized.buttons.test(GamepadButton::left_stick)) { + report[9] |= 0x40; + } + if (normalized.buttons.test(GamepadButton::right_stick)) { + report[9] |= 0x80; + } + + if (normalized.buttons.test(GamepadButton::guide)) { + report[10] |= 0x01; + } + if (normalized.buttons.test(GamepadButton::misc1)) { + report[10] |= 0x04; + } + + if (normalized.gyroscope) { + write_i16(report, 16U, scale_i16(normalized.gyroscope->x, 1145.0F)); + write_i16(report, 18U, scale_i16(normalized.gyroscope->y, 1145.0F)); + write_i16(report, 20U, scale_i16(normalized.gyroscope->z, 1145.0F)); + } + if (normalized.acceleration) { + write_i16(report, 22U, scale_i16(normalized.acceleration->x, 100.0F)); + write_i16(report, 24U, scale_i16(normalized.acceleration->y, 100.0F)); + write_i16(report, 26U, scale_i16(normalized.acceleration->z, 100.0F)); + } + + write_dualsense_touch_contact(report, 33U, normalized.touchpad_contacts[0]); + write_dualsense_touch_contact(report, 37U, normalized.touchpad_contacts[1]); + + const auto battery = normalized.battery.value_or(GamepadBattery {.state = GamepadBatteryState::full, .percentage = 100}); + const auto battery_charge = std::min(10U, static_cast(std::lround(battery.percentage / 10.0F))); + report[53] = static_cast(battery_charge | (dualsense_battery_state(battery.state) << 4U)); + report[54] = 0x0C; + return report; + } + + std::optional dualsense_common_output_offset(const std::vector &report) { + if (report.size() >= 48U && report[0] == dualsense_usb_output_report_id) { + return 1U; + } + if (report.size() >= 49U && report[0] == dualsense_bt_output_report_id) { + const auto enable_hid = (report[1] & 0x02U) != 0; + if (!enable_hid && report.size() < 50U) { + return std::nullopt; + } + return enable_hid ? 2U : 3U; + } + return std::nullopt; + } + + void append_dualsense_outputs( + const std::vector &report, + std::size_t offset, + std::vector &outputs + ) { + const auto valid_flag0 = report[offset]; + const auto valid_flag1 = report[offset + 1U]; + const auto motor_right = report[offset + 2U]; + const auto motor_left = report[offset + 3U]; + const auto right_trigger_effect_type = report[offset + 10U]; + const auto left_trigger_effect_type = report[offset + 21U]; + const auto valid_flag2 = report[offset + 38U]; + + if ((valid_flag0 & dualsense_flag0_rumble) != 0 || (valid_flag2 & dualsense_flag2_compatible_vibration) != 0) { + GamepadOutput output; + output.kind = GamepadOutputKind::rumble; + output.low_frequency_rumble = scale_output_byte(motor_left); + output.high_frequency_rumble = scale_output_byte(motor_right); + output.raw_report = report; + outputs.push_back(std::move(output)); + } else if (valid_flag0 == 0 && valid_flag1 == 0 && valid_flag2 == 0) { + GamepadOutput output; + output.kind = GamepadOutputKind::rumble; + output.raw_report = report; + outputs.push_back(std::move(output)); + } + + if ((valid_flag1 & dualsense_flag1_lightbar) != 0) { + GamepadOutput output; + output.kind = GamepadOutputKind::rgb_led; + output.red = report[offset + 44U]; + output.green = report[offset + 45U]; + output.blue = report[offset + 46U]; + output.raw_report = report; + outputs.push_back(std::move(output)); + } + + const auto trigger_flags = static_cast( + valid_flag0 & (dualsense_flag0_left_trigger | dualsense_flag0_right_trigger) + ); + if (trigger_flags != 0) { + GamepadOutput output; + output.kind = GamepadOutputKind::adaptive_triggers; + output.adaptive_trigger_flags = trigger_flags; + output.left_trigger_effect_type = left_trigger_effect_type; + output.right_trigger_effect_type = right_trigger_effect_type; + std::copy_n(report.begin() + static_cast(offset + 11U), output.right_trigger_effect.size(), output.right_trigger_effect.begin()); + std::copy_n(report.begin() + static_cast(offset + 22U), output.left_trigger_effect.size(), output.left_trigger_effect.begin()); + output.raw_report = report; + outputs.push_back(std::move(output)); + } + } + } // namespace float clamp_axis(float value) { @@ -101,6 +324,13 @@ namespace lvh::reports { normalized.right_stick.y = clamp_axis(state.right_stick.y); normalized.left_trigger = clamp_trigger(state.left_trigger); normalized.right_trigger = clamp_trigger(state.right_trigger); + for (auto &contact : normalized.touchpad_contacts) { + contact.x = std::clamp(contact.x, 0.0F, 1.0F); + contact.y = std::clamp(contact.y, 0.0F, 1.0F); + } + if (normalized.battery) { + normalized.battery->percentage = std::min(100U, normalized.battery->percentage); + } return normalized; } @@ -148,6 +378,10 @@ namespace lvh::reports { } std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state) { + if (profile.device_type == DeviceType::gamepad && profile.gamepad_kind == GamepadProfileKind::dualsense) { + return pack_dualsense_usb_input_report(profile, state); + } + constexpr std::size_t common_report_size = 14; if (profile.device_type != DeviceType::gamepad || profile.input_report_size < common_report_size) { return {}; @@ -172,19 +406,45 @@ namespace lvh::reports { } GamepadOutput parse_output_report(const DeviceProfile &profile, const std::vector &report) { + const auto outputs = parse_output_reports(profile, report); + if (!outputs.empty()) { + return outputs.front(); + } + GamepadOutput output; output.raw_report = report; + return output; + } + + std::vector parse_output_reports(const DeviceProfile &profile, const std::vector &report) { + std::vector outputs; + + if (profile.gamepad_kind == GamepadProfileKind::dualsense) { + if (const auto offset = dualsense_common_output_offset(report)) { + append_dualsense_outputs(report, *offset, outputs); + } + if (!outputs.empty()) { + return outputs; + } + } if ( profile.capabilities.supports_rumble && profile.output_report_size >= 5U && report.size() >= profile.output_report_size && report[0] == profile.report_id ) { + GamepadOutput output; + output.raw_report = report; output.kind = GamepadOutputKind::rumble; output.low_frequency_rumble = read_u16(report, 1U); output.high_frequency_rumble = read_u16(report, 3U); + outputs.push_back(std::move(output)); + return outputs; } - return output; + GamepadOutput output; + output.raw_report = report; + outputs.push_back(std::move(output)); + return outputs; } } // namespace lvh::reports diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 9bc8104..ac2fd4c 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -74,6 +74,63 @@ namespace lvh::detail { mutable std::mutex mutex; }; + struct TouchscreenDevice { + explicit TouchscreenDevice( + DeviceId device_id, + CreateTouchscreenOptions create_options, + std::unique_ptr backend_touchscreen + ): + id {device_id}, + options {std::move(create_options)}, + backend {std::move(backend_touchscreen)} {} + + DeviceId id; + CreateTouchscreenOptions options; + std::unique_ptr backend; + bool open = true; + TouchContact last_contact; + std::size_t submitted_events = 0; + mutable std::mutex mutex; + }; + + struct TrackpadDevice { + explicit TrackpadDevice( + DeviceId device_id, + CreateTrackpadOptions create_options, + std::unique_ptr backend_trackpad + ): + id {device_id}, + options {std::move(create_options)}, + backend {std::move(backend_trackpad)} {} + + DeviceId id; + CreateTrackpadOptions options; + std::unique_ptr backend; + bool open = true; + TouchContact last_contact; + std::size_t submitted_events = 0; + mutable std::mutex mutex; + }; + + struct PenTabletDevice { + explicit PenTabletDevice( + DeviceId device_id, + CreatePenTabletOptions create_options, + std::unique_ptr backend_pen_tablet + ): + id {device_id}, + options {std::move(create_options)}, + backend {std::move(backend_pen_tablet)} {} + + DeviceId id; + CreatePenTabletOptions options; + std::unique_ptr backend; + bool open = true; + PenToolState last_tool; + std::size_t submitted_events = 0; + mutable std::mutex mutex; + }; + class RuntimeState { public: explicit RuntimeState(RuntimeOptions runtime_options): @@ -88,6 +145,9 @@ namespace lvh::detail { std::vector> gamepads; std::vector> keyboards; std::vector> mice; + std::vector> touchscreens; + std::vector> trackpads; + std::vector> pen_tablets; mutable std::mutex mutex; }; @@ -138,6 +198,39 @@ namespace lvh { return OperationStatus::success(); } + OperationStatus validate_touchscreen_options(const CreateTouchscreenOptions &options) { + if (options.profile.device_type != DeviceType::touchscreen) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a touchscreen"); + } + if (options.profile.name.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + return OperationStatus::success(); + } + + OperationStatus validate_trackpad_options(const CreateTrackpadOptions &options) { + if (options.profile.device_type != DeviceType::trackpad) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a trackpad"); + } + if (options.profile.name.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + return OperationStatus::success(); + } + + OperationStatus validate_pen_tablet_options(const CreatePenTabletOptions &options) { + if (options.profile.device_type != DeviceType::pen_tablet) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a pen tablet"); + } + if (options.profile.name.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + return OperationStatus::success(); + } + OperationStatus validate_keyboard_event(const KeyboardEvent &event) { if (event.key_code == 0) { return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code must not be zero"); @@ -154,6 +247,14 @@ namespace lvh { return OperationStatus::success(); } + OperationStatus validate_touch_contact(const TouchContact &contact) { + if (contact.id < 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); + } + + return OperationStatus::success(); + } + template auto with_device(const auto &device, Func &&func) { std::lock_guard lock {device->mutex}; @@ -213,6 +314,15 @@ namespace lvh { }); } + std::vector Gamepad::device_nodes() const { + return with_device(device_, [](const auto &device) { + if (device.backend) { + return device.backend->device_nodes(); + } + return std::vector {}; + }); + } + OperationStatus Gamepad::close() { return with_device(device_, [](auto &device) { if (!device.open) { @@ -321,6 +431,15 @@ namespace lvh { }); } + std::vector Keyboard::device_nodes() const { + return with_device(device_, [](const auto &device) { + if (device.backend) { + return device.backend->device_nodes(); + } + return std::vector {}; + }); + } + OperationStatus Keyboard::close() { return with_device(device_, [](auto &device) { if (!device.open) { @@ -418,6 +537,15 @@ namespace lvh { }); } + std::vector Mouse::device_nodes() const { + return with_device(device_, [](const auto &device) { + if (device.backend) { + return device.backend->device_nodes(); + } + return std::vector {}; + }); + } + OperationStatus Mouse::close() { return with_device(device_, [](auto &device) { if (!device.open) { @@ -498,6 +626,320 @@ namespace lvh { }); } + Touchscreen::Touchscreen(std::shared_ptr device): + device_ {std::move(device)} {} + + Touchscreen::Touchscreen(Touchscreen &&) noexcept = default; + Touchscreen &Touchscreen::operator=(Touchscreen &&) noexcept = default; + Touchscreen::~Touchscreen() = default; + + DeviceId Touchscreen::device_id() const { + return device_->id; + } + + const DeviceProfile &Touchscreen::profile() const { + return device_->options.profile; + } + + bool Touchscreen::is_open() const { + return with_device(device_, [](const auto &device) { + return device.open; + }); + } + + std::vector Touchscreen::device_nodes() const { + return with_device(device_, [](const auto &device) { + if (device.backend) { + return device.backend->device_nodes(); + } + return std::vector {}; + }); + } + + OperationStatus Touchscreen::close() { + return with_device(device_, [](auto &device) { + if (!device.open) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + OperationStatus Touchscreen::place_contact(const TouchContact &contact) { + if (const auto validation = validate_touch_contact(contact); !validation.ok()) { + return validation; + } + + return with_device(device_, [&contact](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "touchscreen is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->place_contact(contact); !status.ok()) { + return status; + } + } + + device.last_contact = contact; + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + OperationStatus Touchscreen::release_contact(std::int32_t contact_id) { + if (contact_id < 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); + } + + return with_device(device_, [contact_id](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "touchscreen is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->release_contact(contact_id); !status.ok()) { + return status; + } + } + + device.last_contact.id = contact_id; + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + TouchContact Touchscreen::last_submitted_contact() const { + return with_device(device_, [](const auto &device) { + return device.last_contact; + }); + } + + std::size_t Touchscreen::submit_count() const { + return with_device(device_, [](const auto &device) { + return device.submitted_events; + }); + } + + Trackpad::Trackpad(std::shared_ptr device): + device_ {std::move(device)} {} + + Trackpad::Trackpad(Trackpad &&) noexcept = default; + Trackpad &Trackpad::operator=(Trackpad &&) noexcept = default; + Trackpad::~Trackpad() = default; + + DeviceId Trackpad::device_id() const { + return device_->id; + } + + const DeviceProfile &Trackpad::profile() const { + return device_->options.profile; + } + + bool Trackpad::is_open() const { + return with_device(device_, [](const auto &device) { + return device.open; + }); + } + + std::vector Trackpad::device_nodes() const { + return with_device(device_, [](const auto &device) { + if (device.backend) { + return device.backend->device_nodes(); + } + return std::vector {}; + }); + } + + OperationStatus Trackpad::close() { + return with_device(device_, [](auto &device) { + if (!device.open) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + OperationStatus Trackpad::place_contact(const TouchContact &contact) { + if (const auto validation = validate_touch_contact(contact); !validation.ok()) { + return validation; + } + + return with_device(device_, [&contact](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "trackpad is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->place_contact(contact); !status.ok()) { + return status; + } + } + + device.last_contact = contact; + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + OperationStatus Trackpad::release_contact(std::int32_t contact_id) { + if (contact_id < 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); + } + + return with_device(device_, [contact_id](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "trackpad is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->release_contact(contact_id); !status.ok()) { + return status; + } + } + + device.last_contact.id = contact_id; + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + OperationStatus Trackpad::button(bool pressed) { + return with_device(device_, [pressed](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "trackpad is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->button(pressed); !status.ok()) { + return status; + } + } + + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + TouchContact Trackpad::last_submitted_contact() const { + return with_device(device_, [](const auto &device) { + return device.last_contact; + }); + } + + std::size_t Trackpad::submit_count() const { + return with_device(device_, [](const auto &device) { + return device.submitted_events; + }); + } + + PenTablet::PenTablet(std::shared_ptr device): + device_ {std::move(device)} {} + + PenTablet::PenTablet(PenTablet &&) noexcept = default; + PenTablet &PenTablet::operator=(PenTablet &&) noexcept = default; + PenTablet::~PenTablet() = default; + + DeviceId PenTablet::device_id() const { + return device_->id; + } + + const DeviceProfile &PenTablet::profile() const { + return device_->options.profile; + } + + bool PenTablet::is_open() const { + return with_device(device_, [](const auto &device) { + return device.open; + }); + } + + std::vector PenTablet::device_nodes() const { + return with_device(device_, [](const auto &device) { + if (device.backend) { + return device.backend->device_nodes(); + } + return std::vector {}; + }); + } + + OperationStatus PenTablet::close() { + return with_device(device_, [](auto &device) { + if (!device.open) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + OperationStatus PenTablet::place_tool(const PenToolState &state) { + return with_device(device_, [&state](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "pen tablet is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->place_tool(state); !status.ok()) { + return status; + } + } + + device.last_tool = state; + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + OperationStatus PenTablet::button(PenButton button, bool pressed) { + return with_device(device_, [button, pressed](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "pen tablet is closed"); + } + + if (device.backend) { + if (const auto status = device.backend->button(button, pressed); !status.ok()) { + return status; + } + } + + ++device.submitted_events; + return OperationStatus::success(); + }); + } + + PenToolState PenTablet::last_submitted_tool() const { + return with_device(device_, [](const auto &device) { + return device.last_tool; + }); + } + + std::size_t PenTablet::submit_count() const { + return with_device(device_, [](const auto &device) { + return device.submitted_events; + }); + } + Runtime::Runtime(RuntimeOptions options): state_ {std::make_shared(options)} {} @@ -615,9 +1057,104 @@ namespace lvh { return {OperationStatus::success(), std::unique_ptr {new Mouse {std::move(device)}}}; } + TouchscreenCreationResult Runtime::create_touchscreen() { + CreateTouchscreenOptions options; + options.profile = profiles::touchscreen(); + return create_touchscreen(options); + } + + TouchscreenCreationResult Runtime::create_touchscreen(const CreateTouchscreenOptions &options) { + if (const auto validation = validate_touchscreen_options(options); !validation.ok()) { + return {validation, nullptr}; + } + + DeviceId id; + { + std::lock_guard lock {state_->mutex}; + id = state_->next_device_id++; + } + + auto backend_result = state_->backend->create_touchscreen(id, options); + if (!backend_result) { + return {std::move(backend_result.status), nullptr}; + } + + auto device = std::make_shared(id, options, std::move(backend_result.touchscreen)); + { + std::lock_guard lock {state_->mutex}; + state_->touchscreens.emplace_back(device); + } + + return {OperationStatus::success(), std::unique_ptr {new Touchscreen {std::move(device)}}}; + } + + TrackpadCreationResult Runtime::create_trackpad() { + CreateTrackpadOptions options; + options.profile = profiles::trackpad(); + return create_trackpad(options); + } + + TrackpadCreationResult Runtime::create_trackpad(const CreateTrackpadOptions &options) { + if (const auto validation = validate_trackpad_options(options); !validation.ok()) { + return {validation, nullptr}; + } + + DeviceId id; + { + std::lock_guard lock {state_->mutex}; + id = state_->next_device_id++; + } + + auto backend_result = state_->backend->create_trackpad(id, options); + if (!backend_result) { + return {std::move(backend_result.status), nullptr}; + } + + auto device = std::make_shared(id, options, std::move(backend_result.trackpad)); + { + std::lock_guard lock {state_->mutex}; + state_->trackpads.emplace_back(device); + } + + return {OperationStatus::success(), std::unique_ptr {new Trackpad {std::move(device)}}}; + } + + PenTabletCreationResult Runtime::create_pen_tablet() { + CreatePenTabletOptions options; + options.profile = profiles::pen_tablet(); + return create_pen_tablet(options); + } + + PenTabletCreationResult Runtime::create_pen_tablet(const CreatePenTabletOptions &options) { + if (const auto validation = validate_pen_tablet_options(options); !validation.ok()) { + return {validation, nullptr}; + } + + DeviceId id; + { + std::lock_guard lock {state_->mutex}; + id = state_->next_device_id++; + } + + auto backend_result = state_->backend->create_pen_tablet(id, options); + if (!backend_result) { + return {std::move(backend_result.status), nullptr}; + } + + auto device = std::make_shared(id, options, std::move(backend_result.pen_tablet)); + { + std::lock_guard lock {state_->mutex}; + state_->pen_tablets.emplace_back(device); + } + + return {OperationStatus::success(), std::unique_ptr {new PenTablet {std::move(device)}}}; + } + std::size_t Runtime::active_device_count() const { std::lock_guard lock {state_->mutex}; - return count_open_devices(state_->gamepads) + count_open_devices(state_->keyboards) + count_open_devices(state_->mice); + return count_open_devices(state_->gamepads) + count_open_devices(state_->keyboards) + count_open_devices(state_->mice) + + count_open_devices(state_->touchscreens) + count_open_devices(state_->trackpads) + + count_open_devices(state_->pen_tablets); } void Runtime::close_all() { @@ -625,6 +1162,9 @@ namespace lvh { close_devices(state_->gamepads); close_devices(state_->keyboards); close_devices(state_->mice); + close_devices(state_->touchscreens); + close_devices(state_->trackpads); + close_devices(state_->pen_tablets); } } // namespace lvh diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 3a44a38..dabc500 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -12,9 +12,15 @@ #include #include #include +#include +#include #include +#include #include #include +#include +#include +#include #include #include #include @@ -53,6 +59,13 @@ namespace lvh::detail { constexpr auto uhid_path = "/dev/uhid"; constexpr auto uinput_path = "/dev/uinput"; constexpr auto absolute_axis_max = 65535; + constexpr auto touch_axis_max_x = 19200; + constexpr auto touch_axis_max_y = 10800; + constexpr auto touch_max_contacts = 16; + constexpr auto touch_pressure_max = 253; + constexpr auto tablet_pressure_max = 4096; + constexpr auto tablet_distance_max = 1024; + constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; int system_access(const char *path, int mode) { @@ -124,6 +137,84 @@ namespace lvh::detail { destination[length] = 0; } + std::optional read_first_line(const std::filesystem::path &path) { + std::ifstream file {path}; + if (!file) { + return std::nullopt; + } + + std::string line; + std::getline(file, line); + return line; + } + + void append_node(std::vector &nodes, DeviceNodeKind kind, const std::filesystem::path &path) { + nodes.push_back({.kind = kind, .path = path.string()}); + } + + bool hidraw_name_matches(const std::filesystem::path &uevent_path, const std::string &name) { + std::ifstream file {uevent_path}; + if (!file) { + return false; + } + + std::string line; + while (std::getline(file, line)) { + constexpr auto key = "HID_NAME="; + if (line.starts_with(key)) { + return line.substr(std::char_traits::length(key)) == name; + } + } + + return false; + } + + std::vector discover_input_nodes_by_name(const std::string &name) { + std::vector nodes; + if (name.empty()) { + return nodes; + } + + std::error_code error; + const std::filesystem::path input_root {"/sys/class/input"}; + if (std::filesystem::exists(input_root, error)) { + for (std::filesystem::directory_iterator it {input_root, error}, end; !error && it != end; it.increment(error)) { + const auto filename = it->path().filename().string(); + const auto is_event_node = filename.starts_with("event"); + const auto is_joystick_node = filename.starts_with("js"); + if (!is_event_node && !is_joystick_node) { + continue; + } + + const auto sysfs_name = read_first_line(it->path() / "device" / "name"); + if (!sysfs_name || *sysfs_name != name) { + continue; + } + + append_node( + nodes, + is_event_node ? DeviceNodeKind::input_event : DeviceNodeKind::joystick, + std::filesystem::path {"/dev/input"} / it->path().filename() + ); + append_node(nodes, DeviceNodeKind::sysfs, it->path()); + } + } + + const std::filesystem::path hidraw_root {"/sys/class/hidraw"}; + if (std::filesystem::exists(hidraw_root, error)) { + for (std::filesystem::directory_iterator it {hidraw_root, error}, end; !error && it != end; it.increment(error)) { + if (!hidraw_name_matches(it->path() / "device" / "uevent", name)) { + continue; + } + + append_node(nodes, DeviceNodeKind::hidraw, std::filesystem::path {"/dev"} / it->path().filename()); + append_node(nodes, DeviceNodeKind::sysfs, it->path()); + } + } + + return nodes; + } + OperationStatus ioctl_status(const std::string &operation) { return system_error_status(ErrorCode::backend_failure, operation, errno); } @@ -351,6 +442,19 @@ namespace lvh::detail { return static_cast(numerator / limit); } + int scale_normalized_axis(float value, int maximum) { + return static_cast(std::lround(std::clamp(value, 0.0F, 1.0F) * static_cast(maximum))); + } + + int clamp_degrees(std::int32_t value) { + return std::clamp(value, -90, 90); + } + + int tablet_tilt_units(float degrees) { + const auto radians = std::clamp(degrees, -90.0F, 90.0F) * static_cast(std::numbers::pi) / 180.0F; + return static_cast(std::lround(radians * tablet_resolution)); + } + std::vector decode_utf8(const std::string &text) { std::vector codepoints; for (std::size_t i = 0; i < text.size();) { @@ -506,6 +610,52 @@ namespace lvh::detail { std::mutex write_mutex_; }; + void configure_absinfo( + uinput_user_dev &device, + int code, + int minimum, + int maximum, + int fuzz = 0, + int flat = 0 + ) { + device.absmin[code] = minimum; + device.absmax[code] = maximum; + device.absfuzz[code] = fuzz; + device.absflat[code] = flat; + } + + void configure_uinput_absinfo(uinput_user_dev &device, DeviceType device_type) { + switch (device_type) { + case DeviceType::mouse: + configure_absinfo(device, ABS_X, 0, absolute_axis_max); + configure_absinfo(device, ABS_Y, 0, absolute_axis_max); + break; + case DeviceType::touchscreen: + case DeviceType::trackpad: + configure_absinfo(device, ABS_MT_SLOT, 0, touch_max_contacts - 1); + configure_absinfo(device, ABS_X, 0, touch_axis_max_x); + configure_absinfo(device, ABS_Y, 0, touch_axis_max_y); + configure_absinfo(device, ABS_MT_POSITION_X, 0, touch_axis_max_x); + configure_absinfo(device, ABS_MT_POSITION_Y, 0, touch_axis_max_y); + configure_absinfo(device, ABS_MT_TRACKING_ID, 0, 65535); + configure_absinfo(device, ABS_PRESSURE, 0, touch_pressure_max); + configure_absinfo(device, ABS_MT_PRESSURE, 0, touch_pressure_max); + configure_absinfo(device, ABS_MT_ORIENTATION, -90, 90); + break; + case DeviceType::pen_tablet: + configure_absinfo(device, ABS_X, 0, touch_axis_max_x, 1); + configure_absinfo(device, ABS_Y, 0, touch_axis_max_y, 1); + configure_absinfo(device, ABS_PRESSURE, 0, tablet_pressure_max); + configure_absinfo(device, ABS_DISTANCE, 0, tablet_distance_max); + configure_absinfo(device, ABS_TILT_X, -90, 90); + configure_absinfo(device, ABS_TILT_Y, -90, 90); + break; + case DeviceType::gamepad: + case DeviceType::keyboard: + break; + } + } + OperationStatus write_uinput_user_device(int fd, const DeviceProfile &profile, DeviceId id) { uinput_user_dev device {}; copy_string(device.name, profile.name); @@ -513,14 +663,7 @@ namespace lvh::detail { device.id.vendor = profile.vendor_id; device.id.product = profile.product_id; device.id.version = profile.version; - device.absmin[ABS_X] = 0; - device.absmax[ABS_X] = absolute_axis_max; - device.absmin[ABS_Y] = 0; - device.absmax[ABS_Y] = absolute_axis_max; - device.absfuzz[ABS_X] = 0; - device.absfuzz[ABS_Y] = 0; - device.absflat[ABS_X] = 0; - device.absflat[ABS_Y] = 0; + configure_uinput_absinfo(device, profile.device_type); const auto result = system_write(fd, &device, sizeof(device)); if (result < 0) { @@ -604,6 +747,95 @@ namespace lvh::detail { return OperationStatus::success(); } + OperationStatus enable_uinput_property(int fd, int property, const std::string &description) { +#if defined(UI_SET_PROPBIT) + if (system_ioctl(fd, UI_SET_PROPBIT, static_cast(property)) < 0) { + return ioctl_status("failed to enable uinput property " + description); + } +#else + static_cast(fd); + static_cast(property); + static_cast(description); +#endif + + return OperationStatus::success(); + } + + OperationStatus enable_uinput_touch_axes(int fd) { + if (system_ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { + return ioctl_status("failed to enable uinput touch key events"); + } + if (system_ioctl(fd, UI_SET_EVBIT, EV_ABS) < 0) { + return ioctl_status("failed to enable uinput touch absolute events"); + } + + for (const auto code : {ABS_MT_SLOT, ABS_X, ABS_Y, ABS_MT_POSITION_X, ABS_MT_POSITION_Y, ABS_MT_TRACKING_ID, ABS_PRESSURE, ABS_MT_PRESSURE, ABS_MT_ORIENTATION}) { + if (system_ioctl(fd, UI_SET_ABSBIT, code) < 0) { + return ioctl_status("failed to enable uinput touch absolute axis " + std::to_string(code)); + } + } + + if (system_ioctl(fd, UI_SET_KEYBIT, BTN_TOUCH) < 0) { + return ioctl_status("failed to enable uinput touch button"); + } + + return OperationStatus::success(); + } + + OperationStatus enable_uinput_touchscreen(int fd) { + if (const auto status = enable_uinput_touch_axes(fd); !status.ok()) { + return status; + } + return enable_uinput_property(fd, INPUT_PROP_DIRECT, "direct touch"); + } + + OperationStatus enable_uinput_trackpad(int fd) { + if (const auto status = enable_uinput_touch_axes(fd); !status.ok()) { + return status; + } + + for (const auto button : {BTN_LEFT, BTN_TOOL_FINGER, BTN_TOOL_DOUBLETAP, BTN_TOOL_TRIPLETAP, BTN_TOOL_QUADTAP, BTN_TOOL_QUINTTAP}) { + if (system_ioctl(fd, UI_SET_KEYBIT, button) < 0) { + return ioctl_status("failed to enable uinput trackpad button " + std::to_string(button)); + } + } + + if (const auto status = enable_uinput_property(fd, INPUT_PROP_POINTER, "pointer"); !status.ok()) { + return status; + } + return enable_uinput_property(fd, INPUT_PROP_BUTTONPAD, "buttonpad"); + } + + OperationStatus enable_uinput_pen_tablet(int fd) { + if (system_ioctl(fd, UI_SET_EVBIT, EV_KEY) < 0) { + return ioctl_status("failed to enable uinput pen tablet key events"); + } + if (system_ioctl(fd, UI_SET_EVBIT, EV_ABS) < 0) { + return ioctl_status("failed to enable uinput pen tablet absolute events"); + } + + std::vector buttons {BTN_TOUCH, BTN_STYLUS, BTN_STYLUS2, BTN_TOOL_PEN, BTN_TOOL_RUBBER, BTN_TOOL_BRUSH, BTN_TOOL_PENCIL, BTN_TOOL_AIRBRUSH}; +#if defined(BTN_STYLUS3) + buttons.push_back(BTN_STYLUS3); +#endif + for (const auto button : buttons) { + if (system_ioctl(fd, UI_SET_KEYBIT, button) < 0) { + return ioctl_status("failed to enable uinput pen tablet button " + std::to_string(button)); + } + } + + for (const auto code : {ABS_X, ABS_Y, ABS_PRESSURE, ABS_DISTANCE, ABS_TILT_X, ABS_TILT_Y}) { + if (system_ioctl(fd, UI_SET_ABSBIT, code) < 0) { + return ioctl_status("failed to enable uinput pen tablet absolute axis " + std::to_string(code)); + } + } + + if (const auto status = enable_uinput_property(fd, INPUT_PROP_POINTER, "tablet pointer"); !status.ok()) { + return status; + } + return enable_uinput_property(fd, INPUT_PROP_DIRECT, "tablet direct"); + } + /** * @brief Backend keyboard backed by one Linux uinput file descriptor. */ @@ -620,7 +852,12 @@ namespace lvh::detail { if (const auto status = enable_uinput_keyboard(file_descriptor()); !status.ok()) { return status; } - return write_uinput_user_device(file_descriptor(), options.profile, id); + device_name_ = options.profile.name; + auto status = write_uinput_user_device(file_descriptor(), options.profile, id); + if (status.ok() && options.auto_repeat_interval_ms > 0) { + start_repeat_thread(options.auto_repeat_interval_ms); + } + return status; } OperationStatus submit(const KeyboardEvent &event) override { @@ -628,15 +865,11 @@ namespace lvh::detail { return OperationStatus::failure(ErrorCode::device_closed, "uinput keyboard is closed"); } - const auto linux_key = key_code_to_linux(event.key_code); - if (linux_key < 0) { - return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by the Linux backend"); + auto status = emit_keyboard_event(event); + if (status.ok()) { + update_pressed_keys(event); } - - if (const auto status = emit_event(EV_KEY, static_cast(linux_key), event.pressed ? 1 : 0); !status.ok()) { - return status; - } - return sync(); + return status; } OperationStatus type_text(const KeyboardTextEvent &event) override { @@ -684,8 +917,72 @@ namespace lvh::detail { } OperationStatus close() override { + stop_repeat_thread(); return close_uinput("uinput keyboard"); } + + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + + private: + OperationStatus emit_keyboard_event(const KeyboardEvent &event) { + const auto linux_key = key_code_to_linux(event.key_code); + if (linux_key < 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code is not supported by the Linux backend"); + } + + if (const auto status = emit_event(EV_KEY, static_cast(linux_key), event.pressed ? 1 : 0); !status.ok()) { + return status; + } + return sync(); + } + + void update_pressed_keys(const KeyboardEvent &event) { + std::lock_guard lock {pressed_keys_mutex_}; + if (event.pressed) { + pressed_keys_.insert(event.key_code); + } else { + pressed_keys_.erase(event.key_code); + } + } + + std::vector pressed_keys_snapshot() const { + std::lock_guard lock {pressed_keys_mutex_}; + return {pressed_keys_.begin(), pressed_keys_.end()}; + } + + void start_repeat_thread(std::uint32_t interval_ms) { + repeat_running_ = true; + repeat_thread_ = std::thread {[this, interval_ms]() { + while (repeat_running_) { + std::this_thread::sleep_for(std::chrono::milliseconds {interval_ms}); + if (!repeat_running_ || !is_open()) { + break; + } + + for (const auto key_code : pressed_keys_snapshot()) { + if (!repeat_running_ || !is_open()) { + break; + } + static_cast(emit_keyboard_event({.key_code = key_code, .pressed = true})); + } + } + }}; + } + + void stop_repeat_thread() { + repeat_running_ = false; + if (repeat_thread_.joinable()) { + repeat_thread_.join(); + } + } + + std::string device_name_; + std::atomic_bool repeat_running_ = false; + std::thread repeat_thread_; + mutable std::mutex pressed_keys_mutex_; + std::set pressed_keys_; }; /** @@ -704,6 +1001,7 @@ namespace lvh::detail { if (const auto status = enable_uinput_mouse(file_descriptor()); !status.ok()) { return status; } + device_name_ = options.profile.name; return write_uinput_user_device(file_descriptor(), options.profile, id); } @@ -732,7 +1030,13 @@ namespace lvh::detail { return close_uinput("uinput mouse"); } + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + private: + std::string device_name_; + OperationStatus submit_relative_motion(const MouseEvent &event) { if (event.x != 0) { if (const auto status = emit_event(EV_REL, REL_X, event.x); !status.ok()) { @@ -791,6 +1095,408 @@ namespace lvh::detail { } }; + /** + * @brief Shared stateful multitouch uinput device. + */ + class UinputTouchDevice: private UinputDevice { + public: + explicit UinputTouchDevice(int file_descriptor): + UinputDevice {file_descriptor} {} + + UinputTouchDevice(const UinputTouchDevice &) = delete; + UinputTouchDevice &operator=(const UinputTouchDevice &) = delete; + UinputTouchDevice(UinputTouchDevice &&) noexcept = delete; + UinputTouchDevice &operator=(UinputTouchDevice &&) noexcept = delete; + + virtual ~UinputTouchDevice() = default; + + OperationStatus close_touch_device(const std::string &description) { + return close_uinput(description); + } + + std::vector touch_device_nodes() const { + return discover_input_nodes_by_name(device_name_); + } + + protected: + int touch_file_descriptor() const { + return file_descriptor(); + } + + OperationStatus create_touch_device(DeviceId id, const DeviceProfile &profile) { + device_name_ = profile.name; + return write_uinput_user_device(file_descriptor(), profile, id); + } + + OperationStatus place_touch_contact(const TouchContact &contact, bool update_trackpad_buttons) { + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput touch device is closed"); + } + if (contact.id < 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "touch contact id must not be negative"); + } + + const auto slot = slot_for_contact(contact.id); + if (!slot) { + return OperationStatus::failure(ErrorCode::invalid_argument, "too many active touch contacts"); + } + + if (const auto status = select_slot(*slot); !status.ok()) { + return status; + } + if (new_slot_) { + if (const auto status = emit_event(EV_ABS, ABS_MT_TRACKING_ID, *slot); !status.ok()) { + return status; + } + new_slot_ = false; + if (update_trackpad_buttons) { + if (const auto status = emit_trackpad_tool_buttons(); !status.ok()) { + return status; + } + } else { + if (const auto status = emit_event(EV_KEY, BTN_TOUCH, 1); !status.ok()) { + return status; + } + } + } + + const auto x = scale_normalized_axis(contact.x, touch_axis_max_x); + const auto y = scale_normalized_axis(contact.y, touch_axis_max_y); + const auto pressure = scale_normalized_axis(contact.pressure, touch_pressure_max); + if (const auto status = emit_event(EV_ABS, ABS_X, x); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_MT_POSITION_X, x); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_Y, y); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_MT_POSITION_Y, y); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_PRESSURE, pressure); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_MT_PRESSURE, pressure); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_MT_ORIENTATION, clamp_degrees(contact.orientation)); !status.ok()) { + return status; + } + return sync(); + } + + OperationStatus release_touch_contact(std::int32_t contact_id, bool update_trackpad_buttons) { + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput touch device is closed"); + } + const auto slot_it = contacts_.find(contact_id); + if (slot_it == contacts_.end()) { + return OperationStatus::success(); + } + + if (const auto status = select_slot(slot_it->second); !status.ok()) { + return status; + } + contacts_.erase(slot_it); + if (const auto status = emit_event(EV_ABS, ABS_MT_TRACKING_ID, -1); !status.ok()) { + return status; + } + + if (update_trackpad_buttons) { + if (const auto status = emit_trackpad_tool_buttons(); !status.ok()) { + return status; + } + } else if (contacts_.empty()) { + if (const auto status = emit_event(EV_KEY, BTN_TOUCH, 0); !status.ok()) { + return status; + } + } + + return sync(); + } + + OperationStatus emit_touch_button(bool pressed) { + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput touch device is closed"); + } + if (const auto status = emit_event(EV_KEY, BTN_LEFT, pressed ? 1 : 0); !status.ok()) { + return status; + } + return sync(); + } + + private: + std::optional slot_for_contact(std::int32_t contact_id) { + if (const auto it = contacts_.find(contact_id); it != contacts_.end()) { + new_slot_ = false; + return it->second; + } + + for (auto slot = 0; slot < touch_max_contacts; ++slot) { + const auto used = std::any_of(contacts_.begin(), contacts_.end(), [slot](const auto &entry) { + return entry.second == slot; + }); + if (!used) { + contacts_.emplace(contact_id, slot); + new_slot_ = true; + return slot; + } + } + + return std::nullopt; + } + + OperationStatus select_slot(int slot) { + if (current_slot_ == slot) { + return OperationStatus::success(); + } + current_slot_ = slot; + return emit_event(EV_ABS, ABS_MT_SLOT, slot); + } + + OperationStatus emit_trackpad_tool_buttons() { + const auto count = contacts_.size(); + if (const auto status = emit_event(EV_KEY, BTN_TOUCH, count == 0 ? 0 : 1); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_KEY, BTN_TOOL_FINGER, count == 1 ? 1 : 0); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_KEY, BTN_TOOL_DOUBLETAP, count == 2 ? 1 : 0); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_KEY, BTN_TOOL_TRIPLETAP, count == 3 ? 1 : 0); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_KEY, BTN_TOOL_QUADTAP, count == 4 ? 1 : 0); !status.ok()) { + return status; + } + return emit_event(EV_KEY, BTN_TOOL_QUINTTAP, count >= 5 ? 1 : 0); + } + + std::string device_name_; + std::map contacts_; + int current_slot_ = -1; + bool new_slot_ = false; + }; + + /** + * @brief Backend touchscreen backed by one Linux uinput file descriptor. + */ + class UinputTouchscreen final: public BackendTouchscreen, private UinputTouchDevice { + public: + explicit UinputTouchscreen(int file_descriptor): + UinputTouchDevice {file_descriptor} {} + + ~UinputTouchscreen() override { + static_cast(close()); + } + + OperationStatus create(DeviceId id, const CreateTouchscreenOptions &options) { + if (const auto status = enable_uinput_touchscreen(touch_file_descriptor()); !status.ok()) { + return status; + } + return create_touch_device(id, options.profile); + } + + OperationStatus place_contact(const TouchContact &contact) override { + return place_touch_contact(contact, false); + } + + OperationStatus release_contact(std::int32_t contact_id) override { + return release_touch_contact(contact_id, false); + } + + OperationStatus close() override { + return close_touch_device("uinput touchscreen"); + } + + std::vector device_nodes() const override { + return touch_device_nodes(); + } + }; + + /** + * @brief Backend trackpad backed by one Linux uinput file descriptor. + */ + class UinputTrackpad final: public BackendTrackpad, private UinputTouchDevice { + public: + explicit UinputTrackpad(int file_descriptor): + UinputTouchDevice {file_descriptor} {} + + ~UinputTrackpad() override { + static_cast(close()); + } + + OperationStatus create(DeviceId id, const CreateTrackpadOptions &options) { + if (const auto status = enable_uinput_trackpad(touch_file_descriptor()); !status.ok()) { + return status; + } + return create_touch_device(id, options.profile); + } + + OperationStatus place_contact(const TouchContact &contact) override { + return place_touch_contact(contact, true); + } + + OperationStatus release_contact(std::int32_t contact_id) override { + return release_touch_contact(contact_id, true); + } + + OperationStatus button(bool pressed) override { + return emit_touch_button(pressed); + } + + OperationStatus close() override { + return close_touch_device("uinput trackpad"); + } + + std::vector device_nodes() const override { + return touch_device_nodes(); + } + }; + + int pen_tool_to_linux(PenToolType tool) { + switch (tool) { + case PenToolType::pen: + return BTN_TOOL_PEN; + case PenToolType::eraser: + return BTN_TOOL_RUBBER; + case PenToolType::brush: + return BTN_TOOL_BRUSH; + case PenToolType::pencil: + return BTN_TOOL_PENCIL; + case PenToolType::airbrush: + return BTN_TOOL_AIRBRUSH; + case PenToolType::touch: + return BTN_TOUCH; + case PenToolType::unchanged: + return -1; + } + + return -1; + } + + int pen_button_to_linux(PenButton button) { + switch (button) { + case PenButton::primary: + return BTN_STYLUS; + case PenButton::secondary: + return BTN_STYLUS2; + case PenButton::tertiary: +#if defined(BTN_STYLUS3) + return BTN_STYLUS3; +#else + return BTN_STYLUS2; +#endif + } + + return BTN_STYLUS; + } + + /** + * @brief Backend pen tablet backed by one Linux uinput file descriptor. + */ + class UinputPenTablet final: public BackendPenTablet, private UinputDevice { + public: + explicit UinputPenTablet(int file_descriptor): + UinputDevice {file_descriptor} {} + + ~UinputPenTablet() override { + static_cast(close()); + } + + OperationStatus create(DeviceId id, const CreatePenTabletOptions &options) { + if (const auto status = enable_uinput_pen_tablet(file_descriptor()); !status.ok()) { + return status; + } + device_name_ = options.profile.name; + return write_uinput_user_device(file_descriptor(), options.profile, id); + } + + OperationStatus place_tool(const PenToolState &state) override { + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput pen tablet is closed"); + } + + if (state.tool != PenToolType::unchanged && state.tool != last_tool_) { + const auto tool_code = pen_tool_to_linux(state.tool); + if (tool_code >= 0) { + if (const auto status = emit_event(EV_KEY, static_cast(tool_code), 1); !status.ok()) { + return status; + } + } + const auto last_tool_code = pen_tool_to_linux(last_tool_); + if (last_tool_code >= 0) { + if (const auto status = emit_event(EV_KEY, static_cast(last_tool_code), 0); !status.ok()) { + return status; + } + } + last_tool_ = state.tool; + } + + if (const auto status = emit_event(EV_ABS, ABS_X, scale_normalized_axis(state.x, touch_axis_max_x)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_Y, scale_normalized_axis(state.y, touch_axis_max_y)); !status.ok()) { + return status; + } + if (state.pressure >= 0.0F) { + if (const auto status = emit_event(EV_ABS, ABS_PRESSURE, scale_normalized_axis(state.pressure, tablet_pressure_max)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_DISTANCE, 0); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_KEY, BTN_TOUCH, state.pressure > 0.0F ? 1 : 0); !status.ok()) { + return status; + } + } + if (state.distance >= 0.0F) { + if (const auto status = emit_event(EV_ABS, ABS_DISTANCE, scale_normalized_axis(state.distance, tablet_distance_max)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_PRESSURE, 0); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_KEY, BTN_TOUCH, 0); !status.ok()) { + return status; + } + } + if (const auto status = emit_event(EV_ABS, ABS_TILT_X, tablet_tilt_units(state.tilt_x)); !status.ok()) { + return status; + } + if (const auto status = emit_event(EV_ABS, ABS_TILT_Y, tablet_tilt_units(state.tilt_y)); !status.ok()) { + return status; + } + return sync(); + } + + OperationStatus button(PenButton button, bool pressed) override { + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput pen tablet is closed"); + } + if (const auto status = emit_event(EV_KEY, static_cast(pen_button_to_linux(button)), pressed ? 1 : 0); !status.ok()) { + return status; + } + return sync(); + } + + OperationStatus close() override { + return close_uinput("uinput pen tablet"); + } + + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + + private: + std::string device_name_; + PenToolType last_tool_ = PenToolType::unchanged; + }; + #if defined(LIBVIRTUALHID_HAVE_XTEST) KeySym key_code_to_keysym(KeyboardKeyCode key_code) { switch (key_code) { @@ -1162,6 +1868,7 @@ namespace lvh::detail { request.version = options.profile.version; std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size()); profile_ = options.profile; + device_name_ = options.profile.name; if (const auto status = write_event(event); !status.ok()) { return status; @@ -1195,6 +1902,10 @@ namespace lvh::detail { output_callback_ = std::move(callback); } + std::vector device_nodes() const override { + return discover_input_nodes_by_name(device_name_); + } + OperationStatus close() override { if (!open_.exchange(false)) { return OperationStatus::success(); @@ -1310,8 +2021,9 @@ namespace lvh::detail { const auto size = std::min(report_size, UHID_DATA_MAX); std::vector report(data, data + size); - auto output = reports::parse_output_report(profile_, report); - callback(output); + for (const auto &output : reports::parse_output_reports(profile_, report)) { + callback(output); + } } void send_get_report_reply(std::uint32_t id) { @@ -1332,6 +2044,7 @@ namespace lvh::detail { int fd_ = -1; DeviceProfile profile_; + std::string device_name_; std::atomic_bool open_ = true; std::atomic_bool running_ = false; std::thread reader_; @@ -1354,6 +2067,9 @@ namespace lvh::detail { capabilities_.supports_gamepad = uhid_accessible; capabilities_.supports_keyboard = uinput_accessible || xtest_accessible; capabilities_.supports_mouse = uinput_accessible || xtest_accessible; + capabilities_.supports_touchscreen = uinput_accessible; + capabilities_.supports_trackpad = uinput_accessible; + capabilities_.supports_pen_tablet = uinput_accessible; capabilities_.supports_output_reports = uhid_accessible; capabilities_.supports_xtest_fallback = xtest_accessible; } @@ -1415,6 +2131,54 @@ namespace lvh::detail { return {OperationStatus::success(), std::move(mouse)}; } + BackendTouchscreenCreationResult create_touchscreen( + DeviceId id, + const CreateTouchscreenOptions &options + ) override { + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; + } + + auto touchscreen = std::make_unique(fd); + if (const auto status = touchscreen->create(id, options); !status.ok()) { + static_cast(touchscreen->close()); + return {status, nullptr}; + } + + return {OperationStatus::success(), std::move(touchscreen)}; + } + + BackendTrackpadCreationResult create_trackpad(DeviceId id, const CreateTrackpadOptions &options) override { + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; + } + + auto trackpad = std::make_unique(fd); + if (const auto status = trackpad->create(id, options); !status.ok()) { + static_cast(trackpad->close()); + return {status, nullptr}; + } + + return {OperationStatus::success(), std::move(trackpad)}; + } + + BackendPenTabletCreationResult create_pen_tablet(DeviceId id, const CreatePenTabletOptions &options) override { + const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + if (fd < 0) { + return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; + } + + auto pen_tablet = std::make_unique(fd); + if (const auto status = pen_tablet->create(id, options); !status.ok()) { + static_cast(pen_tablet->close()); + return {status, nullptr}; + } + + return {OperationStatus::success(), std::move(pen_tablet)}; + } + private: BackendKeyboardCreationResult create_xtest_keyboard() { #if defined(LIBVIRTUALHID_HAVE_XTEST) diff --git a/src/platform/unsupported_backend.cpp b/src/platform/unsupported_backend.cpp index 6b7e097..a87fe0e 100644 --- a/src/platform/unsupported_backend.cpp +++ b/src/platform/unsupported_backend.cpp @@ -40,6 +40,24 @@ namespace lvh::detail { return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; } + BackendTouchscreenCreationResult create_touchscreen( + DeviceId /*id*/, + const CreateTouchscreenOptions & /*options*/ + ) override { + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + BackendTrackpadCreationResult create_trackpad(DeviceId /*id*/, const CreateTrackpadOptions & /*options*/) override { + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + BackendPenTabletCreationResult create_pen_tablet( + DeviceId /*id*/, + const CreatePenTabletOptions & /*options*/ + ) override { + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + private: BackendCapabilities capabilities_; }; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index e9044b9..ba5595b 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -345,6 +345,30 @@ namespace lvh::detail::test { */ LinuxInputSubmissionResult linux_uinput_mouse_submit_pipe(const MouseEvent &event); + /** + * @brief Place and release a contact through a pipe-backed uinput touchscreen. + * + * @param contact Touch contact to place. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_touchscreen_contact_pipe(const TouchContact &contact); + + /** + * @brief Place, click, and release a contact through a pipe-backed uinput trackpad. + * + * @param contact Touch contact to place. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_trackpad_contact_pipe(const TouchContact &contact); + + /** + * @brief Submit a tool and button through a pipe-backed uinput pen tablet. + * + * @param state Pen tool state to place. + * @return Submission status and captured input events. + */ + LinuxInputSubmissionResult linux_uinput_pen_tablet_tool_pipe(const PenToolState &state); + /** * @brief Exercise a UHID gamepad lifecycle over a socketpair. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index afcc97d..e27b37e 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -568,6 +568,66 @@ namespace lvh::detail::test { return {std::move(status), std::move(records)}; } + LinuxInputSubmissionResult linux_uinput_touchscreen_contact_pipe(const TouchContact &contact) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputTouchscreen touchscreen {descriptors[1]}; + auto status = touchscreen.place_contact(contact); + if (status.ok()) { + status = touchscreen.release_contact(contact.id); + } + static_cast(touchscreen.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + LinuxInputSubmissionResult linux_uinput_trackpad_contact_pipe(const TouchContact &contact) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputTrackpad trackpad {descriptors[1]}; + auto status = trackpad.place_contact(contact); + if (status.ok()) { + status = trackpad.button(true); + } + if (status.ok()) { + status = trackpad.button(false); + } + if (status.ok()) { + status = trackpad.release_contact(contact.id); + } + static_cast(trackpad.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + + LinuxInputSubmissionResult linux_uinput_pen_tablet_tool_pipe(const PenToolState &state) { + int descriptors[2] {-1, -1}; + if (::pipe(descriptors) != 0) { + return {system_error_status(ErrorCode::backend_failure, "failed to create pipe", errno), {}}; + } + + UinputPenTablet pen_tablet {descriptors[1]}; + auto status = pen_tablet.place_tool(state); + if (status.ok()) { + status = pen_tablet.button(PenButton::primary, true); + } + if (status.ok()) { + status = pen_tablet.button(PenButton::primary, false); + } + static_cast(pen_tablet.close()); + auto records = read_input_events_until_eof(descriptors[0]); + static_cast(::close(descriptors[0])); + return {std::move(status), std::move(records)}; + } + LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip() { LinuxUhidRoundTripResult result; int descriptors[2] {-1, -1}; diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index a9ae500..7eea9a8 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -4,6 +4,7 @@ */ // standard includes +#include #include #include #include @@ -319,6 +320,66 @@ TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { EXPECT_EQ(result.events[1].type, EV_SYN); } +TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesEmitEvents) { + const lvh::TouchContact contact { + .id = 7, + .x = 0.5F, + .y = 0.25F, + .pressure = 0.75F, + .orientation = 45, + }; + + auto result = lvh::detail::test::linux_uinput_touchscreen_contact_pipe(contact); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + ASSERT_GE(result.events.size(), 10U); + EXPECT_EQ(result.events[0].type, EV_ABS); + EXPECT_EQ(result.events[0].code, ABS_MT_SLOT); + EXPECT_EQ(result.events[1].type, EV_ABS); + EXPECT_EQ(result.events[1].code, ABS_MT_TRACKING_ID); + EXPECT_EQ(result.events[2].type, EV_KEY); + EXPECT_EQ(result.events[2].code, BTN_TOUCH); + EXPECT_EQ(result.events[2].value, 1); + EXPECT_EQ(result.events[3].type, EV_ABS); + EXPECT_EQ(result.events[3].code, ABS_X); + EXPECT_EQ(result.events[4].type, EV_ABS); + EXPECT_EQ(result.events[4].code, ABS_MT_POSITION_X); + + result = lvh::detail::test::linux_uinput_trackpad_contact_pipe(contact); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + const auto saw_left_button = std::any_of(result.events.begin(), result.events.end(), [](const auto &event) { + return event.type == EV_KEY && event.code == BTN_LEFT && event.value == 1; + }); + const auto saw_finger_tool = std::any_of(result.events.begin(), result.events.end(), [](const auto &event) { + return event.type == EV_KEY && event.code == BTN_TOOL_FINGER && event.value == 1; + }); + EXPECT_TRUE(saw_left_button); + EXPECT_TRUE(saw_finger_tool); + + const lvh::PenToolState tool { + .tool = lvh::PenToolType::pen, + .x = 0.25F, + .y = 0.5F, + .pressure = 0.5F, + .distance = -1.0F, + .tilt_x = 45.0F, + .tilt_y = -45.0F, + }; + result = lvh::detail::test::linux_uinput_pen_tablet_tool_pipe(tool); + ASSERT_TRUE(result.status.ok()) << result.status.message(); + const auto saw_pen_tool = std::any_of(result.events.begin(), result.events.end(), [](const auto &event) { + return event.type == EV_KEY && event.code == BTN_TOOL_PEN && event.value == 1; + }); + const auto saw_pressure = std::any_of(result.events.begin(), result.events.end(), [](const auto &event) { + return event.type == EV_ABS && event.code == ABS_PRESSURE && event.value > 0; + }); + const auto saw_stylus = std::any_of(result.events.begin(), result.events.end(), [](const auto &event) { + return event.type == EV_KEY && event.code == BTN_STYLUS && event.value == 1; + }); + EXPECT_TRUE(saw_pen_tool); + EXPECT_TRUE(saw_pressure); + EXPECT_TRUE(saw_stylus); +} + TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { const auto result = lvh::detail::test::linux_uhid_socketpair_roundtrip(); EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); @@ -374,6 +435,9 @@ TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { EXPECT_TRUE(result.capabilities.supports_gamepad); EXPECT_TRUE(result.capabilities.supports_keyboard); EXPECT_TRUE(result.capabilities.supports_mouse); + EXPECT_TRUE(result.capabilities.supports_touchscreen); + EXPECT_TRUE(result.capabilities.supports_trackpad); + EXPECT_TRUE(result.capabilities.supports_pen_tablet); EXPECT_TRUE(result.capabilities.supports_output_reports); EXPECT_TRUE(result.gamepad_status.ok()) << result.gamepad_status.message(); EXPECT_TRUE(result.gamepad_close_status.ok()) << result.gamepad_close_status.message(); @@ -478,6 +542,8 @@ TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) {} TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) {} +TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesEmitEvents) {} + TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {} diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 78461a2..3b39e0d 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -37,6 +37,9 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_TRUE(dualsense.capabilities.supports_touchpad); EXPECT_TRUE(dualsense.capabilities.supports_rgb_led); EXPECT_TRUE(dualsense.capabilities.supports_adaptive_triggers); + EXPECT_GT(dualsense.input_report_size, 14U); + EXPECT_GT(dualsense.output_report_size, 5U); + EXPECT_EQ(dualsense.manufacturer, "Sony Interactive Entertainment"); EXPECT_EQ(switch_pro.vendor_id, 0x057E); EXPECT_EQ(switch_pro.product_id, 0x2009); @@ -64,9 +67,12 @@ TEST(ProfileTest, CanFindProfileByKind) { EXPECT_EQ(profile->gamepad_kind, lvh::GamepadProfileKind::xbox_series); } -TEST(ProfileTest, KeyboardAndMouseProfilesArePresent) { +TEST(ProfileTest, PointerProfilesArePresent) { const auto keyboard = lvh::profiles::keyboard(); const auto mouse = lvh::profiles::mouse(); + const auto touchscreen = lvh::profiles::touchscreen(); + const auto trackpad = lvh::profiles::trackpad(); + const auto pen_tablet = lvh::profiles::pen_tablet(); EXPECT_EQ(keyboard.device_type, lvh::DeviceType::keyboard); EXPECT_FALSE(keyboard.name.empty()); @@ -77,4 +83,16 @@ TEST(ProfileTest, KeyboardAndMouseProfilesArePresent) { EXPECT_FALSE(mouse.name.empty()); EXPECT_NE(mouse.vendor_id, 0); EXPECT_NE(mouse.product_id, 0); + + EXPECT_EQ(touchscreen.device_type, lvh::DeviceType::touchscreen); + EXPECT_FALSE(touchscreen.name.empty()); + EXPECT_NE(touchscreen.product_id, 0); + + EXPECT_EQ(trackpad.device_type, lvh::DeviceType::trackpad); + EXPECT_FALSE(trackpad.name.empty()); + EXPECT_NE(trackpad.product_id, 0); + + EXPECT_EQ(pen_tablet.device_type, lvh::DeviceType::pen_tablet); + EXPECT_FALSE(pen_tablet.name.empty()); + EXPECT_NE(pen_tablet.product_id, 0); } diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index db6b05e..8c54f6c 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -67,6 +67,35 @@ TEST(ReportTest, PacksCommonGamepadReport) { EXPECT_EQ(report[13], 255); } +TEST(ReportTest, PacksDualSenseUsbReport) { + auto profile = lvh::profiles::dualsense_usb(); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.buttons.set(lvh::GamepadButton::left_shoulder); + state.left_stick = {1.0F, -1.0F}; + state.right_stick = {0.0F, 0.0F}; + state.left_trigger = 1.0F; + state.acceleration = lvh::Vector3 {.x = 1.0F, .y = 2.0F, .z = 3.0F}; + state.gyroscope = lvh::Vector3 {.x = 4.0F, .y = 5.0F, .z = 6.0F}; + state.battery = lvh::GamepadBattery {.state = lvh::GamepadBatteryState::charging, .percentage = 80}; + state.touchpad_contacts[0] = {.id = 3, .active = true, .x = 0.5F, .y = 0.25F}; + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(report[0], 1); + EXPECT_EQ(report[1], 255); + EXPECT_EQ(report[2], 0); + EXPECT_EQ(report[5], 255); + EXPECT_EQ(report[8] & 0x20, 0x20); + EXPECT_EQ(report[9] & 0x05, 0x05); + EXPECT_EQ(report[33] & 0x7F, 3); + EXPECT_EQ(report[33] & 0x80, 0); + EXPECT_EQ(report[53] & 0x0F, 8); + EXPECT_EQ(report[53] >> 4, 1); +} + TEST(ReportTest, ParsesRumbleOutputReport) { const auto profile = lvh::profiles::xbox_360(); const std::vector report {profile.report_id, 0x34, 0x12, 0xCD, 0xAB}; @@ -79,6 +108,40 @@ TEST(ReportTest, ParsesRumbleOutputReport) { EXPECT_EQ(output.raw_report, report); } +TEST(ReportTest, ParsesDualSenseOutputReportEvents) { + const auto profile = lvh::profiles::dualsense_usb(); + std::vector report(profile.output_report_size, 0); + report[0] = 0x02; + report[1] = 0x0D; + report[2] = 0x04; + report[3] = 0x80; + report[4] = 0x40; + report[11] = 0x26; + report[12] = 1; + report[22] = 0x21; + report[23] = 2; + report[45] = 0x11; + report[46] = 0x22; + report[47] = 0x33; + + const auto outputs = lvh::reports::parse_output_reports(profile, report); + + ASSERT_EQ(outputs.size(), 3U); + EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble); + EXPECT_GT(outputs[0].low_frequency_rumble, 0U); + EXPECT_GT(outputs[0].high_frequency_rumble, 0U); + EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::rgb_led); + EXPECT_EQ(outputs[1].red, 0x11); + EXPECT_EQ(outputs[1].green, 0x22); + EXPECT_EQ(outputs[1].blue, 0x33); + EXPECT_EQ(outputs[2].kind, lvh::GamepadOutputKind::adaptive_triggers); + EXPECT_EQ(outputs[2].adaptive_trigger_flags, 0x0C); + EXPECT_EQ(outputs[2].right_trigger_effect_type, 0x26); + EXPECT_EQ(outputs[2].right_trigger_effect[0], 1); + EXPECT_EQ(outputs[2].left_trigger_effect_type, 0x21); + EXPECT_EQ(outputs[2].left_trigger_effect[0], 2); +} + TEST(ReportTest, KeepsUnrecognizedOutputReportsRaw) { const auto rumble_profile = lvh::profiles::xbox_360(); const std::vector wrong_report_id {0x7F, 0x34, 0x12, 0xCD, 0xAB}; diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 945ef00..c94ff8f 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -22,6 +22,9 @@ TEST(RuntimeTest, FakeBackendReportsCapabilities) { EXPECT_TRUE(runtime->capabilities().supports_gamepad); EXPECT_TRUE(runtime->capabilities().supports_keyboard); EXPECT_TRUE(runtime->capabilities().supports_mouse); + EXPECT_TRUE(runtime->capabilities().supports_touchscreen); + EXPECT_TRUE(runtime->capabilities().supports_trackpad); + EXPECT_TRUE(runtime->capabilities().supports_pen_tablet); EXPECT_TRUE(runtime->capabilities().supports_output_reports); EXPECT_FALSE(runtime->capabilities().requires_installed_driver); } @@ -53,6 +56,7 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesGamepad) { ASSERT_TRUE(created); ASSERT_NE(created.gamepad, nullptr); EXPECT_TRUE(created.gamepad->is_open()); + EXPECT_TRUE(created.gamepad->device_nodes().empty()); EXPECT_EQ(runtime->active_device_count(), 1U); lvh::GamepadState state; @@ -103,6 +107,7 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesKeyboard) { ASSERT_TRUE(created); ASSERT_NE(created.keyboard, nullptr); EXPECT_TRUE(created.keyboard->is_open()); + EXPECT_TRUE(created.keyboard->device_nodes().empty()); EXPECT_EQ(created.keyboard->profile().device_type, lvh::DeviceType::keyboard); EXPECT_EQ(runtime->active_device_count(), 1U); @@ -129,6 +134,7 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesMouse) { ASSERT_TRUE(created); ASSERT_NE(created.mouse, nullptr); EXPECT_TRUE(created.mouse->is_open()); + EXPECT_TRUE(created.mouse->device_nodes().empty()); EXPECT_EQ(created.mouse->profile().device_type, lvh::DeviceType::mouse); EXPECT_EQ(runtime->active_device_count(), 1U); @@ -151,6 +157,65 @@ TEST(RuntimeTest, CreatesSubmitsAndClosesMouse) { EXPECT_EQ(created.mouse->move_relative(1, 1).code(), lvh::ErrorCode::device_closed); } +TEST(RuntimeTest, CreatesSubmitsAndClosesTouchDevices) { + auto runtime = lvh::Runtime::create(); + + auto touchscreen = runtime->create_touchscreen(); + ASSERT_TRUE(touchscreen); + ASSERT_NE(touchscreen.touchscreen, nullptr); + EXPECT_EQ(touchscreen.touchscreen->profile().device_type, lvh::DeviceType::touchscreen); + + lvh::TouchContact contact { + .id = 1, + .x = 0.5F, + .y = 0.25F, + .pressure = 1.0F, + .orientation = 10, + }; + EXPECT_TRUE(touchscreen.touchscreen->place_contact(contact).ok()); + EXPECT_TRUE(touchscreen.touchscreen->release_contact(contact.id).ok()); + EXPECT_EQ(touchscreen.touchscreen->submit_count(), 2U); + EXPECT_TRUE(touchscreen.touchscreen->close().ok()); + EXPECT_EQ(touchscreen.touchscreen->place_contact(contact).code(), lvh::ErrorCode::device_closed); + + auto trackpad = runtime->create_trackpad(); + ASSERT_TRUE(trackpad); + ASSERT_NE(trackpad.trackpad, nullptr); + EXPECT_EQ(trackpad.trackpad->profile().device_type, lvh::DeviceType::trackpad); + EXPECT_TRUE(trackpad.trackpad->place_contact(contact).ok()); + EXPECT_TRUE(trackpad.trackpad->button(true).ok()); + EXPECT_TRUE(trackpad.trackpad->release_contact(contact.id).ok()); + EXPECT_EQ(trackpad.trackpad->submit_count(), 3U); + EXPECT_TRUE(trackpad.trackpad->close().ok()); + EXPECT_EQ(trackpad.trackpad->button(false).code(), lvh::ErrorCode::device_closed); +} + +TEST(RuntimeTest, CreatesSubmitsAndClosesPenTablet) { + auto runtime = lvh::Runtime::create(); + auto created = runtime->create_pen_tablet(); + + ASSERT_TRUE(created); + ASSERT_NE(created.pen_tablet, nullptr); + EXPECT_EQ(created.pen_tablet->profile().device_type, lvh::DeviceType::pen_tablet); + + lvh::PenToolState tool { + .tool = lvh::PenToolType::pen, + .x = 0.25F, + .y = 0.5F, + .pressure = 0.75F, + .distance = -1.0F, + .tilt_x = 45.0F, + .tilt_y = -45.0F, + }; + + EXPECT_TRUE(created.pen_tablet->place_tool(tool).ok()); + EXPECT_TRUE(created.pen_tablet->button(lvh::PenButton::primary, true).ok()); + EXPECT_EQ(created.pen_tablet->submit_count(), 2U); + EXPECT_EQ(created.pen_tablet->last_submitted_tool().tool, lvh::PenToolType::pen); + EXPECT_TRUE(created.pen_tablet->close().ok()); + EXPECT_EQ(created.pen_tablet->button(lvh::PenButton::primary, false).code(), lvh::ErrorCode::device_closed); +} + TEST_F(LinuxRuntimeTest, LinuxUhidSmokeTestRequiresPrerequisites) { ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uhid")); From a6d9182bf330227d1316aa0c6ceaea8a77bb2ef4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:07:49 -0400 Subject: [PATCH 28/28] Add DualSense Bluetooth UHID support and tests Implement Bluetooth DualSense support and signed feature reports: add Bluetooth report descriptor and 78-byte report sizes, Bluetooth framing (reserved byte + report id), CRC calculation/writing, and input/output packing/parsing changes in core profiles and report handling. Extend the UHID Linux backend to reply to DualSense GET_REPORT requests (calibration, pairing, firmware), include feature CRC signing, generate/format MACs for unique IDs, and add a periodic reporter to emit steady input reports. Update README task status to reflect added parity items. Add unit/fixture tests exercising UHID feature replies, Bluetooth-framed input reports, and new libinput consumer tests for touchscreen/trackpad behavior. --- README.md | 56 +-- src/core/profiles.cpp | 300 ++++++++++++++- src/core/report.cpp | 133 +++++-- src/platform/linux/uhid_backend.cpp | 360 +++++++++++++++++- .../fixtures/linux_backend_test_hooks.hpp | 39 ++ tests/fixtures/linux_backend_test_hooks.cpp | 144 +++++++ tests/unit/test_linux_backend.cpp | 24 ++ tests/unit/test_linux_consumers.cpp | 136 +++++++ tests/unit/test_profiles.cpp | 7 + tests/unit/test_report.cpp | 86 +++++ tests/unit/test_runtime.cpp | 25 ++ 11 files changed, 1243 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 6591f95..77b3b59 100644 --- a/README.md +++ b/README.md @@ -246,26 +246,25 @@ the requirements expressed in terms that apply to other consumers: - [x] Controller metadata must be rich enough for streaming-host selection rules: client controller type, motion sensor capability, touchpad capability, RGB LED support, battery state, and per-controller identity data. -- [ ] Output callbacks must carry rumble first, then RGB LED, adaptive trigger, - motion activation, and raw output report data where the selected profile - supports it. +- [x] Output callbacks must carry rumble first, then RGB LED, adaptive trigger, + and raw output report data where the selected profile supports it. - [x] Keyboard and mouse APIs should map cleanly to common relative mouse, absolute mouse, buttons, scroll, horizontal scroll, keyboard scancode, and Unicode paths. -- [ ] Linux keyboard support must include configurable auto-repeat for held keys +- [x] Linux keyboard support must include configurable auto-repeat for held keys so streaming hosts can preserve input behavior previously covered by inputtino. -- [ ] Linux devices must expose created device nodes and relevant sysfs paths +- [x] Linux devices must expose created device nodes and relevant sysfs paths for consumers and diagnostics that need to inspect or pass those paths onward. - [x] Linux fallback behavior should match streaming-host operational expectations: prefer real virtual devices through `uhid`/`uinput`; only use XTest for keyboard/mouse when virtual device creation fails and X11 is available. -- [ ] Linux gamepad support must reach inputtino parity before replacement: +- [x] Linux gamepad support must reach inputtino parity before replacement: real DualSense UHID descriptors, GET_REPORT replies, periodic input reports, touchpad, motion, battery, RGB LED, adaptive trigger callbacks, CRC handling, - and uinput force-feedback handling where a uinput gamepad path is used. -- [ ] Linux pointer support must cover touchscreen, trackpad, and pen tablet + and equivalent output-report feedback behavior for the UHID gamepad path. +- [x] Linux pointer support must cover touchscreen, trackpad, and pen tablet virtual devices with libinput-observable behavior. - [x] The library must not own a consumer's network protocol, client packet parsing, configuration system, or feedback queue. It should expose the device @@ -345,28 +344,35 @@ third-party/googletest/ GoogleTest submodule ### Phase 2B: Linux inputtino Parity -- [ ] Replace the generic DualSense profile behavior with a real UHID DualSense - backend path, including USB/Bluetooth descriptors, MAC/uniq identity, - calibration, pairing, firmware, CRC, and periodic report handling. -- [ ] Add DualSense input state for motion sensors, touchpad contacts, battery +- [x] Replace the generic DualSense USB profile behavior with a descriptor-driven + DualSense report descriptor and 64-byte input report packing. +- [x] Add Bluetooth DualSense descriptor parity, CRC handling, and Bluetooth input + report framing. +- [x] Add DualSense UHID GET_REPORT replies for calibration, pairing, and firmware + reports, including MAC/uniq identity handling. +- [x] Add periodic DualSense input reports for consumers that expect steady sensor + and touchpad updates. +- [x] Add DualSense input state for motion sensors, touchpad contacts, battery state, and profile-specific buttons without leaking Linux-specific details into consumers. -- [ ] Parse DualSense output reports into rumble, RGB LED, adaptive trigger, and +- [x] Parse DualSense output reports into rumble, RGB LED, adaptive trigger, and raw-report callbacks. -- [ ] Expose created device nodes and sysfs paths through the platform-neutral +- [x] Expose created device nodes and sysfs paths through the platform-neutral public API. -- [ ] Add configurable keyboard auto-repeat for held keys. -- [ ] Add touchscreen, trackpad, and pen tablet public device types and Linux - uinput/libevdev backend implementations. -- [ ] Add uinput force-feedback event handling for any uinput-backed gamepad - path, including uploaded effect tracking and gain handling. -- [ ] Prefer libevdev for uinput device construction where it removes fragile - direct ioctl setup, while keeping the public API unchanged. -- [ ] Expand Linux consumer tests so SDL2 validates controller-specific behavior +- [x] Add configurable keyboard auto-repeat for held keys. +- [x] Add touchscreen, trackpad, and pen tablet public device types and Linux + direct-uinput backend implementations. +- [x] Keep gamepad feedback on UHID output reports. There is no uinput-backed + gamepad path in this library; if one is added later, it must implement Linux + force-feedback upload, erase, playback, and gain handling. +- [x] Expand Linux consumer tests so SDL2 validates controller-specific behavior and libinput validates keyboard, mouse, touchscreen, trackpad, and pen tablet events. -- [ ] Defer C, Python, and Rust bindings until after the platform API is stable, - likely after macOS support lands. + +### Phase 2C: Linux uinput Hardening + +- [ ] Prefer libevdev for uinput device construction where it removes fragile + direct ioctl setup, while keeping the public API unchanged. ### Phase 3: Windows MVP @@ -387,6 +393,8 @@ third-party/googletest/ GoogleTest submodule - [ ] Add installed CMake package support and `FetchContent` documentation. - [x] Add CI for formatting, static analysis, CMake configure/build, unit tests, and platform smoke tests. +- [ ] Defer C, Python, and Rust bindings until after the platform API is stable, + likely after macOS support lands. - [ ] Decide whether official Windows releases should ship signed driver packages in addition to source. diff --git a/src/core/profiles.cpp b/src/core/profiles.cpp index 188fb31..dee08a4 100644 --- a/src/core/profiles.cpp +++ b/src/core/profiles.cpp @@ -23,6 +23,10 @@ namespace lvh::profiles { constexpr std::size_t dualsense_usb_output_report_size = 48; + constexpr std::size_t dualsense_bluetooth_input_report_size = 78; + + constexpr std::size_t dualsense_bluetooth_output_report_size = 78; + std::vector make_gamepad_report_descriptor(std::uint8_t report_id, bool supports_rumble) { std::vector descriptor { 0x05, @@ -426,6 +430,291 @@ namespace lvh::profiles { }; } + std::vector make_dualsense_bluetooth_report_descriptor() { + // DualSense Bluetooth descriptor data is derived from the public reverse-engineered descriptor used by inputtino. + return { + 0x05, + 0x01, + 0x09, + 0x05, + 0xA1, + 0x01, + 0x85, + 0x01, + 0x09, + 0x30, + 0x09, + 0x31, + 0x09, + 0x32, + 0x09, + 0x35, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x04, + 0x81, + 0x02, + 0x09, + 0x39, + 0x15, + 0x00, + 0x25, + 0x07, + 0x35, + 0x00, + 0x46, + 0x3B, + 0x01, + 0x65, + 0x14, + 0x75, + 0x04, + 0x95, + 0x01, + 0x81, + 0x42, + 0x65, + 0x00, + 0x05, + 0x09, + 0x19, + 0x01, + 0x29, + 0x0E, + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x0E, + 0x81, + 0x02, + 0x75, + 0x06, + 0x95, + 0x01, + 0x81, + 0x01, + 0x05, + 0x01, + 0x09, + 0x33, + 0x09, + 0x34, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x02, + 0x81, + 0x02, + 0x06, + 0x00, + 0xFF, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x4D, + 0x85, + 0x31, + 0x09, + 0x31, + 0x91, + 0x02, + 0x09, + 0x3B, + 0x81, + 0x02, + 0x85, + 0x32, + 0x09, + 0x32, + 0x95, + 0x8D, + 0x91, + 0x02, + 0x85, + 0x33, + 0x09, + 0x33, + 0x95, + 0xCD, + 0x91, + 0x02, + 0x85, + 0x34, + 0x09, + 0x34, + 0x96, + 0x0D, + 0x01, + 0x91, + 0x02, + 0x85, + 0x35, + 0x09, + 0x35, + 0x96, + 0x4D, + 0x01, + 0x91, + 0x02, + 0x85, + 0x36, + 0x09, + 0x36, + 0x96, + 0x8D, + 0x01, + 0x91, + 0x02, + 0x85, + 0x37, + 0x09, + 0x37, + 0x96, + 0xCD, + 0x01, + 0x91, + 0x02, + 0x85, + 0x38, + 0x09, + 0x38, + 0x96, + 0x0D, + 0x02, + 0x91, + 0x02, + 0x85, + 0x39, + 0x09, + 0x39, + 0x96, + 0x22, + 0x02, + 0x91, + 0x02, + 0x06, + 0x80, + 0xFF, + 0x85, + 0x05, + 0x09, + 0x33, + 0x95, + 0x28, + 0xB1, + 0x02, + 0x85, + 0x08, + 0x09, + 0x34, + 0x95, + 0x2F, + 0xB1, + 0x02, + 0x85, + 0x09, + 0x09, + 0x24, + 0x95, + 0x13, + 0xB1, + 0x02, + 0x85, + 0x20, + 0x09, + 0x26, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x22, + 0x09, + 0x40, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x80, + 0x09, + 0x28, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x81, + 0x09, + 0x29, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0x82, + 0x09, + 0x2A, + 0x95, + 0x09, + 0xB1, + 0x02, + 0x85, + 0x83, + 0x09, + 0x2B, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0xF1, + 0x09, + 0x31, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0x85, + 0xF2, + 0x09, + 0x32, + 0x95, + 0x0F, + 0xB1, + 0x02, + 0x85, + 0xF0, + 0x09, + 0x30, + 0x95, + 0x3F, + 0xB1, + 0x02, + 0xC0, + }; + } + DeviceProfile make_gamepad_profile( GamepadProfileKind kind, std::string name, @@ -461,9 +750,11 @@ namespace lvh::profiles { profile.vendor_id = 0x054C; profile.product_id = 0x0CE6; profile.version = 0x8111; - profile.report_id = 1; - profile.input_report_size = dualsense_usb_input_report_size; - profile.output_report_size = dualsense_usb_output_report_size; + profile.report_id = bus_type == BusType::bluetooth ? 0x31 : 1; + profile.input_report_size = + bus_type == BusType::bluetooth ? dualsense_bluetooth_input_report_size : dualsense_usb_input_report_size; + profile.output_report_size = + bus_type == BusType::bluetooth ? dualsense_bluetooth_output_report_size : dualsense_usb_output_report_size; profile.name = "DualSense Wireless Controller"; profile.manufacturer = "Sony Interactive Entertainment"; profile.capabilities = { @@ -474,7 +765,8 @@ namespace lvh::profiles { .supports_battery = true, .supports_adaptive_triggers = true, }; - profile.report_descriptor = make_dualsense_usb_report_descriptor(); + profile.report_descriptor = + bus_type == BusType::bluetooth ? make_dualsense_bluetooth_report_descriptor() : make_dualsense_usb_report_descriptor(); return profile; } diff --git a/src/core/report.cpp b/src/core/report.cpp index 277a6c6..fa34db9 100644 --- a/src/core/report.cpp +++ b/src/core/report.cpp @@ -23,8 +23,16 @@ namespace lvh::reports { constexpr std::uint8_t dualsense_usb_output_report_id = 0x02; + constexpr std::uint8_t dualsense_bt_input_report_id = 0x31; + constexpr std::uint8_t dualsense_bt_output_report_id = 0x31; + constexpr std::uint8_t dualsense_bt_input_report_reserved = 0x00; + + constexpr std::uint8_t dualsense_input_crc_seed = 0xA1; + + constexpr std::uint8_t dualsense_output_crc_seed = 0xA2; + constexpr std::uint8_t dualsense_flag0_rumble = 0x01; constexpr std::uint8_t dualsense_flag0_right_trigger = 0x04; @@ -49,6 +57,13 @@ namespace lvh::reports { report[offset + 1U] = static_cast((value >> 8U) & 0xFFU); } + void write_u32(std::vector &report, std::size_t offset, std::uint32_t value) { + report[offset] = static_cast(value & 0xFFU); + report[offset + 1U] = static_cast((value >> 8U) & 0xFFU); + report[offset + 2U] = static_cast((value >> 16U) & 0xFFU); + report[offset + 3U] = static_cast((value >> 24U) & 0xFFU); + } + void write_i16(std::vector &report, std::size_t offset, std::int16_t value) { write_u16(report, offset, static_cast(value)); } @@ -59,6 +74,31 @@ namespace lvh::reports { return static_cast(low | static_cast(high << 8U)); } + std::uint32_t crc32(const std::uint8_t *buffer, std::size_t length, std::uint32_t seed = 0) { + auto crc = seed ^ 0xFFFFFFFFU; + for (std::size_t index = 0; index < length; ++index) { + crc ^= buffer[index]; + for (auto bit = 0; bit < 8; ++bit) { + const auto mask = 0U - (crc & 1U); + crc = (crc >> 1U) ^ (0xEDB88320U & mask); + } + } + return crc ^ 0xFFFFFFFFU; + } + + std::uint32_t dualsense_crc_seed(std::uint8_t seed) { + return crc32(&seed, 1U); + } + + void write_dualsense_crc(std::vector &report, std::uint8_t seed) { + if (report.size() < 4U) { + return; + } + + const auto crc_offset = report.size() - 4U; + write_u32(report, crc_offset, crc32(report.data(), crc_offset, dualsense_crc_seed(seed))); + } + std::int16_t scale_i16(float value, float multiplier) { const auto scaled = std::clamp(value * multiplier, -32768.0F, 32767.0F); return static_cast(std::lround(scaled)); @@ -131,85 +171,97 @@ namespace lvh::reports { report[offset + 3U] = static_cast((y >> 4U) & 0xFFU); } - std::vector pack_dualsense_usb_input_report(const DeviceProfile &profile, const GamepadState &state) { - if (profile.input_report_size < 64U) { + std::vector pack_dualsense_input_report(const DeviceProfile &profile, const GamepadState &state) { + const auto is_bluetooth = profile.bus_type == BusType::bluetooth; + const auto payload_offset = is_bluetooth ? 2U : 1U; + const auto minimum_report_size = is_bluetooth ? 78U : 64U; + if (profile.input_report_size < minimum_report_size) { return {}; } const auto normalized = normalize_state(state); std::vector report(profile.input_report_size, 0); - report[0] = profile.report_id; - report[1] = normalize_dualsense_axis(normalized.left_stick.x); - report[2] = normalize_dualsense_axis(normalized.left_stick.y); - report[3] = normalize_dualsense_axis(normalized.right_stick.x); - report[4] = normalize_dualsense_axis(normalized.right_stick.y); - report[5] = normalize_trigger(normalized.left_trigger); - report[6] = normalize_trigger(normalized.right_trigger); - report[8] = hat_from_buttons(normalized.buttons); + report[0] = is_bluetooth ? dualsense_bt_input_report_id : profile.report_id; + if (is_bluetooth) { + report[1] = dualsense_bt_input_report_reserved; + } + + report[payload_offset + 0U] = normalize_dualsense_axis(normalized.left_stick.x); + report[payload_offset + 1U] = normalize_dualsense_axis(normalized.left_stick.y); + report[payload_offset + 2U] = normalize_dualsense_axis(normalized.right_stick.x); + report[payload_offset + 3U] = normalize_dualsense_axis(normalized.right_stick.y); + report[payload_offset + 4U] = normalize_trigger(normalized.left_trigger); + report[payload_offset + 5U] = normalize_trigger(normalized.right_trigger); + report[payload_offset + 7U] = hat_from_buttons(normalized.buttons); if (normalized.buttons.test(GamepadButton::x)) { - report[8] |= 0x10; + report[payload_offset + 7U] |= 0x10; } if (normalized.buttons.test(GamepadButton::a)) { - report[8] |= 0x20; + report[payload_offset + 7U] |= 0x20; } if (normalized.buttons.test(GamepadButton::b)) { - report[8] |= 0x40; + report[payload_offset + 7U] |= 0x40; } if (normalized.buttons.test(GamepadButton::y)) { - report[8] |= 0x80; + report[payload_offset + 7U] |= 0x80; } if (normalized.buttons.test(GamepadButton::left_shoulder)) { - report[9] |= 0x01; + report[payload_offset + 8U] |= 0x01; } if (normalized.buttons.test(GamepadButton::right_shoulder)) { - report[9] |= 0x02; + report[payload_offset + 8U] |= 0x02; } if (normalized.left_trigger > 0.0F) { - report[9] |= 0x04; + report[payload_offset + 8U] |= 0x04; } if (normalized.right_trigger > 0.0F) { - report[9] |= 0x08; + report[payload_offset + 8U] |= 0x08; } if (normalized.buttons.test(GamepadButton::back)) { - report[9] |= 0x10; + report[payload_offset + 8U] |= 0x10; } if (normalized.buttons.test(GamepadButton::start)) { - report[9] |= 0x20; + report[payload_offset + 8U] |= 0x20; } if (normalized.buttons.test(GamepadButton::left_stick)) { - report[9] |= 0x40; + report[payload_offset + 8U] |= 0x40; } if (normalized.buttons.test(GamepadButton::right_stick)) { - report[9] |= 0x80; + report[payload_offset + 8U] |= 0x80; } if (normalized.buttons.test(GamepadButton::guide)) { - report[10] |= 0x01; + report[payload_offset + 9U] |= 0x01; } if (normalized.buttons.test(GamepadButton::misc1)) { - report[10] |= 0x04; + report[payload_offset + 9U] |= 0x04; } if (normalized.gyroscope) { - write_i16(report, 16U, scale_i16(normalized.gyroscope->x, 1145.0F)); - write_i16(report, 18U, scale_i16(normalized.gyroscope->y, 1145.0F)); - write_i16(report, 20U, scale_i16(normalized.gyroscope->z, 1145.0F)); + write_i16(report, payload_offset + 15U, scale_i16(normalized.gyroscope->x, 1145.0F)); + write_i16(report, payload_offset + 17U, scale_i16(normalized.gyroscope->y, 1145.0F)); + write_i16(report, payload_offset + 19U, scale_i16(normalized.gyroscope->z, 1145.0F)); } if (normalized.acceleration) { - write_i16(report, 22U, scale_i16(normalized.acceleration->x, 100.0F)); - write_i16(report, 24U, scale_i16(normalized.acceleration->y, 100.0F)); - write_i16(report, 26U, scale_i16(normalized.acceleration->z, 100.0F)); + write_i16(report, payload_offset + 21U, scale_i16(normalized.acceleration->x, 100.0F)); + write_i16(report, payload_offset + 23U, scale_i16(normalized.acceleration->y, 100.0F)); + write_i16(report, payload_offset + 25U, scale_i16(normalized.acceleration->z, 100.0F)); } - write_dualsense_touch_contact(report, 33U, normalized.touchpad_contacts[0]); - write_dualsense_touch_contact(report, 37U, normalized.touchpad_contacts[1]); + write_dualsense_touch_contact(report, payload_offset + 32U, normalized.touchpad_contacts[0]); + write_dualsense_touch_contact(report, payload_offset + 36U, normalized.touchpad_contacts[1]); const auto battery = normalized.battery.value_or(GamepadBattery {.state = GamepadBatteryState::full, .percentage = 100}); const auto battery_charge = std::min(10U, static_cast(std::lround(battery.percentage / 10.0F))); - report[53] = static_cast(battery_charge | (dualsense_battery_state(battery.state) << 4U)); - report[54] = 0x0C; + report[payload_offset + 52U] = + static_cast(battery_charge | (dualsense_battery_state(battery.state) << 4U)); + report[payload_offset + 53U] = 0x0C; + + if (is_bluetooth) { + write_dualsense_crc(report, dualsense_input_crc_seed); + } return report; } @@ -218,6 +270,17 @@ namespace lvh::reports { return 1U; } if (report.size() >= 49U && report[0] == dualsense_bt_output_report_id) { + if (report.size() >= 78U) { + const auto expected_crc = crc32(report.data(), report.size() - 4U, dualsense_crc_seed(dualsense_output_crc_seed)); + const auto actual_crc = static_cast(report[report.size() - 4U]) | + (static_cast(report[report.size() - 3U]) << 8U) | + (static_cast(report[report.size() - 2U]) << 16U) | + (static_cast(report[report.size() - 1U]) << 24U); + if (actual_crc != expected_crc) { + return std::nullopt; + } + } + const auto enable_hid = (report[1] & 0x02U) != 0; if (!enable_hid && report.size() < 50U) { return std::nullopt; @@ -379,7 +442,7 @@ namespace lvh::reports { std::vector pack_input_report(const DeviceProfile &profile, const GamepadState &state) { if (profile.device_type == DeviceType::gamepad && profile.gamepad_kind == GamepadProfileKind::dualsense) { - return pack_dualsense_usb_input_report(profile, state); + return pack_dualsense_input_report(profile, state); } constexpr std::size_t common_report_size = 14; diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index dabc500..bbf082f 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -5,6 +5,7 @@ // standard includes #include +#include #include #include #include @@ -67,6 +68,144 @@ namespace lvh::detail { constexpr auto tablet_distance_max = 1024; constexpr auto tablet_resolution = 28; constexpr auto poll_timeout_ms = 100; + constexpr auto dualsense_calibration_report = 0x05; + constexpr auto dualsense_pairing_report = 0x09; + constexpr auto dualsense_firmware_report = 0x20; + constexpr auto dualsense_periodic_report_ms = 10; + constexpr std::uint8_t dualsense_feature_crc_seed = 0xA3; + + constexpr std::uint8_t dualsense_calibration_info[] { + 0x05, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x10, + 0x27, + 0xF0, + 0xD8, + 0x10, + 0x27, + 0xF0, + 0xD8, + 0x10, + 0x27, + 0xF0, + 0xD8, + 0xF4, + 0x01, + 0xF4, + 0x01, + 0x10, + 0x27, + 0xF0, + 0xD8, + 0x10, + 0x27, + 0xF0, + 0xD8, + 0x10, + 0x27, + 0xF0, + 0xD8, + 0x0B, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + }; + + constexpr std::uint8_t dualsense_firmware_info[] { + 0x20, + 0x4A, + 0x75, + 0x6E, + 0x20, + 0x31, + 0x39, + 0x20, + 0x32, + 0x30, + 0x32, + 0x33, + 0x31, + 0x34, + 0x3A, + 0x34, + 0x37, + 0x3A, + 0x33, + 0x34, + 0x03, + 0x00, + 0x44, + 0x00, + 0x08, + 0x02, + 0x00, + 0x01, + 0x36, + 0x00, + 0x00, + 0x01, + 0xC1, + 0xC8, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x54, + 0x01, + 0x00, + 0x00, + 0x14, + 0x00, + 0x00, + 0x00, + 0x0B, + 0x00, + 0x01, + 0x00, + 0x06, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + 0x00, + }; + + constexpr std::uint8_t dualsense_pairing_info[] { + 0x09, + 0x74, + 0xE7, + 0xD6, + 0x3A, + 0x53, + 0x35, + 0x08, + 0x25, + 0x00, + 0x1E, + 0x00, + 0xEE, + 0x74, + 0xD0, + 0xBC, + 0x00, + 0x00, + 0x00, + 0x00, + }; int system_access(const char *path, int mode) { return ::access(path, mode); @@ -112,6 +251,73 @@ namespace lvh::detail { return system_access(uinput_path, R_OK | W_OK) == 0; } + std::array generated_mac_address(DeviceId id) { + return { + 0x02, + 0x00, + static_cast((id >> 24U) & 0xFFU), + static_cast((id >> 16U) & 0xFFU), + static_cast((id >> 8U) & 0xFFU), + static_cast(id & 0xFFU), + }; + } + + std::optional> parse_mac_address(const std::string &text) { + std::array mac {}; + std::istringstream stream {text}; + for (std::size_t index = 0; index < mac.size(); ++index) { + unsigned int value = 0; + stream >> std::hex >> value; + if (!stream || value > 0xFFU) { + return std::nullopt; + } + mac[index] = static_cast(value); + if (index + 1U < mac.size()) { + char separator = 0; + stream >> separator; + if (separator != ':') { + return std::nullopt; + } + } + } + return mac; + } + + std::string format_mac_address(const std::array &mac) { + std::ostringstream stream; + stream << std::hex << std::setfill('0'); + for (std::size_t index = 0; index < mac.size(); ++index) { + if (index != 0) { + stream << ':'; + } + stream << std::setw(2) << static_cast(mac[index]); + } + return stream.str(); + } + + std::uint32_t crc32(const std::uint8_t *buffer, std::size_t length, std::uint32_t seed = 0) { + auto crc = seed ^ 0xFFFFFFFFU; + for (std::size_t index = 0; index < length; ++index) { + crc ^= buffer[index]; + for (auto bit = 0; bit < 8; ++bit) { + const auto mask = 0U - (crc & 1U); + crc = (crc >> 1U) ^ (0xEDB88320U & mask); + } + } + return crc ^ 0xFFFFFFFFU; + } + + std::uint32_t dualsense_crc_seed(std::uint8_t seed) { + return crc32(&seed, 1U); + } + + void write_u32_le(std::uint8_t *buffer, std::uint32_t value) { + buffer[0] = static_cast(value & 0xFFU); + buffer[1] = static_cast((value >> 8U) & 0xFFU); + buffer[2] = static_cast((value >> 16U) & 0xFFU); + buffer[3] = static_cast((value >> 24U) & 0xFFU); + } + std::uint16_t to_uhid_bus(BusType bus_type) { if (bus_type == BusType::bluetooth) { return BUS_BLUETOOTH; @@ -656,6 +862,64 @@ namespace lvh::detail { } } + OperationStatus configure_uinput_abs_setup( + int fd, + int code, + int minimum, + int maximum, + int fuzz = 0, + int flat = 0, + int resolution = 0 + ) { +#if defined(UI_ABS_SETUP) + uinput_abs_setup setup {}; + setup.code = static_cast<__u16>(code); + setup.absinfo.minimum = minimum; + setup.absinfo.maximum = maximum; + setup.absinfo.fuzz = fuzz; + setup.absinfo.flat = flat; + setup.absinfo.resolution = resolution; + + if (system_ioctl(fd, UI_ABS_SETUP, reinterpret_cast(&setup)) < 0) { + return ioctl_status("failed to configure uinput absolute axis " + std::to_string(code)); + } +#else + static_cast(fd); + static_cast(code); + static_cast(minimum); + static_cast(maximum); + static_cast(fuzz); + static_cast(flat); + static_cast(resolution); +#endif + + return OperationStatus::success(); + } + + OperationStatus configure_uinput_abs_setup(int fd, DeviceType device_type) { + if (device_type != DeviceType::pen_tablet) { + return OperationStatus::success(); + } + + // libinput requires tablet coordinate and tilt axes to advertise resolution. + if (const auto status = configure_uinput_abs_setup(fd, ABS_X, 0, touch_axis_max_x, 1, 0, tablet_resolution); !status.ok()) { + return status; + } + if (const auto status = configure_uinput_abs_setup(fd, ABS_Y, 0, touch_axis_max_y, 1, 0, tablet_resolution); !status.ok()) { + return status; + } + if (const auto status = configure_uinput_abs_setup(fd, ABS_PRESSURE, 0, tablet_pressure_max); !status.ok()) { + return status; + } + if (const auto status = configure_uinput_abs_setup(fd, ABS_DISTANCE, 0, tablet_distance_max); !status.ok()) { + return status; + } + if (const auto status = configure_uinput_abs_setup(fd, ABS_TILT_X, -90, 90, 0, 0, tablet_resolution); !status.ok()) { + return status; + } + return configure_uinput_abs_setup(fd, ABS_TILT_Y, -90, 90, 0, 0, tablet_resolution); + } + OperationStatus write_uinput_user_device(int fd, const DeviceProfile &profile, DeviceId id) { uinput_user_dev device {}; copy_string(device.name, profile.name); @@ -673,6 +937,10 @@ namespace lvh::detail { return OperationStatus::failure(ErrorCode::backend_failure, "short write while creating uinput device"); } + if (const auto status = configure_uinput_abs_setup(fd, profile.device_type); !status.ok()) { + return status; + } + if (system_ioctl(fd, UI_DEV_CREATE) < 0) { return ioctl_status("failed to create uinput device " + std::to_string(id)); } @@ -1858,9 +2126,15 @@ namespace lvh::detail { } event.type = UHID_CREATE2; + auto unique_id = options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id; + if (options.profile.gamepad_kind == GamepadProfileKind::dualsense) { + dualsense_mac_address_ = parse_mac_address(options.metadata.stable_id).value_or(generated_mac_address(id)); + unique_id = format_mac_address(dualsense_mac_address_); + } + copy_string(request.name, options.profile.name); copy_string(request.phys, "libvirtualhid/uhid/" + std::to_string(id)); - copy_string(request.uniq, options.metadata.stable_id.empty() ? std::to_string(id) : options.metadata.stable_id); + copy_string(request.uniq, unique_id); request.rd_size = static_cast(options.profile.report_descriptor.size()); request.bus = to_uhid_bus(options.profile.bus_type); request.vendor = options.profile.vendor_id; @@ -1869,6 +2143,10 @@ namespace lvh::detail { std::memcpy(request.rd_data, options.profile.report_descriptor.data(), options.profile.report_descriptor.size()); profile_ = options.profile; device_name_ = options.profile.name; + { + std::lock_guard lock {report_mutex_}; + last_report_ = reports::pack_input_report(profile_, {}); + } if (const auto status = write_event(event); !status.ok()) { return status; @@ -1878,6 +2156,11 @@ namespace lvh::detail { reader_ = std::thread {[this]() { read_loop(); }}; + if (profile_.gamepad_kind == GamepadProfileKind::dualsense) { + periodic_reporter_ = std::thread {[this]() { + periodic_report_loop(); + }}; + } return OperationStatus::success(); } @@ -1894,7 +2177,12 @@ namespace lvh::detail { event.type = UHID_INPUT2; event.u.input2.size = static_cast(report.size()); std::memcpy(event.u.input2.data, report.data(), report.size()); - return write_event(event); + auto status = write_event(event); + if (status.ok()) { + std::lock_guard lock {report_mutex_}; + last_report_ = report; + } + return status; } void set_output_callback(OutputCallback callback) override { @@ -1920,6 +2208,9 @@ namespace lvh::detail { status = write_event(event); } + if (periodic_reporter_.joinable()) { + periodic_reporter_.join(); + } if (reader_.joinable()) { reader_.join(); } @@ -1997,7 +2288,7 @@ namespace lvh::detail { dispatch_output_report(event.u.output.data, event.u.output.size); break; case UHID_GET_REPORT: - send_get_report_reply(event.u.get_report.id); + send_get_report_reply(event.u.get_report.id, event.u.get_report.rnum); break; case UHID_SET_REPORT: dispatch_output_report(event.u.set_report.data, event.u.set_report.size); @@ -2008,6 +2299,24 @@ namespace lvh::detail { } } + void periodic_report_loop() { + while (running_) { + std::this_thread::sleep_for(std::chrono::milliseconds {dualsense_periodic_report_ms}); + if (!running_ || !open_) { + break; + } + + std::vector report; + { + std::lock_guard lock {report_mutex_}; + report = last_report_; + } + if (!report.empty()) { + static_cast(submit(report)); + } + } + } + void dispatch_output_report(const __u8 *data, std::size_t report_size) { OutputCallback callback; { @@ -2026,14 +2335,53 @@ namespace lvh::detail { } } - void send_get_report_reply(std::uint32_t id) { + void send_get_report_reply(std::uint32_t id, std::uint8_t report_number) { uhid_event event {}; event.type = UHID_GET_REPORT_REPLY; event.u.get_report_reply.id = id; event.u.get_report_reply.err = EIO; + + if (profile_.gamepad_kind == GamepadProfileKind::dualsense) { + event.u.get_report_reply.err = 0; + switch (report_number) { + case dualsense_calibration_report: + copy_get_report_payload(event, dualsense_calibration_info, sizeof(dualsense_calibration_info)); + break; + case dualsense_pairing_report: + copy_get_report_payload(event, dualsense_pairing_info, sizeof(dualsense_pairing_info)); + for (std::size_t index = 0; index < dualsense_mac_address_.size(); ++index) { + event.u.get_report_reply.data[1U + index] = + dualsense_mac_address_[dualsense_mac_address_.size() - 1U - index]; + } + break; + case dualsense_firmware_report: + copy_get_report_payload(event, dualsense_firmware_info, sizeof(dualsense_firmware_info)); + break; + default: + event.u.get_report_reply.err = EINVAL; + break; + } + + if (profile_.bus_type == BusType::bluetooth && event.u.get_report_reply.err == 0 && event.u.get_report_reply.size >= 4U) { + const auto crc_offset = static_cast(event.u.get_report_reply.size) - 4U; + const auto crc = crc32( + event.u.get_report_reply.data, + crc_offset, + dualsense_crc_seed(dualsense_feature_crc_seed) + ); + write_u32_le(event.u.get_report_reply.data + crc_offset, crc); + } + } + static_cast(write_event(event)); } + template + void copy_get_report_payload(uhid_event &event, const std::uint8_t (&payload)[Size], std::size_t payload_size) { + event.u.get_report_reply.size = static_cast(std::min(payload_size, UHID_DATA_MAX)); + std::memcpy(event.u.get_report_reply.data, payload, event.u.get_report_reply.size); + } + void send_set_report_reply(std::uint32_t id, std::uint16_t error) { uhid_event event {}; event.type = UHID_SET_REPORT_REPLY; @@ -2045,10 +2393,14 @@ namespace lvh::detail { int fd_ = -1; DeviceProfile profile_; std::string device_name_; + std::array dualsense_mac_address_ {}; + std::vector last_report_; std::atomic_bool open_ = true; std::atomic_bool running_ = false; std::thread reader_; + std::thread periodic_reporter_; std::mutex write_mutex_; + std::mutex report_mutex_; std::mutex callback_mutex_; OutputCallback output_callback_; }; diff --git a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp index ba5595b..2d90a8b 100644 --- a/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -84,6 +84,31 @@ namespace lvh::detail::test { */ bool saw_get_report_reply = false; + /** + * @brief Whether the peer observed a DualSense calibration reply. + */ + bool saw_dualsense_calibration = false; + + /** + * @brief Whether the peer observed a DualSense pairing reply. + */ + bool saw_dualsense_pairing = false; + + /** + * @brief Whether the peer observed a DualSense firmware reply. + */ + bool saw_dualsense_firmware = false; + + /** + * @brief Whether the peer observed a signed Bluetooth DualSense feature reply. + */ + bool saw_dualsense_feature_crc = false; + + /** + * @brief Whether the peer observed a Bluetooth-framed DualSense input report. + */ + bool saw_dualsense_bluetooth_input = false; + /** * @brief Whether the peer observed a set-report reply. */ @@ -376,6 +401,20 @@ namespace lvh::detail::test { */ LinuxUhidRoundTripResult linux_uhid_socketpair_roundtrip(); + /** + * @brief Exercise DualSense UHID feature-report replies over a socketpair. + * + * @return Round-trip result with feature-report observations. + */ + LinuxUhidRoundTripResult linux_dualsense_uhid_socketpair_reports(); + + /** + * @brief Exercise Bluetooth DualSense UHID framing and signed feature replies over a socketpair. + * + * @return Round-trip result with Bluetooth framing observations. + */ + LinuxUhidRoundTripResult linux_dualsense_bluetooth_uhid_socketpair_reports(); + /** * @brief Create all Linux backend device types using fake successful syscalls. * diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index e27b37e..3909222 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -360,6 +360,26 @@ namespace lvh::detail::test { return true; } + bool read_uhid_event_type(int fd, unsigned int event_type, uhid_event &event) { + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds {1}; + while (std::chrono::steady_clock::now() < deadline) { + if (!read_uhid_event(fd, event)) { + return false; + } + if (event.type == event_type) { + return true; + } + } + return false; + } + + std::uint32_t read_u32_le(const std::uint8_t *buffer) { + return static_cast(buffer[0]) | + (static_cast(buffer[1]) << 8U) | + (static_cast(buffer[2]) << 16U) | + (static_cast(buffer[3]) << 24U); + } + void enable_fake_device_syscalls(LinuxTestSyscalls &syscalls) { syscalls.override_access = true; syscalls.override_open = true; @@ -710,6 +730,130 @@ namespace lvh::detail::test { return result; } + LinuxUhidRoundTripResult linux_dualsense_uhid_socketpair_reports() { + LinuxUhidRoundTripResult result; + int descriptors[2] {-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors) != 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno); + result.submit_status = result.create_status; + result.close_status = result.create_status; + return result; + } + + CreateGamepadOptions options; + options.profile = profiles::dualsense_usb(); + options.metadata.stable_id = "02:03:04:05:06:07"; + + UhidGamepad gamepad {descriptors[0]}; + result.create_status = gamepad.create(8, options); + + uhid_event event {}; + if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { + result.saw_create = event.u.create2.vendor == options.profile.vendor_id && + event.u.create2.product == options.profile.product_id; + } + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 11; + event.u.get_report.rnum = 0x05; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) { + result.saw_dualsense_calibration = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 && + event.u.get_report_reply.data[0] == 0x05; + } + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 12; + event.u.get_report.rnum = 0x09; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) { + result.saw_dualsense_pairing = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 7 && + event.u.get_report_reply.data[0] == 0x09 && + event.u.get_report_reply.data[1] == 0x07 && + event.u.get_report_reply.data[6] == 0x02; + } + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 13; + event.u.get_report.rnum = 0x20; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) { + result.saw_dualsense_firmware = event.u.get_report_reply.err == 0 && event.u.get_report_reply.size > 0 && + event.u.get_report_reply.data[0] == 0x20; + } + + result.close_status = gamepad.close(); + static_cast(::close(descriptors[1])); + result.submit_status = OperationStatus::success(); + return result; + } + + LinuxUhidRoundTripResult linux_dualsense_bluetooth_uhid_socketpair_reports() { + LinuxUhidRoundTripResult result; + int descriptors[2] {-1, -1}; + if (::socketpair(AF_UNIX, SOCK_STREAM, 0, descriptors) != 0) { + result.create_status = system_error_status(ErrorCode::backend_failure, "failed to create socketpair", errno); + result.submit_status = result.create_status; + result.close_status = result.create_status; + return result; + } + + CreateGamepadOptions options; + options.profile = profiles::dualsense_bluetooth(); + options.metadata.stable_id = "02:03:04:05:06:07"; + + UhidGamepad gamepad {descriptors[0]}; + result.create_status = gamepad.create(9, options); + + uhid_event event {}; + if (read_uhid_event_type(descriptors[1], UHID_CREATE2, event)) { + result.saw_create = event.u.create2.vendor == options.profile.vendor_id && + event.u.create2.product == options.profile.product_id && + event.u.create2.bus == BUS_BLUETOOTH; + } + + if (read_uhid_event_type(descriptors[1], UHID_INPUT2, event)) { + const auto report_size = static_cast(event.u.input2.size); + if (report_size == options.profile.input_report_size && event.u.input2.data[0] == 0x31) { + const auto crc_offset = report_size - 4U; + const auto expected_crc = crc32(event.u.input2.data, crc_offset, dualsense_crc_seed(0xA1)); + const auto actual_crc = read_u32_le(event.u.input2.data + crc_offset); + result.saw_dualsense_bluetooth_input = expected_crc == actual_crc; + } + } + + event = {}; + event.type = UHID_GET_REPORT; + event.u.get_report.id = 14; + event.u.get_report.rnum = 0x09; + static_cast(write_uhid_event(descriptors[1], event)); + if (read_uhid_event_type(descriptors[1], UHID_GET_REPORT_REPLY, event)) { + const auto report_size = static_cast(event.u.get_report_reply.size); + result.saw_dualsense_pairing = event.u.get_report_reply.err == 0 && report_size > 7U && + event.u.get_report_reply.data[0] == 0x09 && + event.u.get_report_reply.data[1] == 0x07 && + event.u.get_report_reply.data[6] == 0x02; + if (report_size >= 4U) { + const auto crc_offset = report_size - 4U; + const auto expected_crc = crc32( + event.u.get_report_reply.data, + crc_offset, + dualsense_crc_seed(dualsense_feature_crc_seed) + ); + const auto actual_crc = read_u32_le(event.u.get_report_reply.data + crc_offset); + result.saw_dualsense_feature_crc = expected_crc == actual_crc; + } + } + + result.close_status = gamepad.close(); + static_cast(::close(descriptors[1])); + result.submit_status = OperationStatus::success(); + return result; + } + LinuxBackendFakeCreationResult linux_backend_create_all_fake_success() { LinuxTestSyscalls syscalls; enable_fake_device_syscalls(syscalls); diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp index 7eea9a8..c7592b5 100644 --- a/tests/unit/test_linux_backend.cpp +++ b/tests/unit/test_linux_backend.cpp @@ -396,6 +396,26 @@ TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) { EXPECT_EQ(result.last_output.high_frequency_rumble, 0x1234); } +TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) { + const auto result = lvh::detail::test::linux_dualsense_uhid_socketpair_reports(); + EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.saw_dualsense_calibration); + EXPECT_TRUE(result.saw_dualsense_pairing); + EXPECT_TRUE(result.saw_dualsense_firmware); +} + +TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) { + const auto result = lvh::detail::test::linux_dualsense_bluetooth_uhid_socketpair_reports(); + EXPECT_TRUE(result.create_status.ok()) << result.create_status.message(); + EXPECT_TRUE(result.close_status.ok()) << result.close_status.message(); + EXPECT_TRUE(result.saw_create); + EXPECT_TRUE(result.saw_dualsense_bluetooth_input); + EXPECT_TRUE(result.saw_dualsense_pairing); + EXPECT_TRUE(result.saw_dualsense_feature_crc); +} + TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) { const auto unavailable = lvh::detail::test::linux_backend_fake_unavailable_capabilities(); EXPECT_FALSE(unavailable.supports_virtual_hid); @@ -546,6 +566,10 @@ TEST_F(LinuxBackendTest, PipeBackedUinputTouchDevicesEmitEvents) {} TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} +TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {} + +TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {} + TEST_F(LinuxBackendTest, FakeLinuxBackendCreatesAllDeviceTypes) {} TEST_F(LinuxBackendTest, FakeUhidSyscallsCoverFailureBranches) {} diff --git a/tests/unit/test_linux_consumers.cpp b/tests/unit/test_linux_consumers.cpp index 772c445..b1acf71 100644 --- a/tests/unit/test_linux_consumers.cpp +++ b/tests/unit/test_linux_consumers.cpp @@ -436,10 +436,146 @@ TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) { EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_RELEASED); } + +TEST_F(LinuxConsumerTest, LibinputSeesUinputTouchscreenContacts) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_TRUE(runtime->capabilities().supports_touchscreen); + + lvh::CreateTouchscreenOptions options; + options.profile = lvh::profiles::touchscreen(); + options.profile.name = unique_device_name("libinput Touchscreen"); + options.stable_id = "libvirtualhid-libinput-touchscreen-test"; + + auto created = runtime->create_touchscreen(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto node = wait_for_readable_event_node(options.profile.name); + ASSERT_TRUE(node) << "libinput touchscreen event node was not readable for " << options.profile.name; + + auto context = create_libinput_context(*node); + ASSERT_NE(context.get(), nullptr) << "libinput could not open " << node->string(); + + auto event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_DEVICE_ADDED}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_device(event.get()), nullptr); + EXPECT_TRUE(libinput_device_has_capability(libinput_event_get_device(event.get()), LIBINPUT_DEVICE_CAP_TOUCH)); + + const lvh::TouchContact contact {.id = 1, .x = 0.25F, .y = 0.5F, .pressure = 1.0F}; + ASSERT_TRUE(created.touchscreen->place_contact(contact).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_TOUCH_DOWN, LIBINPUT_EVENT_TOUCH_MOTION}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_touch_event(event.get()), nullptr); + + ASSERT_TRUE(created.touchscreen->release_contact(contact.id).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_TOUCH_UP}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_touch_event(event.get()), nullptr); +} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputTrackpadButton) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_TRUE(runtime->capabilities().supports_trackpad); + + lvh::CreateTrackpadOptions options; + options.profile = lvh::profiles::trackpad(); + options.profile.name = unique_device_name("libinput Trackpad"); + options.stable_id = "libvirtualhid-libinput-trackpad-test"; + + auto created = runtime->create_trackpad(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto node = wait_for_readable_event_node(options.profile.name); + ASSERT_TRUE(node) << "libinput trackpad event node was not readable for " << options.profile.name; + + auto context = create_libinput_context(*node); + ASSERT_NE(context.get(), nullptr) << "libinput could not open " << node->string(); + + auto event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_DEVICE_ADDED}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_device(event.get()), nullptr); + EXPECT_TRUE(libinput_device_has_capability(libinput_event_get_device(event.get()), LIBINPUT_DEVICE_CAP_POINTER)); + + const lvh::TouchContact contact {.id = 2, .x = 0.5F, .y = 0.5F, .pressure = 1.0F}; + ASSERT_TRUE(created.trackpad->place_contact(contact).ok()); + ASSERT_TRUE(created.trackpad->button(true).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); + ASSERT_NE(event.get(), nullptr); + auto *pointer_event = libinput_event_get_pointer_event(event.get()); + ASSERT_NE(pointer_event, nullptr); + EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); + EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_PRESSED); + + ASSERT_TRUE(created.trackpad->button(false).ok()); + event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_POINTER_BUTTON}); + ASSERT_NE(event.get(), nullptr); + pointer_event = libinput_event_get_pointer_event(event.get()); + ASSERT_NE(pointer_event, nullptr); + EXPECT_EQ(libinput_event_pointer_get_button(pointer_event), BTN_LEFT); + EXPECT_EQ(libinput_event_pointer_get_button_state(pointer_event), LIBINPUT_BUTTON_STATE_RELEASED); +} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions runtime_options; + runtime_options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(runtime_options); + ASSERT_TRUE(runtime->capabilities().supports_pen_tablet); + + lvh::CreatePenTabletOptions options; + options.profile = lvh::profiles::pen_tablet(); + options.profile.name = unique_device_name("libinput Pen Tablet"); + options.stable_id = "libvirtualhid-libinput-pen-tablet-test"; + + auto created = runtime->create_pen_tablet(options); + ASSERT_TRUE(created) << created.status.message(); + + const auto node = wait_for_readable_event_node(options.profile.name); + ASSERT_TRUE(node) << "libinput pen tablet event node was not readable for " << options.profile.name; + + auto context = create_libinput_context(*node); + ASSERT_NE(context.get(), nullptr) << "libinput could not open " << node->string(); + + auto event = wait_for_libinput_event(context.get(), {LIBINPUT_EVENT_DEVICE_ADDED}); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_device(event.get()), nullptr); + EXPECT_TRUE(libinput_device_has_capability(libinput_event_get_device(event.get()), LIBINPUT_DEVICE_CAP_TABLET_TOOL)); + + const lvh::PenToolState tool { + .tool = lvh::PenToolType::pen, + .x = 0.25F, + .y = 0.75F, + .pressure = 0.5F, + .distance = 0.0F, + .tilt_x = 15.0F, + .tilt_y = -15.0F, + }; + ASSERT_TRUE(created.pen_tablet->place_tool(tool).ok()); + event = wait_for_libinput_event( + context.get(), + {LIBINPUT_EVENT_TABLET_TOOL_PROXIMITY, LIBINPUT_EVENT_TABLET_TOOL_AXIS, LIBINPUT_EVENT_TABLET_TOOL_TIP} + ); + ASSERT_NE(event.get(), nullptr); + ASSERT_NE(libinput_event_get_tablet_tool_event(event.get()), nullptr); +} #else TEST_F(LinuxConsumerTest, SdlSeesUhidGamepadButtonAndAxisInput) {} TEST_F(LinuxConsumerTest, LibinputSeesUinputKeyboardKeys) {} TEST_F(LinuxConsumerTest, LibinputSeesUinputMouseMotionAndButtons) {} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputTouchscreenContacts) {} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputTrackpadButton) {} + +TEST_F(LinuxConsumerTest, LibinputSeesUinputPenTabletTool) {} #endif diff --git a/tests/unit/test_profiles.cpp b/tests/unit/test_profiles.cpp index 3b39e0d..62b97d6 100644 --- a/tests/unit/test_profiles.cpp +++ b/tests/unit/test_profiles.cpp @@ -41,6 +41,13 @@ TEST(ProfileTest, StreamingControllerProfilesArePresent) { EXPECT_GT(dualsense.output_report_size, 5U); EXPECT_EQ(dualsense.manufacturer, "Sony Interactive Entertainment"); + const auto dualsense_bluetooth = lvh::profiles::dualsense_bluetooth(); + EXPECT_EQ(dualsense_bluetooth.bus_type, lvh::BusType::bluetooth); + EXPECT_EQ(dualsense_bluetooth.report_id, 0x31); + EXPECT_EQ(dualsense_bluetooth.input_report_size, 78U); + EXPECT_EQ(dualsense_bluetooth.output_report_size, 78U); + EXPECT_NE(dualsense_bluetooth.report_descriptor, dualsense.report_descriptor); + EXPECT_EQ(switch_pro.vendor_id, 0x057E); EXPECT_EQ(switch_pro.product_id, 0x2009); } diff --git a/tests/unit/test_report.cpp b/tests/unit/test_report.cpp index 8c54f6c..4ee4e9d 100644 --- a/tests/unit/test_report.cpp +++ b/tests/unit/test_report.cpp @@ -13,6 +13,31 @@ #include #include +namespace { + std::uint32_t test_crc32(const std::uint8_t *buffer, std::size_t length, std::uint32_t seed = 0) { + auto crc = seed ^ 0xFFFFFFFFU; + for (std::size_t index = 0; index < length; ++index) { + crc ^= buffer[index]; + for (auto bit = 0; bit < 8; ++bit) { + const auto mask = 0U - (crc & 1U); + crc = (crc >> 1U) ^ (0xEDB88320U & mask); + } + } + return crc ^ 0xFFFFFFFFU; + } + + std::uint32_t test_dualsense_crc_seed(std::uint8_t seed) { + return test_crc32(&seed, 1U); + } + + std::uint32_t read_u32_le(const std::vector &bytes, std::size_t offset) { + return static_cast(bytes[offset]) | + (static_cast(bytes[offset + 1U]) << 8U) | + (static_cast(bytes[offset + 2U]) << 16U) | + (static_cast(bytes[offset + 3U]) << 24U); + } +} // namespace + TEST(ReportTest, NormalizesAxesAndTriggers) { EXPECT_EQ(lvh::reports::normalize_axis(-2.0F), -32768); EXPECT_EQ(lvh::reports::normalize_axis(-1.0F), -32768); @@ -96,6 +121,33 @@ TEST(ReportTest, PacksDualSenseUsbReport) { EXPECT_EQ(report[53] >> 4, 1); } +TEST(ReportTest, PacksDualSenseBluetoothReportWithCrc) { + const auto profile = lvh::profiles::dualsense_bluetooth(); + + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.left_stick = {1.0F, -1.0F}; + state.right_trigger = 1.0F; + state.battery = lvh::GamepadBattery {.state = lvh::GamepadBatteryState::full, .percentage = 100}; + + const auto report = lvh::reports::pack_input_report(profile, state); + + ASSERT_EQ(report.size(), profile.input_report_size); + EXPECT_EQ(report[0], 0x31); + EXPECT_EQ(report[1], 0x00); + EXPECT_EQ(report[2], 255); + EXPECT_EQ(report[3], 0); + EXPECT_EQ(report[7], 255); + EXPECT_EQ(report[9] & 0x20, 0x20); + EXPECT_EQ(report[10] & 0x08, 0x08); + EXPECT_EQ(report[54] & 0x0F, 10); + EXPECT_EQ(report[55], 0x0C); + + const auto crc_offset = report.size() - 4U; + const auto expected_crc = test_crc32(report.data(), crc_offset, test_dualsense_crc_seed(0xA1)); + EXPECT_EQ(read_u32_le(report, crc_offset), expected_crc); +} + TEST(ReportTest, ParsesRumbleOutputReport) { const auto profile = lvh::profiles::xbox_360(); const std::vector report {profile.report_id, 0x34, 0x12, 0xCD, 0xAB}; @@ -142,6 +194,40 @@ TEST(ReportTest, ParsesDualSenseOutputReportEvents) { EXPECT_EQ(outputs[2].left_trigger_effect[0], 2); } +TEST(ReportTest, ParsesDualSenseBluetoothOutputReportEvents) { + const auto profile = lvh::profiles::dualsense_bluetooth(); + std::vector report(profile.output_report_size, 0); + report[0] = 0x31; + report[1] = 0x02; + report[2] = 0x0D; + report[3] = 0x04; + report[4] = 0x80; + report[5] = 0x40; + report[12] = 0x26; + report[13] = 1; + report[23] = 0x21; + report[24] = 2; + report[46] = 0x11; + report[47] = 0x22; + report[48] = 0x33; + const auto crc_offset = report.size() - 4U; + const auto crc = test_crc32(report.data(), crc_offset, test_dualsense_crc_seed(0xA2)); + report[crc_offset] = static_cast(crc & 0xFFU); + report[crc_offset + 1U] = static_cast((crc >> 8U) & 0xFFU); + report[crc_offset + 2U] = static_cast((crc >> 16U) & 0xFFU); + report[crc_offset + 3U] = static_cast((crc >> 24U) & 0xFFU); + + const auto outputs = lvh::reports::parse_output_reports(profile, report); + + ASSERT_EQ(outputs.size(), 3U); + EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble); + EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::rgb_led); + EXPECT_EQ(outputs[1].red, 0x11); + EXPECT_EQ(outputs[1].green, 0x22); + EXPECT_EQ(outputs[1].blue, 0x33); + EXPECT_EQ(outputs[2].kind, lvh::GamepadOutputKind::adaptive_triggers); +} + TEST(ReportTest, KeepsUnrecognizedOutputReportsRaw) { const auto rumble_profile = lvh::profiles::xbox_360(); const std::vector wrong_report_id {0x7F, 0x34, 0x12, 0xCD, 0xAB}; diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index c94ff8f..1ff34a2 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -255,4 +255,29 @@ TEST_F(LinuxRuntimeTest, LinuxUinputSmokeTestRequiresPrerequisites) { ASSERT_TRUE(mouse) << mouse.status.message(); EXPECT_TRUE(mouse.mouse->move_relative(1, 1).ok()); EXPECT_TRUE(mouse.mouse->vertical_scroll(120).ok()); + + ASSERT_TRUE(capabilities.supports_touchscreen); + auto touchscreen = runtime->create_touchscreen(); + ASSERT_TRUE(touchscreen) << touchscreen.status.message(); + EXPECT_TRUE(touchscreen.touchscreen->place_contact({.id = 1, .x = 0.5F, .y = 0.25F, .pressure = 1.0F}).ok()); + EXPECT_TRUE(touchscreen.touchscreen->release_contact(1).ok()); + + ASSERT_TRUE(capabilities.supports_trackpad); + auto trackpad = runtime->create_trackpad(); + ASSERT_TRUE(trackpad) << trackpad.status.message(); + EXPECT_TRUE(trackpad.trackpad->place_contact({.id = 1, .x = 0.5F, .y = 0.25F, .pressure = 1.0F}).ok()); + EXPECT_TRUE(trackpad.trackpad->button(true).ok()); + EXPECT_TRUE(trackpad.trackpad->button(false).ok()); + EXPECT_TRUE(trackpad.trackpad->release_contact(1).ok()); + + ASSERT_TRUE(capabilities.supports_pen_tablet); + auto pen_tablet = runtime->create_pen_tablet(); + ASSERT_TRUE(pen_tablet) << pen_tablet.status.message(); + EXPECT_TRUE( + pen_tablet.pen_tablet + ->place_tool({.tool = lvh::PenToolType::pen, .x = 0.5F, .y = 0.25F, .pressure = 0.75F, .tilt_x = 10.0F}) + .ok() + ); + EXPECT_TRUE(pen_tablet.pen_tablet->button(lvh::PenButton::primary, true).ok()); + EXPECT_TRUE(pen_tablet.pen_tablet->button(lvh::PenButton::primary, false).ok()); }