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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c2cc1e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,394 @@ +--- +name: CI +permissions: {} + +on: + pull_request: + push: + branches: + - master + +concurrency: + group: "${{ github.workflow }}-${{ github.ref }}" + cancel-in-progress: true + +env: + OPENCPPCOVERAGE_VERSION: '0.9.9.0' + PYTHON_VERSION: '3.14' + +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++ + 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} + kind: msys2 + cc: gcc + cxx: g++ + msystem: ucrt64 + toolchain: ucrt-x86_64 + gcov_executable: gcov + - name: Windows-MSVC + os: windows-2022 + 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 \ + libinput-dev \ + libsdl2-dev \ + libx11-dev \ + libxtst-dev \ + llvm \ + 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 + for module in uhid uinput; do + if ! sudo modprobe "${module}"; then + message="Unable to load ${module}; tests requiring /dev/${module}" + message="${message} 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' + 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: 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' + 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: + 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: Prepare report directory + run: cmake -E make_directory cmake-build-ci/reports + + - name: Run tests + id: test + if: matrix.kind != 'msvc' + 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: | + $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 + 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 gamepad adapter example + if: matrix.kind != 'msvc' + run: | + if [[ "${RUNNER_OS}" == "Windows" ]]; then + ./cmake-build-ci/examples/gamepad_adapter.exe + else + ./cmake-build-ci/examples/gamepad_adapter + fi + + - name: Run gamepad adapter example MSVC + if: matrix.kind == 'msvc' + run: .\cmake-build-ci\examples\Debug\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 + + - 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: true + 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/.gitignore b/.gitignore index e3f4af3..c1eca37 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,15 @@ # JetBrains IDEs .idea/ + +# Python +.venv/ + +# CMake +build/ +cmake-build-*/ + +# Local temp directories +.tmp/ + +# doxyconfig +docs/doxyconfig* diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..0c946a7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,11 @@ +[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 +[submodule "third-party/lizardbyte-common"] + path = third-party/lizardbyte-common + url = https://github.com/LizardByte/lizardbyte-common.git + branch = master 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9575465 --- /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. 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 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. + +Always follow the style guidelines defined in .clang-format for c/c++ code when that file is present. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..396889c --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,120 @@ +# +# Project configuration +# +cmake_minimum_required(VERSION 3.24) +project(libvirtualhid VERSION 0.0.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" ${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) + +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 +# +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") + 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() + endforeach() +endfunction() + +# +# Library code is located here +# When building tests this must be after the coverage flags are set +# +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() +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/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..77b3b59 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,421 @@ -# 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: + +- [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 + 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 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. + +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" +``` + +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 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 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, +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. +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 created = runtime->create_gamepad(lvh::profiles::xbox_360()); +if (!created) { + return; +} + +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.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`: 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 + 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`. + +## Streaming Host Integration Requirements + +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: + +- [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 + 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. +- [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. +- [x] Linux keyboard support must include configurable auto-repeat for held keys + so streaming hosts can preserve input behavior previously covered by + inputtino. +- [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. +- [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 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 + primitives consumers need to keep that ownership in their applications. + +## Tooling and Dependency Plan + +- [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. +- [x] Put all submodules under `third-party`. +- [x] Add GoogleTest as a submodule at `third-party/googletest`; do not download it + during configure. +- [x] Add the LizardByte Doxygen configuration as a submodule at + `third-party/doxyconfig` and use it for local docs and Read the Docs builds. +- [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. +- [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 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. + +## 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 +docs/ Project Doxygen configuration +third-party/doxyconfig/ LizardByte Doxygen configuration submodule +third-party/googletest/ GoogleTest submodule +``` + +## Implementation Plan + +### Phase 1: Project Foundation + +- [x] Add CMake project scaffolding and exported target + `libvirtualhid::libvirtualhid`. +- [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`. +- [x] Add Doxygen documentation wiring with `third-party/doxyconfig`, a project + `docs/Doxyfile`, and Read the Docs configuration. +- [x] Add CI using the `libdisplaydevice` workflow pattern for Linux GCC, Linux + Clang, macOS, Windows MinGW/UCRT64, and Windows MSVC configure/build/test + coverage. +- [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 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. +- [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 2B: Linux inputtino Parity + +- [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. +- [x] Parse DualSense output reports into rumble, RGB LED, adaptive trigger, and + raw-report callbacks. +- [x] Expose created device nodes and sysfs paths through the platform-neutral + public API. +- [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. + +### 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 + +- [ ] 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. +- [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, + 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. +- [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. + +### 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: Sunshine, SDL, HIDAPI, browser + Gamepad API, DirectInput/XInput/GameInput on Windows, and evdev/libinput + libraries on Linux. + +## License + +`libvirtualhid` is licensed under the MIT License. See [LICENSE](LICENSE). diff --git a/cmake/libvirtualhid-config.cmake.in b/cmake/libvirtualhid-config.cmake.in new file mode 100644 index 0000000..8b67467 --- /dev/null +++ b/cmake/libvirtualhid-config.cmake.in @@ -0,0 +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/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/examples/CMakeLists.txt b/examples/CMakeLists.txt new file mode 100644 index 0000000..b560a9f --- /dev/null +++ b/examples/CMakeLists.txt @@ -0,0 +1,16 @@ +add_executable(gamepad_adapter + "${CMAKE_CURRENT_SOURCE_DIR}/gamepad_adapter.cpp") + +add_executable(keyboard_mouse_adapter + "${CMAKE_CURRENT_SOURCE_DIR}/keyboard_mouse_adapter.cpp") + +target_link_libraries(gamepad_adapter + PRIVATE + libvirtualhid::libvirtualhid) + +target_link_libraries(keyboard_mouse_adapter + PRIVATE + libvirtualhid::libvirtualhid) + +libvirtualhid_copy_mingw_runtime(gamepad_adapter) +libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) diff --git a/examples/gamepad_adapter.cpp b/examples/gamepad_adapter.cpp new file mode 100644 index 0000000..962188c --- /dev/null +++ b/examples/gamepad_adapter.cpp @@ -0,0 +1,57 @@ +/** + * @file examples/gamepad_adapter.cpp + * @brief Minimal gamepad adapter example. + */ + +// standard includes +#include + +// local includes +#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 = "remote-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/examples/keyboard_mouse_adapter.cpp b/examples/keyboard_mouse_adapter.cpp new file mode 100644 index 0000000..c52780c --- /dev/null +++ b/examples/keyboard_mouse_adapter.cpp @@ -0,0 +1,67 @@ +/** + * @file examples/keyboard_mouse_adapter.cpp + * @brief Minimal 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/libvirtualhid.hpp b/include/libvirtualhid/libvirtualhid.hpp new file mode 100644 index 0000000..4bc6226 --- /dev/null +++ b/include/libvirtualhid/libvirtualhid.hpp @@ -0,0 +1,11 @@ +/** + * @file libvirtualhid/libvirtualhid.hpp + * @brief Aggregate include for the libvirtualhid public C++ API. + */ +#pragma once + +// local includes +#include +#include +#include +#include diff --git a/include/libvirtualhid/profiles.hpp b/include/libvirtualhid/profiles.hpp new file mode 100644 index 0000000..e8f8da9 --- /dev/null +++ b/include/libvirtualhid/profiles.hpp @@ -0,0 +1,122 @@ +/** + * @file include/libvirtualhid/profiles.hpp + * @brief Built-in virtual device profile declarations. + */ +#pragma once + +// 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 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 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. + * + * @return Switch Pro-compatible device profile. + */ + 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 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. + * + * @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 new file mode 100644 index 0000000..c1ed40a --- /dev/null +++ b/include/libvirtualhid/report.hpp @@ -0,0 +1,91 @@ +/** + * @file include/libvirtualhid/report.hpp + * @brief Gamepad state normalization, report packing, and output parsing declarations. + */ +#pragma once + +// 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 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); + + /** + * @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); + + /** + * @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 new file mode 100644 index 0000000..87ede22 --- /dev/null +++ b/include/libvirtualhid/runtime.hpp @@ -0,0 +1,1068 @@ +/** + * @file include/libvirtualhid/runtime.hpp + * @brief Runtime and virtual device handle declarations. + */ +#pragma once + +// standard includes +#include +#include +#include + +// local includes +#include + +namespace lvh { + + namespace detail { + struct GamepadDevice; + struct KeyboardDevice; + struct MouseDevice; + struct TouchscreenDevice; + struct TrackpadDevice; + struct PenTabletDevice; + 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 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. + * + * @return Close operation status. + */ + virtual OperationStatus 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::device_nodes + */ + std::vector device_nodes() const override; + + /** + * @copydoc VirtualDevice::close + */ + OperationStatus close() override; + + /** + * @brief Submit the latest gamepad input state. + * + * @param state Gamepad input state. + * @return Submit operation status. + */ + OperationStatus 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. + */ + OperationStatus 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 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::device_nodes + */ + std::vector device_nodes() const override; + + /** + * @copydoc VirtualDevice::close + */ + OperationStatus close() override; + + /** + * @brief Submit a keyboard key transition. + * + * @param event Keyboard event. + * @return Submit operation status. + */ + OperationStatus submit(const KeyboardEvent &event); + + /** + * @brief Press a keyboard key. + * + * @param key_code Portable key code. + * @return Submit operation status. + */ + OperationStatus press(KeyboardKeyCode key_code); + + /** + * @brief Release a keyboard key. + * + * @param key_code Portable key code. + * @return Submit operation status. + */ + OperationStatus release(KeyboardKeyCode key_code); + + /** + * @brief Type UTF-8 text. + * + * @param event Text event. + * @return Submit operation status. + */ + OperationStatus 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::device_nodes + */ + std::vector device_nodes() const override; + + /** + * @copydoc VirtualDevice::close + */ + OperationStatus close() override; + + /** + * @brief Submit a mouse event. + * + * @param event Mouse event. + * @return Submit operation status. + */ + OperationStatus submit(const MouseEvent &event); + + /** + * @brief Submit relative pointer movement. + * + * @param delta_x Horizontal delta. + * @param delta_y Vertical delta. + * @return Submit operation status. + */ + OperationStatus 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. + */ + 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. + * + * @param button Mouse button. + * @param pressed Whether the button is pressed. + * @return Submit operation status. + */ + OperationStatus button(MouseButton button, bool pressed); + + /** + * @brief Submit high-resolution vertical scroll. + * + * @param distance High-resolution scroll distance. + * @return Submit operation status. + */ + OperationStatus vertical_scroll(std::int32_t distance); + + /** + * @brief Submit high-resolution horizontal scroll. + * + * @param distance High-resolution scroll distance. + * @return Submit operation status. + */ + OperationStatus 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 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. + */ + struct GamepadCreationResult { + /** + * @brief Creation status. + */ + OperationStatus 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 Result returned by keyboard creation. + */ + struct KeyboardCreationResult { + /** + * @brief Creation status. + */ + OperationStatus 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. + */ + OperationStatus 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 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. + */ + 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 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 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. + * + * @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 new file mode 100644 index 0000000..cde9b7a --- /dev/null +++ b/include/libvirtualhid/types.hpp @@ -0,0 +1,967 @@ +/** + * @file include/libvirtualhid/types.hpp + * @brief Core public types for libvirtualhid. + */ +#pragma once + +// standard includes +#include +#include +#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, ///< 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 OperationStatus { + public: + /** + * @brief Construct a successful status. + */ + OperationStatus(); + + /** + * @brief Construct a status with an explicit error code and message. + * + * @param code Error category. + * @param message Human-readable status message. + */ + OperationStatus(ErrorCode code, std::string message); + + /** + * @brief Create a successful status. + * + * @return Successful status object. + */ + static OperationStatus success(); + + /** + * @brief Create a failing status. + * + * @param code Error category. + * @param message Human-readable failure message. + * @return Failing status object. + */ + static OperationStatus 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 Human-readable 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 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. + */ + 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 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. + */ + enum class DeviceType { + 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. + }; + + /** + * @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 Expected packed input report size in bytes. + */ + 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. + */ + 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 Full keyboard creation request. + */ + struct CreateKeyboardOptions { + /** + * @brief Device profile to instantiate. + */ + 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. + */ + 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 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. + */ + 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 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. + */ + 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 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 {}; + }; + + /** + * @brief Keyboard key code accepted by the keyboard event model. + * + * 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; + + /** + * @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 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. + */ + 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 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. + */ + std::vector raw_report; + }; + + /** + * @brief Callback invoked when a gamepad receives output from the backend. + */ + using OutputCallback = std::function; + +} // namespace lvh diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt new file mode 100644 index 0000000..70fd7c0 --- /dev/null +++ b/src/CMakeLists.txt @@ -0,0 +1,72 @@ +add_library(${PROJECT_NAME} STATIC) +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) + if(LIBVIRTUALHID_ENABLE_XTEST) + find_package(X11 QUIET) + endif() + + target_sources(${PROJECT_NAME} + PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/platform/linux/uhid_backend.cpp") + 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 + "${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 + 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/backend.cpp b/src/core/backend.cpp new file mode 100644 index 0000000..bc6c5a5 --- /dev/null +++ b/src/core/backend.cpp @@ -0,0 +1,195 @@ +/** + * @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: + OperationStatus submit(const std::vector & /*report*/) override { + return OperationStatus::success(); + } + + void set_output_callback(OutputCallback callback) override { + output_callback_ = std::move(callback); + } + + OperationStatus close() override { + return OperationStatus::success(); + } + + private: + OutputCallback output_callback_; + }; + + /** + * @brief In-memory keyboard backend used for portable tests. + */ + class FakeKeyboard final: public BackendKeyboard { + public: + OperationStatus submit(const KeyboardEvent & /*event*/) override { + return OperationStatus::success(); + } + + OperationStatus type_text(const KeyboardTextEvent & /*event*/) override { + return OperationStatus::success(); + } + + OperationStatus close() override { + return OperationStatus::success(); + } + }; + + /** + * @brief In-memory mouse backend used for portable tests. + */ + class FakeMouse final: public BackendMouse { + public: + OperationStatus submit(const MouseEvent & /*event*/) override { + return OperationStatus::success(); + } + + OperationStatus close() override { + return OperationStatus::success(); + } + }; + + /** + * @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. + */ + class FakeBackend final: public Backend { + public: + FakeBackend() { + capabilities_.backend_name = "fake"; + 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; + } + + const BackendCapabilities &capabilities() const override { + return capabilities_; + } + + BackendGamepadCreationResult create_gamepad(DeviceId /*id*/, const CreateGamepadOptions & /*options*/) override { + return {OperationStatus::success(), std::make_unique()}; + } + + BackendKeyboardCreationResult create_keyboard( + DeviceId /*id*/, + const CreateKeyboardOptions & /*options*/ + ) override { + return {OperationStatus::success(), std::make_unique()}; + } + + BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions & /*options*/) override { + 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_; + }; + + } // 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..2be5e8b --- /dev/null +++ b/src/core/backend.hpp @@ -0,0 +1,562 @@ +/** + * @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 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. + * + * @param callback Output callback. + */ + virtual void set_output_callback(OutputCallback callback) = 0; + + /** + * @brief Close the backend device. + * + * @return Close status. + */ + virtual OperationStatus close() = 0; + + protected: + 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 OperationStatus submit(const KeyboardEvent &event) = 0; + + /** + * @brief Submit UTF-8 text to the backend. + * + * @param event Text event. + * @return Submit status. + */ + 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. + * + * @return Close status. + */ + virtual OperationStatus 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 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. + * + * @return Close status. + */ + virtual OperationStatus close() = 0; + + protected: + 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. + */ + struct BackendGamepadCreationResult { + /** + * @brief Creation status. + */ + OperationStatus 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 Result returned by an internal backend keyboard creation request. + */ + struct BackendKeyboardCreationResult { + /** + * @brief Creation status. + */ + OperationStatus 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. + */ + OperationStatus 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 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. + */ + 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; + + /** + * @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; + + /** + * @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; + }; + + /** + * @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/profiles.cpp b/src/core/profiles.cpp new file mode 100644 index 0000000..dee08a4 --- /dev/null +++ b/src/core/profiles.cpp @@ -0,0 +1,906 @@ +/** + * @file src/core/profiles.cpp + * @brief Built-in virtual gamepad profile definitions. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include + +namespace lvh::profiles { + namespace { + + constexpr std::size_t common_report_size = 14; + + 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; + + 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, + 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) + }; + + 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; + } + + 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, + }; + } + + 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, + 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; + 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.capabilities.supports_rumble); + 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 = 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 = { + .supports_rumble = true, + .supports_motion = true, + .supports_touchpad = true, + .supports_rgb_led = true, + .supports_battery = true, + .supports_adaptive_triggers = true, + }; + profile.report_descriptor = + bus_type == BusType::bluetooth ? make_dualsense_bluetooth_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; + 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() { + 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 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() { + return make_gamepad_profile( + GamepadProfileKind::switch_pro, + "Nintendo Switch Pro Controller", + 0x057E, + 0x2009, + 0x8111, + {.supports_rumble = true, .supports_motion = true, .supports_battery = true} + ); + } + + DeviceProfile keyboard() { + return make_simple_profile(DeviceType::keyboard, "libvirtualhid Keyboard", 0x0002); + } + + DeviceProfile mouse() { + 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: + 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..fa34db9 --- /dev/null +++ b/src/core/report.cpp @@ -0,0 +1,513 @@ +/** + * @file src/core/report.cpp + * @brief Gamepad report normalization and packing definitions. + */ + +// standard includes +#include +#include +#include +#include +#include +#include +#include +#include + +// local includes +#include + +namespace lvh::reports { + namespace { + + constexpr std::uint8_t neutral_hat = 8; + + 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; + + 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)); + } + + void append_i16(std::vector &report, std::int16_t value) { + 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_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)); + } + + 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::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)); + } + + 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; + + 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; + } + + 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_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] = 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[payload_offset + 7U] |= 0x10; + } + if (normalized.buttons.test(GamepadButton::a)) { + report[payload_offset + 7U] |= 0x20; + } + if (normalized.buttons.test(GamepadButton::b)) { + report[payload_offset + 7U] |= 0x40; + } + if (normalized.buttons.test(GamepadButton::y)) { + report[payload_offset + 7U] |= 0x80; + } + + if (normalized.buttons.test(GamepadButton::left_shoulder)) { + report[payload_offset + 8U] |= 0x01; + } + if (normalized.buttons.test(GamepadButton::right_shoulder)) { + report[payload_offset + 8U] |= 0x02; + } + if (normalized.left_trigger > 0.0F) { + report[payload_offset + 8U] |= 0x04; + } + if (normalized.right_trigger > 0.0F) { + report[payload_offset + 8U] |= 0x08; + } + if (normalized.buttons.test(GamepadButton::back)) { + report[payload_offset + 8U] |= 0x10; + } + if (normalized.buttons.test(GamepadButton::start)) { + report[payload_offset + 8U] |= 0x20; + } + if (normalized.buttons.test(GamepadButton::left_stick)) { + report[payload_offset + 8U] |= 0x40; + } + if (normalized.buttons.test(GamepadButton::right_stick)) { + report[payload_offset + 8U] |= 0x80; + } + + if (normalized.buttons.test(GamepadButton::guide)) { + report[payload_offset + 9U] |= 0x01; + } + if (normalized.buttons.test(GamepadButton::misc1)) { + report[payload_offset + 9U] |= 0x04; + } + + if (normalized.gyroscope) { + 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, 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, 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[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; + } + + 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) { + 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; + } + 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) { + 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); + 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; + } + + 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) { + if (profile.device_type == DeviceType::gamepad && profile.gamepad_kind == GamepadProfileKind::dualsense) { + return pack_dualsense_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 {}; + } + + 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; + } + + 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; + } + + 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 new file mode 100644 index 0000000..ac2fd4c --- /dev/null +++ b/src/core/runtime.cpp @@ -0,0 +1,1170 @@ +/** + * @file src/core/runtime.cpp + * @brief Runtime and virtual device handle definitions. + */ + +// standard includes +#include +#include +#include +#include + +// local includes +#include "core/backend.hpp" + +#include +#include +#include + +namespace lvh::detail { + + struct GamepadDevice { + explicit GamepadDevice( + DeviceId device_id, + CreateGamepadOptions create_options, + std::unique_ptr backend_gamepad + ): + id {device_id}, + 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; + std::size_t submitted_reports = 0; + OutputCallback output_callback; + 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; + }; + + 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): + options {runtime_options}, + 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; + std::vector> keyboards; + std::vector> mice; + std::vector> touchscreens; + std::vector> trackpads; + std::vector> pen_tablets; + mutable std::mutex mutex; + }; + +} // namespace lvh::detail + +namespace lvh { + namespace { + + OperationStatus validate_gamepad_options(const CreateGamepadOptions &options) { + if (options.profile.device_type != DeviceType::gamepad) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a gamepad"); + } + if (options.profile.name.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + if (options.profile.report_descriptor.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile report descriptor must not be empty"); + } + if (options.profile.report_id == 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile report id must not be zero"); + } + if (options.profile.input_report_size == 0) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile input report size must not be zero"); + } + + return OperationStatus::success(); + } + + OperationStatus validate_keyboard_options(const CreateKeyboardOptions &options) { + if (options.profile.device_type != DeviceType::keyboard) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a keyboard"); + } + if (options.profile.name.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + return OperationStatus::success(); + } + + OperationStatus validate_mouse_options(const CreateMouseOptions &options) { + if (options.profile.device_type != DeviceType::mouse) { + return OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a mouse"); + } + if (options.profile.name.empty()) { + return OperationStatus::failure(ErrorCode::invalid_argument, "device profile name must not be empty"); + } + + 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"); + } + + return OperationStatus::success(); + } + + OperationStatus validate_mouse_event(const MouseEvent &event) { + if (event.kind == MouseEventKind::absolute_motion && (event.width <= 0 || event.height <= 0)) { + return OperationStatus::failure(ErrorCode::invalid_argument, "absolute mouse movement requires positive dimensions"); + } + + 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}; + 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): + 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; + }); + } + + 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) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + OperationStatus Gamepad::submit(const GamepadState &state) { + return with_device(device_, [&state](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "gamepad is closed"); + } + + auto report = reports::pack_input_report(device.options.profile, state); + if (report.empty()) { + return OperationStatus::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; + return OperationStatus::success(); + }); + } + + 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; + }); + } + + OperationStatus Gamepad::dispatch_output(const GamepadOutput &output) { + OutputCallback callback; + const auto status = with_device(device_, [&callback](auto &device) { + if (!device.open) { + return OperationStatus::failure(ErrorCode::device_closed, "gamepad is closed"); + } + callback = device.output_callback; + return OperationStatus::success(); + }); + + if (!status.ok()) { + return status; + } + if (callback) { + callback(output); + } + return OperationStatus::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; + }); + } + + 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; + }); + } + + 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) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + 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 OperationStatus::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 OperationStatus::success(); + }); + } + + OperationStatus Keyboard::press(KeyboardKeyCode key_code) { + return submit({.key_code = key_code, .pressed = true}); + } + + OperationStatus Keyboard::release(KeyboardKeyCode key_code) { + return submit({.key_code = key_code, .pressed = false}); + } + + OperationStatus Keyboard::type_text(const KeyboardTextEvent &event) { + return with_device(device_, [&event](auto &device) { + if (!device.open) { + return OperationStatus::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 OperationStatus::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; + }); + } + + 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) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (device.backend) { + status = device.backend->close(); + } + + device.open = false; + return status; + }); + } + + 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 OperationStatus::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 OperationStatus::success(); + }); + } + + 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}); + } + + 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}); + } + + OperationStatus Mouse::button(MouseButton button, bool pressed) { + MouseEvent event; + event.kind = MouseEventKind::button; + event.button = button; + event.pressed = pressed; + return submit(event); + } + + OperationStatus Mouse::vertical_scroll(std::int32_t distance) { + MouseEvent event; + event.kind = MouseEventKind::vertical_scroll; + event.high_resolution_scroll = distance; + return submit(event); + } + + OperationStatus 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; + }); + } + + 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)} {} + + Runtime::Runtime(Runtime &&) noexcept = default; + Runtime &Runtime::operator=(Runtime &&) noexcept = default; + + Runtime::~Runtime() { + if (state_) { + close_all(); + } + } + + 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 (const auto validation = validate_gamepad_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_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 {OperationStatus::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 {OperationStatus::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 {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) + + count_open_devices(state_->touchscreens) + count_open_devices(state_->trackpads) + + count_open_devices(state_->pen_tablets); + } + + void Runtime::close_all() { + std::lock_guard lock {state_->mutex}; + 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/core/types.cpp b/src/core/types.cpp new file mode 100644 index 0000000..42f32b4 --- /dev/null +++ b/src/core/types.cpp @@ -0,0 +1,70 @@ +/** + * @file src/core/types.cpp + * @brief Core type helper definitions. + */ + +// standard includes +#include + +// local includes +#include + +namespace lvh { + + OperationStatus::OperationStatus(): + code_ {ErrorCode::ok}, + message_ {} {} + + OperationStatus::OperationStatus(ErrorCode code, std::string message): + code_ {code}, + message_ {std::move(message)} {} + + OperationStatus OperationStatus::success() { + return {}; + } + + OperationStatus OperationStatus::failure(ErrorCode code, std::string message) { + if (code == ErrorCode::ok) { + return {}; + } + return {code, std::move(message)}; + } + + bool OperationStatus::ok() const { + return code_ == ErrorCode::ok; + } + + ErrorCode OperationStatus::code() const { + return code_; + } + + const std::string &OperationStatus::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/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp new file mode 100644 index 0000000..bbf082f --- /dev/null +++ b/src/platform/linux/uhid_backend.cpp @@ -0,0 +1,2568 @@ +/** + * @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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include +#ifndef __user + #define __user +#endif +#include +#include +#include +#include +#include +#include + +#if defined(LIBVIRTUALHID_HAVE_XTEST) + #include + #include + #include + #include +#endif + +// local includes +#include "core/backend.hpp" + +#include +#include + +namespace lvh::detail { + namespace { + + 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; + 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); + } + + int system_open(const char *path, int flags) { + return ::open(path, flags); + } + + int system_close(int fd) { + return ::close(fd); + } + + std::ptrdiff_t system_write(int fd, const void *buffer, std::size_t size) { + return static_cast(::write(fd, buffer, size)); + } + + int system_ioctl(int fd, unsigned long request, unsigned long argument = 0) { + return ::ioctl(fd, request, argument); + } + + int system_poll(pollfd *descriptors, nfds_t descriptor_count, int timeout) { + return ::poll(descriptors, descriptor_count, timeout); + } + + std::ptrdiff_t system_read(int fd, void *buffer, std::size_t size) { + return static_cast(::read(fd, buffer, size)); + } + + std::string errno_message(int error) { + return std::error_code(error, std::generic_category()).message(); + } + + OperationStatus system_error_status(ErrorCode code, const std::string &operation, int error) { + return OperationStatus::failure(code, operation + ": " + errno_message(error)); + } + + bool can_access_uhid() { + return system_access(uhid_path, R_OK | W_OK) == 0; + } + + bool can_access_uinput() { + 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; + } + 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); + std::memcpy(destination, source.data(), length); + 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; + } + + 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); + } + + 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) { + 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) { + 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) { + 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; + } + 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) { + 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; + } + + 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); + } + + 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();) { + 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: + 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); + } + + OperationStatus sync() { + return emit_event(EV_SYN, SYN_REPORT, 0); + } + + OperationStatus close_uinput(const std::string &description) { + if (!open_.exchange(false)) { + return OperationStatus::success(); + } + + auto status = OperationStatus::success(); + if (fd_ >= 0) { + if (system_ioctl(fd_, UI_DEV_DESTROY) < 0) { + status = ioctl_status("failed to destroy " + description); + } + if (system_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: + OperationStatus emit_event_locked(std::uint16_t type, std::uint16_t code, std::int32_t value) { + if (fd_ < 0) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput device is closed"); + } + + input_event event {}; + event.type = type; + event.code = code; + event.value = value; + + 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); + } + if (static_cast(result) != sizeof(event)) { + return OperationStatus::failure(ErrorCode::backend_failure, "short write while sending uinput event"); + } + + return OperationStatus::success(); + } + + int fd_ = -1; + std::atomic_bool open_ = true; + 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 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); + 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; + configure_uinput_absinfo(device, profile.device_type); + + 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); + } + if (static_cast(result) != sizeof(device)) { + 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)); + } + + return OperationStatus::success(); + } + + 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"); + } + + for (auto code = 1; code < KEY_MAX; ++code) { + if (system_ioctl(fd, UI_SET_KEYBIT, code) < 0) { + return ioctl_status("failed to enable uinput keyboard key " + std::to_string(code)); + } + } + + return OperationStatus::success(); + } + + 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"); + } + if (system_ioctl(fd, UI_SET_EVBIT, EV_REL) < 0) { + return ioctl_status("failed to enable uinput relative mouse events"); + } + 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 (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 (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 (system_ioctl(fd, UI_SET_RELBIT, REL_WHEEL_HI_RES) < 0) { + return ioctl_status("failed to enable uinput high-resolution vertical scroll"); + } +#else + 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 (system_ioctl(fd, UI_SET_RELBIT, REL_HWHEEL_HI_RES) < 0) { + return ioctl_status("failed to enable uinput high-resolution horizontal scroll"); + } +#else + if (system_ioctl(fd, UI_SET_RELBIT, REL_HWHEEL) < 0) { + return ioctl_status("failed to enable uinput horizontal scroll"); + } +#endif + + if (system_ioctl(fd, UI_SET_ABSBIT, ABS_X) < 0) { + return ioctl_status("failed to enable uinput absolute X axis"); + } + if (system_ioctl(fd, UI_SET_ABSBIT, ABS_Y) < 0) { + return ioctl_status("failed to enable uinput absolute Y axis"); + } + + 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. + */ + class UinputKeyboard final: public BackendKeyboard, private UinputDevice { + public: + explicit UinputKeyboard(int file_descriptor): + UinputDevice {file_descriptor} {} + + ~UinputKeyboard() override { + static_cast(close()); + } + + OperationStatus create(DeviceId id, const CreateKeyboardOptions &options) { + if (const auto status = enable_uinput_keyboard(file_descriptor()); !status.ok()) { + return status; + } + 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 { + if (!is_open()) { + return OperationStatus::failure(ErrorCode::device_closed, "uinput keyboard is closed"); + } + + auto status = emit_keyboard_event(event); + if (status.ok()) { + update_pressed_keys(event); + } + return status; + } + + OperationStatus 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 OperationStatus::success(); + } + + 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_; + }; + + /** + * @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()); + } + + OperationStatus create(DeviceId id, const CreateMouseOptions &options) { + 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); + } + + OperationStatus submit(const MouseEvent &event) override { + if (!is_open()) { + return OperationStatus::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 OperationStatus::failure(ErrorCode::invalid_argument, "unsupported mouse event kind"); + } + + OperationStatus close() override { + 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()) { + return status; + } + } + if (event.y != 0) { + if (const auto status = emit_event(EV_REL, REL_Y, event.y); !status.ok()) { + return status; + } + } + return sync(); + } + + 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; + } + if (const auto status = emit_event(EV_ABS, ABS_Y, scale_absolute_axis(event.y, event.height)); !status.ok()) { + return status; + } + return sync(); + } + + 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(); + } + + 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; + } +#else + if (const auto status = emit_event(EV_REL, REL_WHEEL, legacy_scroll_steps(distance)); !status.ok()) { + return status; + } +#endif + return sync(); + } + + 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; + } +#else + if (const auto status = emit_event(EV_REL, REL_HWHEEL, legacy_scroll_steps(distance)); !status.ok()) { + return status; + } +#endif + return sync(); + } + }; + + /** + * @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) { + 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()); + } + + OperationStatus create() { + display_ = XOpenDisplay(nullptr); + if (display_ == nullptr) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest keyboard fallback"); + } + if (!query_xtest(display_)) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); + } + + return OperationStatus::success(); + } + + OperationStatus submit(const KeyboardEvent &event) override { + if (display_ == nullptr) { + return OperationStatus::failure(ErrorCode::device_closed, "XTest keyboard is closed"); + } + + const auto keysym = key_code_to_keysym(event.key_code); + if (keysym == NoSymbol) { + 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 OperationStatus::failure(ErrorCode::invalid_argument, "keyboard key code has no X11 keycode"); + } + + XTestFakeKeyEvent(display_, keycode, event.pressed ? True : False, CurrentTime); + XFlush(display_); + return OperationStatus::success(); + } + + OperationStatus 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 OperationStatus::success(); + } + + OperationStatus close() override { + if (display_ != nullptr) { + XCloseDisplay(display_); + display_ = nullptr; + } + return OperationStatus::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()); + } + + OperationStatus create() { + display_ = XOpenDisplay(nullptr); + if (display_ == nullptr) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open X display for XTest mouse fallback"); + } + if (!query_xtest(display_)) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "XTest extension is not available"); + } + + return OperationStatus::success(); + } + + OperationStatus submit(const MouseEvent &event) override { + if (display_ == nullptr) { + return OperationStatus::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 OperationStatus::success(); + } + + OperationStatus close() override { + if (display_ != nullptr) { + XCloseDisplay(display_); + display_ = nullptr; + } + return OperationStatus::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. + */ + 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()); + } + + 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 OperationStatus::failure(ErrorCode::unsupported_profile, "HID report descriptor is too large for UHID"); + } + + 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, 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; + 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; + 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; + } + + running_ = true; + reader_ = std::thread {[this]() { + read_loop(); + }}; + if (profile_.gamepad_kind == GamepadProfileKind::dualsense) { + periodic_reporter_ = std::thread {[this]() { + periodic_report_loop(); + }}; + } + return OperationStatus::success(); + } + + OperationStatus submit(const std::vector &report) override { + if (!open_) { + return OperationStatus::failure(ErrorCode::device_closed, "UHID gamepad is closed"); + } + + uhid_event event {}; + if (report.size() > sizeof(event.u.input2.data)) { + return OperationStatus::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()); + 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 { + std::lock_guard lock {callback_mutex_}; + 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(); + } + + running_ = false; + + auto status = OperationStatus::success(); + if (fd_ >= 0) { + uhid_event event {}; + event.type = UHID_DESTROY; + status = write_event(event); + } + + if (periodic_reporter_.joinable()) { + periodic_reporter_.join(); + } + if (reader_.joinable()) { + reader_.join(); + } + + if (fd_ >= 0) { + if (system_close(fd_) != 0 && status.ok()) { + status = system_error_status(ErrorCode::backend_failure, "failed to close /dev/uhid", errno); + } + fd_ = -1; + } + + return status; + } + + private: + OperationStatus write_event(const uhid_event &event) { + std::lock_guard lock {write_mutex_}; + if (fd_ < 0) { + return OperationStatus::failure(ErrorCode::device_closed, "UHID file descriptor is closed"); + } + + 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); + } + if (static_cast(result) != sizeof(event)) { + return OperationStatus::failure(ErrorCode::backend_failure, "short write while sending UHID event"); + } + + return OperationStatus::success(); + } + + void read_loop() { + while (running_) { + pollfd descriptor {}; + descriptor.fd = fd_; + descriptor.events = POLLIN; + + const auto result = system_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 = system_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_report(event.u.output.data, event.u.output.size); + break; + case UHID_GET_REPORT: + 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); + send_set_report_reply(event.u.set_report.id, 0); + break; + default: + break; + } + } + + 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; + { + std::lock_guard lock {callback_mutex_}; + callback = output_callback_; + } + + if (!callback) { + return; + } + + const auto size = std::min(report_size, UHID_DATA_MAX); + std::vector report(data, data + size); + for (const auto &output : reports::parse_output_reports(profile_, report)) { + callback(output); + } + } + + 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; + event.u.set_report_reply.id = id; + event.u.set_report_reply.err = error; + static_cast(write_event(event)); + } + + 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_; + }; + + /** + * @brief Linux platform backend backed by UHID. + */ + class LinuxUhidBackend final: public Backend { + public: + LinuxUhidBackend() { + const auto uhid_accessible = can_access_uhid(); + 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_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; + } + + const BackendCapabilities &capabilities() const override { + return capabilities_; + } + + BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { + 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}; + } + + auto gamepad = std::make_unique(fd); + if (const auto status = gamepad->create(id, options); !status.ok()) { + static_cast(gamepad->close()); + return {status, nullptr}; + } + + return {OperationStatus::success(), std::move(gamepad)}; + } + + BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) override { + const auto fd = system_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 {OperationStatus::success(), std::move(keyboard)}; + } + + BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { + const auto fd = system_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 {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) + auto keyboard = std::make_unique(); + if (const auto status = keyboard->create(); !status.ok()) { + return {status, nullptr}; + } + return {OperationStatus::success(), std::move(keyboard)}; +#else + return {OperationStatus::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 {OperationStatus::success(), std::move(mouse)}; +#else + return {OperationStatus::failure(ErrorCode::backend_unavailable, "failed to open /dev/uinput"), nullptr}; +#endif + } + + 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..a87fe0e --- /dev/null +++ b/src/platform/unsupported_backend.cpp @@ -0,0 +1,71 @@ +/** + * @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 {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + BackendKeyboardCreationResult create_keyboard( + DeviceId /*id*/, + const CreateKeyboardOptions & /*options*/ + ) override { + return {OperationStatus::failure(ErrorCode::backend_unavailable, "platform backend is not implemented yet"), nullptr}; + } + + BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions & /*options*/) override { + 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_; + }; + + } // namespace + + std::unique_ptr create_platform_backend() { + return std::make_unique(); + } + +} // namespace lvh::detail diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..30ce703 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,75 @@ +# +# Setup GoogleTest +# +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) +endif() + +include(GoogleTest) +add_subdirectory("${PROJECT_SOURCE_DIR}/third-party/googletest" + "third-party/googletest") + +set(TEST_BINARY test_libvirtualhid) + +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_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") + + 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" + "${PROJECT_SOURCE_DIR}/src") + +target_link_libraries(${TEST_BINARY} + PRIVATE + 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 + 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 new file mode 100644 index 0000000..cb5279a --- /dev/null +++ b/tests/fixtures/fixtures.cpp @@ -0,0 +1,72 @@ +/** + * @file tests/fixtures/fixtures.cpp + * @brief Shared GoogleTest fixture setup definitions. + */ + +// standard includes +#include + +// platform includes +#if defined(__linux__) + #include +#endif + +// 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; + } +} + +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 new file mode 100644 index 0000000..0b3a7de --- /dev/null +++ b/tests/fixtures/include/fixtures/fixtures.hpp @@ -0,0 +1,80 @@ +/** + * @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}; +}; + +/** + * @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. + +// 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/fixtures/include/fixtures/linux_backend_test_hooks.hpp b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp new file mode 100644 index 0000000..2d90a8b --- /dev/null +++ b/tests/fixtures/include/fixtures/linux_backend_test_hooks.hpp @@ -0,0 +1,618 @@ +/** + * @file tests/fixtures/include/fixtures/linux_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. + */ + OperationStatus 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. + */ + OperationStatus create_status; + + /** + * @brief Submit operation status. + */ + OperationStatus submit_status; + + /** + * @brief Close operation status. + */ + OperationStatus 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 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. + */ + 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. + */ + OperationStatus gamepad_status; + + /** + * @brief Gamepad close status. + */ + OperationStatus gamepad_close_status; + + /** + * @brief Keyboard creation status. + */ + OperationStatus keyboard_status; + + /** + * @brief Keyboard close status. + */ + OperationStatus keyboard_close_status; + + /** + * @brief Mouse creation status. + */ + OperationStatus mouse_status; + + /** + * @brief Mouse close status. + */ + OperationStatus 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. + */ + OperationStatus 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. + */ + 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. + */ + OperationStatus linux_uhid_submit_after_close(); + + /** + * @brief Try creating a uinput keyboard on an invalid file descriptor. + * + * @return Creation status. + */ + OperationStatus 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. + */ + OperationStatus 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. + */ + 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. + */ + OperationStatus 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. + */ + OperationStatus linux_uinput_user_device_invalid_fd(); + + /** + * @brief Try writing a uinput device definition to a pipe. + * + * @return Write status. + */ + OperationStatus linux_uinput_user_device_pipe(); + + /** + * @brief Try creating a uinput mouse on an invalid file descriptor. + * + * @return Creation status. + */ + OperationStatus 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. + */ + OperationStatus linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event); + + /** + * @brief Try submitting a mouse event after closing the backend device. + * + * @return Submit status. + */ + OperationStatus 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 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. + * + * @return Round-trip result. + */ + 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. + * + * @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. + */ + OperationStatus linux_backend_gamepad_fake_open_failure(); + + /** + * @brief Try creating a Linux backend gamepad while fake UHID creation fails. + * + * @return Creation status. + */ + OperationStatus linux_backend_gamepad_fake_create_failure(); + + /** + * @brief Try creating a Linux backend keyboard while fake open fails. + * + * @return Creation status. + */ + OperationStatus linux_backend_keyboard_fake_open_failure(); + + /** + * @brief Try creating a Linux backend keyboard while fake uinput creation fails. + * + * @return Creation status. + */ + 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. + */ + OperationStatus linux_backend_keyboard_fake_fallback_success(); + + /** + * @brief Try creating a Linux backend mouse while fake open fails. + * + * @return Creation status. + */ + OperationStatus linux_backend_mouse_fake_open_failure(); + + /** + * @brief Try creating a Linux backend mouse while fake uinput creation fails. + * + * @return Creation status. + */ + 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. + */ + OperationStatus linux_backend_mouse_fake_fallback_success(); + + /** + * @brief Try submitting a UHID input report while fake write fails. + * + * @return Submit status. + */ + OperationStatus linux_uhid_submit_fake_write_failure(); + + /** + * @brief Try submitting a UHID input report while fake write is short. + * + * @return Submit status. + */ + OperationStatus linux_uhid_submit_fake_short_write(); + + /** + * @brief Try closing a UHID gamepad while fake destroy write fails. + * + * @return Close status. + */ + OperationStatus linux_uhid_close_fake_write_failure(); + + /** + * @brief Try closing a UHID gamepad while fake close fails. + * + * @return Close status. + */ + 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. + */ + 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. + */ + 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. + */ + 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. + */ + OperationStatus 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. + */ + 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. + */ + OperationStatus linux_uinput_user_device_fake_short_write(); + + /** + * @brief Try writing a uinput device definition while fake device creation ioctl fails. + * + * @return Write status. + */ + OperationStatus linux_uinput_user_device_fake_create_failure(); + + /** + * @brief Submit a keyboard event while fake write fails. + * + * @return Submit status. + */ + OperationStatus linux_uinput_keyboard_submit_fake_write_failure(); + + /** + * @brief Submit a keyboard event while fake write is short. + * + * @return Submit status. + */ + OperationStatus linux_uinput_keyboard_submit_fake_short_write(); + + /** + * @brief Submit text through a fake successful uinput keyboard. + * + * @return Submit status. + */ + OperationStatus linux_uinput_keyboard_type_text_fake_success(); + + /** + * @brief Close a uinput keyboard while fake close fails. + * + * @return Close status. + */ + OperationStatus 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. + */ + OperationStatus 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. + */ + OperationStatus 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. + */ + 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 new file mode 100644 index 0000000..3909222 --- /dev/null +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -0,0 +1,1229 @@ +/** + * @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 + #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) + #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 + #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; + } + + 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; + 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; + } + + OperationStatus 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 OperationStatus::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); + } + + OperationStatus 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); + } + + OperationStatus linux_uhid_submit_report_size(std::size_t report_size) { + UhidGamepad gamepad {-1}; + return gamepad.submit(std::vector(report_size, 0)); + } + + OperationStatus linux_uhid_submit_after_close() { + UhidGamepad gamepad {-1}; + static_cast(gamepad.close()); + return gamepad.submit({0}); + } + + OperationStatus linux_uinput_keyboard_create_invalid_fd() { + CreateKeyboardOptions options; + options.profile = profiles::keyboard(); + + UinputKeyboard keyboard {-1}; + return keyboard.create(1, options); + } + + OperationStatus linux_uinput_keyboard_submit_invalid_fd(const KeyboardEvent &event) { + UinputKeyboard keyboard {-1}; + return keyboard.submit(event); + } + + OperationStatus linux_uinput_keyboard_type_text_invalid_fd(const std::string &text) { + UinputKeyboard keyboard {-1}; + return keyboard.type_text({.text = text}); + } + + OperationStatus 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)}; + } + + OperationStatus linux_uinput_user_device_invalid_fd() { + return write_uinput_user_device(-1, profiles::mouse(), 1); + } + + 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); + } + + auto status = write_uinput_user_device(descriptors[1], profiles::mouse(), 1); + static_cast(::close(descriptors[0])); + static_cast(::close(descriptors[1])); + return status; + } + + OperationStatus linux_uinput_mouse_create_invalid_fd() { + CreateMouseOptions options; + options.profile = profiles::mouse(); + + UinputMouse mouse {-1}; + return mouse.create(1, options); + } + + OperationStatus linux_uinput_mouse_submit_invalid_fd(const MouseEvent &event) { + UinputMouse mouse {-1}; + return mouse.submit(event); + } + + 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}); + } + + 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)}; + } + + 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}; + 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; + } + + 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); + 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(); + } + + OperationStatus 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; + } + + OperationStatus 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; + } + + OperationStatus 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; + } + + OperationStatus 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; + } + + OperationStatus 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 OperationStatus::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); + #endif + } + + OperationStatus 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; + } + + OperationStatus 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; + } + + OperationStatus 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 OperationStatus::failure(ErrorCode::backend_unavailable, "XTest fallback is not enabled"); + #endif + } + + OperationStatus 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}); + } + + OperationStatus 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}); + } + + OperationStatus 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(); + } + + OperationStatus linux_uhid_close_fake_close_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_write = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UhidGamepad gamepad {fake_fd}; + return gamepad.close(); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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}); + } + + OperationStatus 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}); + } + + OperationStatus 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"}); + } + + OperationStatus linux_uinput_keyboard_close_fake_close_failure() { + LinuxTestSyscalls syscalls; + syscalls.override_ioctl = true; + ScopedLinuxTestSyscalls scoped_syscalls {syscalls}; + + UinputKeyboard keyboard {fake_fd}; + return keyboard.close(); + } + + OperationStatus 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); + } + + OperationStatus 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); + } + + OperationStatus 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_gamepad_lifecycle.cpp b/tests/unit/test_gamepad_lifecycle.cpp new file mode 100644 index 0000000..b362656 --- /dev/null +++ b/tests/unit/test_gamepad_lifecycle.cpp @@ -0,0 +1,60 @@ +/** + * @file tests/unit/test_gamepad_lifecycle.cpp + * @brief Unit tests for the gamepad lifecycle. + */ + +// local includes +#include "fixtures/fixtures.hpp" + +#include + +TEST(GamepadLifecycleTest, 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 = "remote-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); +} diff --git a/tests/unit/test_linux_backend.cpp b/tests/unit/test_linux_backend.cpp new file mode 100644 index 0000000..c7592b5 --- /dev/null +++ b/tests/unit/test_linux_backend.cpp @@ -0,0 +1,580 @@ +/** + * @file tests/unit/test_linux_backend.cpp + * @brief Unit tests for Linux backend internals. + */ + +// standard includes +#include +#include +#include +#include + +// platform includes +#if defined(__linux__) + #include +#endif + +// lib includes +#include + +// local includes +#include "fixtures/fixtures.hpp" + +#if defined(__linux__) + #include "fixtures/linux_backend_test_hooks.hpp" +#endif + +/** + * @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); + 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); +} + +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); + 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); +} + +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); + 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); +} + +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}) + ); + 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); +} + +TEST_F(LinuxBackendTest, HandlesUhidInvalidFileDescriptorPaths) { + 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); +} + +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(), + 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); +} + +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}); + 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); +} + +TEST_F(LinuxBackendTest, HandlesUinputMouseInvalidFileDescriptorPaths) { + 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); +} + +TEST_F(LinuxBackendTest, PipeBackedUinputMouseEmitsEvents) { + 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); +} + +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(); + 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); +} + +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); + 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_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); + + 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); + 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); + 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); + 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(); + 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(); +} + +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); + 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()); +} + +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(), + 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 + ); +} + +TEST_F(LinuxBackendTest, PlatformRuntimeReportsUnavailableDeviceCreationWhenNodesAreMissing) { + 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 +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, PipeBackedUinputTouchDevicesEmitEvents) {} + +TEST_F(LinuxBackendTest, SocketpairBackedUhidGamepadRoundTripsEvents) {} + +TEST_F(LinuxBackendTest, SocketpairBackedDualSenseRepliesToFeatureReports) {} + +TEST_F(LinuxBackendTest, SocketpairBackedDualSenseBluetoothFramesReports) {} + +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..b1acf71 --- /dev/null +++ b/tests/unit/test_linux_consumers.cpp @@ -0,0 +1,581 @@ +/** + * @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); +} + +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 new file mode 100644 index 0000000..62b97d6 --- /dev/null +++ b/tests/unit/test_profiles.cpp @@ -0,0 +1,105 @@ +/** + * @file tests/unit/test_profiles.cpp + * @brief Unit tests for built-in gamepad profiles. + */ + +// local includes +#include "fixtures/fixtures.hpp" + +#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, StreamingControllerProfilesArePresent) { + 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_GT(dualsense.input_report_size, 14U); + 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); +} + +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); + + ASSERT_TRUE(profile.has_value()); + EXPECT_EQ(profile->gamepad_kind, lvh::GamepadProfileKind::xbox_series); +} + +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()); + 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); + + 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 new file mode 100644 index 0000000..4ee4e9d --- /dev/null +++ b/tests/unit/test_report.cpp @@ -0,0 +1,251 @@ +/** + * @file tests/unit/test_report.cpp + * @brief Unit tests for gamepad report packing. + */ + +// standard includes +#include +#include + +// local includes +#include "fixtures/fixtures.hpp" + +#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); + 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); +} + +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, 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}; + + 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, 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, 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}; + + 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); +} diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp new file mode 100644 index 0000000..1ff34a2 --- /dev/null +++ b/tests/unit/test_runtime.cpp @@ -0,0 +1,283 @@ +/** + * @file tests/unit/test_runtime.cpp + * @brief Unit tests for runtime and virtual gamepad handles. + */ + +// lib includes +#include + +// local includes +#include "fixtures/fixtures.hpp" + +/** + * @brief Test fixture for Linux runtime integration tests. + */ +class LinuxRuntimeTest: public LinuxTest {}; + +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_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); +} + +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-uinput"); + 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) { + 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_TRUE(created.gamepad->device_nodes().empty()); + 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); +} + +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_TRUE(created.keyboard->device_nodes().empty()); + 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_TRUE(created.mouse->device_nodes().empty()); + 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, 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")); + + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(options); + ASSERT_TRUE(runtime->capabilities().supports_gamepad); + + 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()); +} + +TEST_F(LinuxRuntimeTest, LinuxUinputSmokeTestRequiresPrerequisites) { + ASSERT_TRUE(HasReadableWritableDeviceNode("/dev/uinput")); + + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + auto runtime = lvh::Runtime::create(options); + 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()); + + 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()); +} 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 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 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