From e49cbdbf638ba0a34bcce6672336f92c3f40037c Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:06:41 -0400 Subject: [PATCH] feat(FreeBSD): Add backend support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactors CI into reusable workflow-call pipelines (`ci-driver`, `ci-build`, `ci-results`) and updates the top-level `ci.yml` to orchestrate release setup, Windows driver packaging/signing, cross-platform library builds (now including FreeBSD), and Codecov uploads. Adds FreeBSD platform support details across build and runtime behavior: enables threaded use on FreeBSD, applies required Clang libc++ experimental flags, extends the Linux/uhid backend implementation to support FreeBSD uinput paths and platform-specific capability/effective-profile handling, and wires in new FreeBSD integration tests. Documentation is updated to describe FreeBSD’s uinput-based feature subset and integration expectations. --- .github/workflows/ci-build.yml | 645 ++++++++++++++++++++++ .github/workflows/ci-driver.yml | 242 +++++++++ .github/workflows/ci-results.yml | 75 +++ .github/workflows/ci.yml | 797 ++-------------------------- CMakeLists.txt | 4 +- docs/platform-support.md | 35 +- docs/streaming-host-integration.md | 7 +- src/CMakeLists.txt | 7 + src/core/backend.hpp | 7 + src/core/runtime.cpp | 11 +- src/platform/linux/uhid_backend.cpp | 132 ++++- tests/CMakeLists.txt | 3 + tests/unit/test_freebsd_backend.cpp | 126 +++++ tests/unit/test_runtime.cpp | 8 + 14 files changed, 1316 insertions(+), 783 deletions(-) create mode 100644 .github/workflows/ci-build.yml create mode 100644 .github/workflows/ci-driver.yml create mode 100644 .github/workflows/ci-results.yml create mode 100644 tests/unit/test_freebsd_backend.cpp diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml new file mode 100644 index 0000000..58f1dc0 --- /dev/null +++ b/.github/workflows/ci-build.yml @@ -0,0 +1,645 @@ +--- +name: CI-Build +permissions: {} + +on: + workflow_call: + inputs: + python_version: + required: true + type: string + release_commit: + required: true + type: string + release_version: + required: true + type: string + +env: + BRANCH: ${{ github.head_ref || github.ref_name }} + BUILD_VERSION: ${{ inputs.release_version }} + CMAKE_BUILD_CONFIG: Debug + COMMIT: ${{ inputs.release_commit }} + FREEBSD_CLANG_VERSION: 19 + FREEBSD_VERSION: '15.1' + OPENCPPCOVERAGE_VERSION: '0.9.9.0' + PYTHON_VERSION: ${{ inputs.python_version }} + +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 + build_tools: 'ON' + cc: gcc + cxx: g++ + gcov_executable: gcov + - name: Linux-Clang + os: ubuntu-latest + shell: bash + kind: unix + build_tools: 'ON' + 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 + build_tools: 'ON' + cc: clang + cxx: clang++ + gcov_executable: gcov + - name: FreeBSD + os: ubuntu-latest + shell: freebsd {0} + kind: freebsd + build_tools: 'OFF' + cc: clang19 + cxx: clang++19 + gcov_executable: llvm-cov19 gcov + - name: Windows-MinGW-UCRT64 + os: windows-latest + shell: msys2 {0} + kind: msys2 + build_tools: 'ON' + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Get Processor Count + id: processor_count + if: matrix.kind == 'freebsd' + shell: bash + run: | + PROCESSOR_COUNT=$(nproc) + echo "PROCESSOR_COUNT=${PROCESSOR_COUNT}" >> "${GITHUB_OUTPUT}" + echo "PROCESSOR_COUNT: ${PROCESSOR_COUNT}" + + - name: Setup FreeBSD + if: matrix.kind == 'freebsd' + uses: vmactions/freebsd-vm@83b151f58c6047089f4c80eb5ba2039d158ce093 # v1.5.3 + with: + arch: x86_64 + cpu: ${{ steps.processor_count.outputs.PROCESSOR_COUNT }} + envs: 'BRANCH BUILD_VERSION CMAKE_BUILD_CONFIG COMMIT PYTHON_VERSION' + prepare: | + set -e + + python_package_version="$(printf '%s' "${PYTHON_VERSION}" | tr -d '.')" + pkg update + pkg upgrade -y + pkg install -y \ + devel/cmake-core \ + devel/evdev-proto \ + devel/git \ + devel/libevdev \ + devel/llvm${{ env.FREEBSD_CLANG_VERSION }} \ + devel/ninja \ + devel/pkgconf \ + devel/uv \ + "lang/python${python_package_version}" \ + textproc/libxml2 \ + textproc/libxslt \ + x11/libX11 \ + x11/libXtst + + if ! kldstat -q -m uinput; then + kldload uinput + fi + + uinput_node='' + for candidate in /dev/input/uinput /dev/uinput; do + if [ -e "${candidate}" ]; then + uinput_node="${candidate}" + break + fi + done + if [ -z "${uinput_node}" ]; then + echo 'FreeBSD uinput did not expose a supported device node.' >&2 + exit 1 + fi + chmod a+rw "${uinput_node}" + + ln -sf "/usr/local/bin/python${PYTHON_VERSION}" /usr/local/bin/python + release: ${{ env.FREEBSD_VERSION }} + run: | + set -e + + uv sync --project third-party/lizardbyte-common --locked --only-group test-c \ + --python /usr/local/bin/python \ + --no-python-downloads \ + --no-install-project + + git config --global --add safe.directory '*' + sync: nfs + + - name: Setup Dependencies Linux + if: runner.os == 'Linux' && matrix.kind == 'unix' + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + clang \ + cmake \ + libevdev-dev \ + 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' + SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ENV{HID_PHYS}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess" + SUBSYSTEM=="input", KERNEL=="event*", 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 + if ! sudo modprobe hid_playstation; then + echo "::warning::Unable to load hid_playstation; SDL HIDAPI will exercise the native hidraw output path." + fi + 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' && matrix.kind != 'freebsd' + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Setup uv + if: matrix.kind != 'msvc' && matrix.kind != 'freebsd' + uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.0 + with: + enable-cache: true + + - name: Sync Python tools + if: matrix.kind != 'msvc' && matrix.kind != 'freebsd' + 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: + BRANCH: ${{ github.head_ref || github.ref_name }} + BUILD_VERSION: ${{ inputs.release_version }} + CC: ${{ matrix.cc }} + COMMIT: ${{ inputs.release_commit }} + CXX: ${{ matrix.cxx }} + run: | + if [ "${{ matrix.kind }}" = 'freebsd' ]; then + export CC="$(command -v '${{ matrix.cc }}')" + export CXX="$(command -v '${{ matrix.cxx }}')" + fi + + cmake \ + -DBUILD_DOCS=OFF \ + -DBUILD_EXAMPLES=ON \ + -DBUILD_TESTS=ON \ + -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_CONFIG} \ + -DLIBVIRTUALHID_BUILD_TOOLS=${{ matrix.build_tools || 'ON' }} \ + -B cmake-build-ci \ + -G Ninja \ + -S . + + - name: Configure MSVC + if: matrix.kind == 'msvc' + env: + BRANCH: ${{ github.head_ref || github.ref_name }} + BUILD_VERSION: ${{ inputs.release_version }} + COMMIT: ${{ inputs.release_commit }} + run: | + cmake ` + -DBUILD_DOCS=OFF ` + -DBUILD_EXAMPLES=ON ` + -DBUILD_TESTS=ON ` + -DLIBVIRTUALHID_BUILD_TOOLS=${{ matrix.build_tools || 'ON' }} ` + -A x64 ` + -B cmake-build-ci ` + -G "Visual Studio 17 2022" ` + -S . + + - name: Build + run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --parallel 2 + + - name: Download Windows driver installer artifact + if: runner.os == 'Windows' + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: windows-driver-installer + path: windows-driver-installer + + - name: Trust Windows driver catalog signer + if: runner.os == 'Windows' + shell: pwsh + run: | + $certificate = Get-ChildItem ` + -LiteralPath .\windows-driver-installer ` + -Filter *.cer ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + if (!$certificate) { + Write-Host "No separate driver signing certificate artifact was provided." + return + } + + $imported = Import-Certificate ` + -FilePath $certificate.FullName ` + -CertStoreLocation "Cert:\LocalMachine\TrustedPublisher" + foreach ($cert in $imported) { + Write-Host "Trusted driver publisher certificate $($cert.Subject) [$($cert.Thumbprint)]." + } + + - name: Install Windows driver installer + if: runner.os == 'Windows' + shell: pwsh + timeout-minutes: 10 + run: | + $installer = Get-ChildItem -LiteralPath .\windows-driver-installer -Filter *.msi | Select-Object -First 1 + if (!$installer) { + throw "Windows driver installer artifact did not contain an MSI." + } + $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-install.log" + $driverLogPath = Join-Path $env:ProgramData "libvirtualhid\install-driver.log" + $setupApiLogPath = Join-Path $env:windir "inf\setupapi.dev.log" + $process = Start-Process ` + -FilePath msiexec.exe ` + -ArgumentList @("/i", $installer.FullName, "/qn", "/norestart", "/L*v", $logPath) ` + -PassThru ` + -NoNewWindow + if (!$process.WaitForExit([int] [TimeSpan]::FromMinutes(5).TotalMilliseconds)) { + Get-Content -LiteralPath $logPath -Tail 200 -ErrorAction SilentlyContinue + Get-Content -LiteralPath $driverLogPath -Tail 200 -ErrorAction SilentlyContinue + Get-Content -LiteralPath $setupApiLogPath -Tail 300 -ErrorAction SilentlyContinue + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "Windows driver installer timed out after 5 minutes." + } + if ($process.ExitCode -notin @(0, 3010)) { + Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue + Get-Content -LiteralPath $driverLogPath -ErrorAction SilentlyContinue + Get-Content -LiteralPath $setupApiLogPath -Tail 300 -ErrorAction SilentlyContinue + throw "Windows driver installer exited with code $($process.ExitCode)." + } + + - name: Enable GitHub Actions evaluation window + if: runner.os == 'Windows' + shell: pwsh + run: | + $serviceName = "libvirtualhid_broker" + $serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName" + New-ItemProperty ` + -LiteralPath $serviceRegistryPath ` + -Name Environment ` + -PropertyType MultiString ` + -Value @("GITHUB_ACTIONS=true") ` + -Force | Out-Null + Restart-Service -Name $serviceName -Force + (Get-Service -Name $serviceName).WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) + + - name: Verify Windows test driver package + if: runner.os == 'Windows' + shell: pwsh + run: | + if ("${{ matrix.kind }}" -eq "msys2") { + $env:PATH = "C:\msys64\${{ matrix.msystem }}\bin;C:\msys64\usr\bin;$env:PATH" + $gamepadAdapterPath = "$env:GITHUB_WORKSPACE\cmake-build-ci\examples\gamepad_adapter.exe" + } else { + $gamepadAdapterPath = Join-Path ` + "$env:GITHUB_WORKSPACE\cmake-build-ci\examples\$env:CMAKE_BUILD_CONFIG" ` + "gamepad_adapter.exe" + } + $profiles = @("generic", "xone", "xseries", "ds4", "ds5", "switch") + foreach ($profile in $profiles) { + .\scripts\windows\test-installed-driver.ps1 ` + -GamepadAdapterPath $gamepadAdapterPath ` + -Profile $profile ` + -Verbose + } + + - name: Run gamepad adapter example + run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --target run_gamepad_adapter_example + + - name: Prepare report directory + run: cmake -E make_directory cmake-build-ci/reports + + - name: Run tests + id: test + if: matrix.kind != 'msvc' + run: | + cd cmake-build-ci/tests + ./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." + } + + # The broker test hook compiles a private copy only for failure injection. + & $openCppCoverage ` + --sources "$env:GITHUB_WORKSPACE\examples" ` + --sources "$env:GITHUB_WORKSPACE\src" ` + --sources "$env:GITHUB_WORKSPACE\tools" ` + --excluded_sources "$env:GITHUB_WORKSPACE\src\platform\windows\broker" ` + "--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\$env:CMAKE_BUILD_CONFIG\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') + env: + MSYS2_PATH_TYPE: inherit + run: | + cd cmake-build-ci + # The broker test hook compiles a private copy only for failure injection. + uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r .. \ + --filter ../examples/ \ + --filter ../src/ \ + --filter ../tools/ \ + --gcov-executable "${{ matrix.gcov_executable }}" \ + --exclude ../src/platform/windows/broker/ \ + --exclude ../tests/ \ + --exclude ../third-party/ \ + --exclude-noncode-lines \ + --exclude-throw-branches \ + --exclude-unreachable-branches \ + --verbose \ + --xml-pretty \ + -o reports/coverage.xml + + - name: Uninstall Windows driver installer + if: >- + always() && + runner.os == 'Windows' + shell: pwsh + run: | + $installer = Get-ChildItem -LiteralPath .\windows-driver-installer ` + -Filter *.msi ` + -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($installer) { + $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-uninstall.log" + $process = Start-Process ` + -FilePath msiexec.exe ` + -ArgumentList @("/x", $installer.FullName, "/qn", "/norestart", "/L*v", $logPath) ` + -PassThru ` + -NoNewWindow + if (!$process.WaitForExit([int] [TimeSpan]::FromMinutes(5).TotalMilliseconds)) { + Get-Content -LiteralPath $logPath -Tail 200 -ErrorAction SilentlyContinue + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "Windows driver installer uninstall timed out after 5 minutes." + } + if ($process.ExitCode -notin @(0, 3010)) { + Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue + throw "Windows driver installer uninstall exited with code $($process.ExitCode)." + } + } + + - name: Install + run: cmake --install cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --prefix cmake-build-ci/install + + - name: Configure tests-disabled FreeBSD package + if: matrix.kind == 'freebsd' + run: | + set -e + export CC="$(command -v '${{ matrix.cc }}')" + export CXX="$(command -v '${{ matrix.cxx }}')" + + cmake \ + -DBUILD_DOCS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTS=OFF \ + -DCMAKE_BUILD_TYPE:STRING=Release \ + -DCMAKE_INSTALL_PREFIX="${GITHUB_WORKSPACE}/cmake-build-package/install" \ + -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ + -DLIBVIRTUALHID_ENABLE_PACKAGING=OFF \ + -B cmake-build-package \ + -G Ninja \ + -S . + + - name: Build and install FreeBSD package + if: matrix.kind == 'freebsd' + run: | + cmake --build cmake-build-package --parallel 2 + cmake --install cmake-build-package + test ! -d cmake-build-package/install/lib/cmake/lizardbyte-common + + - name: Configure, compile, and link FreeBSD downstream consumer + if: matrix.kind == 'freebsd' + run: | + set -e + export CC="$(command -v '${{ matrix.cc }}')" + export CXX="$(command -v '${{ matrix.cxx }}')" + + cmake \ + -DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ + -DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ + -DCMAKE_PREFIX_PATH="${GITHUB_WORKSPACE}/cmake-build-package/install" \ + -B cmake-build-package-consumer \ + -G Ninja \ + -S tests/package-consumer + cmake --build cmake-build-package-consumer --parallel 2 + + - 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 + + package_consumer: + name: Installed Package Consumer (Linux) + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Install package dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + build-essential \ + cmake \ + libevdev-dev \ + libx11-dev \ + libxtst-dev \ + ninja-build \ + pkg-config + + # This intentionally uses a separate BUILD_TESTS=OFF configuration. Test + # builds add lizardbyte-common for test support and therefore cannot prove + # that a normal installed package is independently consumable. Release also + # keeps an optimized GCC library build under warnings-as-errors without + # changing or publishing the ordinary library artifacts. + - name: Configure tests-disabled package + run: | + cmake \ + -DBUILD_DOCS=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_TESTS=OFF \ + -DCMAKE_BUILD_TYPE:STRING=Release \ + -DCMAKE_INSTALL_PREFIX="${GITHUB_WORKSPACE}/cmake-build-package/install" \ + -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ + -DLIBVIRTUALHID_ENABLE_PACKAGING=OFF \ + -B cmake-build-package \ + -G Ninja \ + -S . + + - name: Build and install package + run: | + cmake --build cmake-build-package --parallel 2 + cmake --install cmake-build-package + test ! -d cmake-build-package/install/lib/cmake/lizardbyte-common + + # A separate CMake project and real symbol reference force find_package, + # imported-target generation, compilation, and the final static-library link. + - name: Configure, compile, and link downstream consumer + run: | + cmake \ + -DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ + -DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ + -DCMAKE_PREFIX_PATH="${GITHUB_WORKSPACE}/cmake-build-package/install" \ + -B cmake-build-package-consumer \ + -G Ninja \ + -S tests/package-consumer + cmake --build cmake-build-package-consumer --parallel 2 diff --git a/.github/workflows/ci-driver.yml b/.github/workflows/ci-driver.yml new file mode 100644 index 0000000..7eb1649 --- /dev/null +++ b/.github/workflows/ci-driver.yml @@ -0,0 +1,242 @@ +--- +name: CI-Driver +permissions: {} + +on: + workflow_call: + inputs: + azure_signing_account: + required: false + type: string + default: '' + azure_signing_cert_profile: + required: false + type: string + default: '' + azure_signing_endpoint: + required: false + type: string + default: '' + publish_release: + required: true + type: string + release_commit: + required: true + type: string + release_version: + required: true + type: string + secrets: + AZURE_CLIENT_ID: + required: false + AZURE_CLIENT_SECRET: + required: false + AZURE_TENANT_ID: + required: false + +env: + DRIVER_BUILD_CONFIG: Release + +jobs: + windows_driver: + name: Windows Driver Installer + permissions: + contents: read + runs-on: windows-2022 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Setup dotnet + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: '10.x' + + - name: Configure Windows driver package + shell: pwsh + env: + BRANCH: ${{ github.head_ref || github.ref_name }} + BUILD_VERSION: ${{ inputs.release_version }} + COMMIT: ${{ inputs.release_commit }} + run: | + $certificatePath = Join-Path $env:GITHUB_WORKSPACE "cmake-build-driver\certificates\libvirtualhid-ci-test.cer" + cmake ` + -DBUILD_DOCS=OFF ` + -DBUILD_EXAMPLES=ON ` + -DBUILD_TESTS=OFF ` + -DLIBVIRTUALHID_BUILD_WINDOWS_DRIVER=ON ` + -DLIBVIRTUALHID_ENABLE_PACKAGING=ON ` + "-DLIBVIRTUALHID_DRIVER_TEST_CERTIFICATE=$certificatePath" ` + -A x64 ` + -B cmake-build-driver ` + -G "Visual Studio 17 2022" ` + -S . + + - name: Build Windows driver package + shell: pwsh + run: >- + cmake --build cmake-build-driver + --config ${{ env.DRIVER_BUILD_CONFIG }} + --target libvirtualhid_windows_catalog libvirtualhid_broker gamepad_adapter virtualhid_control + --parallel 2 + + - name: Validate Azure signing configuration + if: >- + github.event_name == 'push' && + inputs.publish_release == 'true' && + inputs.azure_signing_account == '' + shell: pwsh + run: throw "Release builds must use Azure Trusted Signing for the Windows driver package." + + - name: Sign Windows driver package with local test certificate + if: >- + github.event_name == 'pull_request' || + inputs.publish_release != 'true' + shell: pwsh + run: | + $packagePath = Join-Path ` + $env:GITHUB_WORKSPACE ` + "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG" + $certificatePath = Join-Path ` + $env:GITHUB_WORKSPACE ` + "cmake-build-driver\certificates\libvirtualhid-ci-test.cer" + .\scripts\windows\sign-driver-package.ps1 ` + -PackagePath $packagePath ` + -CertificatePath $certificatePath + + - name: Locate Windows driver catalog + id: driver_catalog + if: >- + github.event_name == 'push' && + inputs.publish_release == 'true' && + inputs.azure_signing_account != '' + shell: pwsh + run: | + $catalogPath = Join-Path ` + $env:GITHUB_WORKSPACE ` + "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat" + "path=$catalogPath" >> $env:GITHUB_OUTPUT + + - name: Sign Windows driver package with Azure Trusted Signing + if: >- + github.event_name == 'push' && + inputs.publish_release == 'true' && + inputs.azure_signing_account != '' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + certificate-profile-name: ${{ inputs.azure_signing_cert_profile }} + endpoint: ${{ inputs.azure_signing_endpoint }} + files: | + ${{ steps.driver_catalog.outputs.path }} + signing-account-name: ${{ inputs.azure_signing_account }} + + - name: Package Windows driver installer + shell: pwsh + run: | + Push-Location .\cmake-build-driver + cpack -G WIX -C $env:DRIVER_BUILD_CONFIG + $packageExitCode = $LASTEXITCODE + Pop-Location + if ($packageExitCode -ne 0) { + exit $packageExitCode + } + New-Item -ItemType Directory -Force -Path artifacts | Out-Null + Copy-Item ` + -LiteralPath .\cmake-build-driver\cpack_artifacts\libvirtualhid.msi ` + -Destination ` + ".\artifacts\libvirtualhid-Windows-AMD64-driver-installer.msi" + + - name: Export Azure driver signing certificate + if: >- + github.event_name == 'push' && + inputs.publish_release == 'true' && + inputs.azure_signing_account != '' + shell: pwsh + run: | + $catalogPath = Join-Path ` + $env:GITHUB_WORKSPACE ` + "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat" + $signature = Get-AuthenticodeSignature -FilePath $catalogPath + if ($signature.Status -ne "Valid") { + throw "Azure signed driver catalog is not valid: $($signature.StatusMessage)" + } + if (!$signature.SignerCertificate) { + throw "Azure signed driver catalog did not expose a signer certificate." + } + + $certificatePath = Join-Path $env:GITHUB_WORKSPACE "artifacts\libvirtualhid-driver-signing.cer" + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $certificatePath) | Out-Null + Export-Certificate -Cert $signature.SignerCertificate -FilePath $certificatePath -Force | Out-Null + Write-Host ( + "Exported Azure driver signing certificate " + + "$($signature.SignerCertificate.Subject) [$($signature.SignerCertificate.Thumbprint)]." + ) + + - name: Sign Windows driver installer with Azure Trusted Signing + if: >- + github.event_name == 'push' && + inputs.publish_release == 'true' && + inputs.azure_signing_account != '' + uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 + with: + azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} + azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} + azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} + certificate-profile-name: ${{ inputs.azure_signing_cert_profile }} + endpoint: ${{ inputs.azure_signing_endpoint }} + files-folder: artifacts + files-folder-filter: msi + files-folder-recurse: false + signing-account-name: ${{ inputs.azure_signing_account }} + + - name: Validate release signing identities + if: >- + github.event_name == 'push' && + inputs.publish_release == 'true' && + inputs.azure_signing_account != '' + shell: pwsh + run: | + $catalogPath = Join-Path ` + $env:GITHUB_WORKSPACE ` + "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat" + $installerPath = Get-ChildItem -LiteralPath .\artifacts -Filter *.msi | + Select-Object -ExpandProperty FullName -First 1 + if (!$installerPath) { + throw "The signed Windows driver installer was not found." + } + + $catalogSignature = Get-AuthenticodeSignature -FilePath $catalogPath + $installerSignature = Get-AuthenticodeSignature -FilePath $installerPath + foreach ($signature in @($catalogSignature, $installerSignature)) { + if ($signature.Status -ne "Valid" -or !$signature.SignerCertificate) { + throw "A release signature is invalid: $($signature.StatusMessage)" + } + } + if ($catalogSignature.SignerCertificate.Subject -cne ` + $installerSignature.SignerCertificate.Subject) { + throw "The catalog and MSI were signed with different identities." + } + Write-Host ( + "Validated release signer " + + "$($installerSignature.SignerCertificate.Subject) " + + "[$($installerSignature.SignerCertificate.Thumbprint)]." + ) + + - name: Debug wix + if: always() + shell: pwsh + run: | + Get-Content .\cmake-build-driver\cpack_artifacts\_CPack_Packages\win64\WIX\wix.log ` + -ErrorAction SilentlyContinue + + - name: Upload Windows driver installer artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-driver-installer + path: artifacts + if-no-files-found: error diff --git a/.github/workflows/ci-results.yml b/.github/workflows/ci-results.yml new file mode 100644 index 0000000..30c3379 --- /dev/null +++ b/.github/workflows/ci-results.yml @@ -0,0 +1,75 @@ +--- +name: CI-Results +permissions: {} + +on: + workflow_call: + secrets: + CODECOV_TOKEN: + required: false + +jobs: + codecov: + name: Codecov-${{ matrix.flag }} + if: startsWith(github.repository, 'LizardByte/') + permissions: + contents: read + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - build_name: FreeBSD + flag: FreeBSD + has_coverage: true + - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - 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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4010663..6eb38b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,15 +13,13 @@ concurrency: cancel-in-progress: true env: - CMAKE_BUILD_CONFIG: Debug - DRIVER_BUILD_CONFIG: Release - OPENCPPCOVERAGE_VERSION: '0.9.9.0' PYTHON_VERSION: '3.14' jobs: setup_release: name: Setup Release outputs: + python_version: ${{ steps.ci_versions.outputs.python_version }} publish_release: ${{ steps.setup_release.outputs.publish_release }} release_body: ${{ steps.setup_release.outputs.release_body }} release_commit: ${{ steps.setup_release.outputs.release_commit }} @@ -35,6 +33,10 @@ jobs: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Export CI versions + id: ci_versions + run: echo "python_version=${PYTHON_VERSION}" >> "${GITHUB_OUTPUT}" + - name: Setup Release id: setup_release uses: LizardByte/actions/actions/release_setup@d0ae7f82215a479fe2b74f4088c53ee6460513dd # v2026.728.214955 @@ -42,707 +44,39 @@ jobs: dotnet: true github_token: ${{ secrets.GITHUB_TOKEN }} - build: - name: Build (${{ matrix.name }}) - needs: - - setup_release - - windows_driver - 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - 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 \ - libevdev-dev \ - 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' - SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ENV{HID_PHYS}=="libvirtualhid/uhid/*", MODE="0666", TAG+="uaccess" - SUBSYSTEM=="hidraw", KERNEL=="hidraw*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess" - SUBSYSTEM=="input", KERNEL=="event*", ATTRS{name}=="(libvirtualhid)*", MODE="0666", TAG+="uaccess" - SUBSYSTEM=="input", KERNEL=="event*", 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 - if ! sudo modprobe hid_playstation; then - echo "::warning::Unable to load hid_playstation; SDL HIDAPI will exercise the native hidraw output path." - fi - 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@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - with: - python-version: ${{ env.PYTHON_VERSION }} - - - name: Setup uv - if: matrix.kind != 'msvc' - uses: astral-sh/setup-uv@ae62891fec2bb8e7d6c99fc78c9fec3a63790f8d # v10.0.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: - BRANCH: ${{ github.head_ref || github.ref_name }} - BUILD_VERSION: ${{ needs.setup_release.outputs.release_version }} - CC: ${{ matrix.cc }} - COMMIT: ${{ needs.setup_release.outputs.release_commit }} - CXX: ${{ matrix.cxx }} - run: | - cmake \ - -DBUILD_DOCS=OFF \ - -DBUILD_EXAMPLES=ON \ - -DBUILD_TESTS=ON \ - -DCMAKE_BUILD_TYPE:STRING=${CMAKE_BUILD_CONFIG} \ - -DLIBVIRTUALHID_BUILD_TOOLS=ON \ - -B cmake-build-ci \ - -G Ninja \ - -S . - - - name: Configure MSVC - if: matrix.kind == 'msvc' - env: - BRANCH: ${{ github.head_ref || github.ref_name }} - BUILD_VERSION: ${{ needs.setup_release.outputs.release_version }} - COMMIT: ${{ needs.setup_release.outputs.release_commit }} - run: | - cmake ` - -DBUILD_DOCS=OFF ` - -DBUILD_EXAMPLES=ON ` - -DBUILD_TESTS=ON ` - -DLIBVIRTUALHID_BUILD_TOOLS=ON ` - -A x64 ` - -B cmake-build-ci ` - -G "Visual Studio 17 2022" ` - -S . - - - name: Build - run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --parallel 2 - - - name: Download Windows driver installer artifact - if: runner.os == 'Windows' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: windows-driver-installer - path: windows-driver-installer - - - name: Trust Windows driver catalog signer - if: runner.os == 'Windows' - shell: pwsh - run: | - $certificate = Get-ChildItem ` - -LiteralPath .\windows-driver-installer ` - -Filter *.cer ` - -ErrorAction SilentlyContinue | - Select-Object -First 1 - if (!$certificate) { - Write-Host "No separate driver signing certificate artifact was provided." - return - } - - $imported = Import-Certificate ` - -FilePath $certificate.FullName ` - -CertStoreLocation "Cert:\LocalMachine\TrustedPublisher" - foreach ($cert in $imported) { - Write-Host "Trusted driver publisher certificate $($cert.Subject) [$($cert.Thumbprint)]." - } - - - name: Install Windows driver installer - if: runner.os == 'Windows' - shell: pwsh - timeout-minutes: 10 - run: | - $installer = Get-ChildItem -LiteralPath .\windows-driver-installer -Filter *.msi | Select-Object -First 1 - if (!$installer) { - throw "Windows driver installer artifact did not contain an MSI." - } - $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-install.log" - $driverLogPath = Join-Path $env:ProgramData "libvirtualhid\install-driver.log" - $setupApiLogPath = Join-Path $env:windir "inf\setupapi.dev.log" - $process = Start-Process ` - -FilePath msiexec.exe ` - -ArgumentList @("/i", $installer.FullName, "/qn", "/norestart", "/L*v", $logPath) ` - -PassThru ` - -NoNewWindow - if (!$process.WaitForExit([int] [TimeSpan]::FromMinutes(5).TotalMilliseconds)) { - Get-Content -LiteralPath $logPath -Tail 200 -ErrorAction SilentlyContinue - Get-Content -LiteralPath $driverLogPath -Tail 200 -ErrorAction SilentlyContinue - Get-Content -LiteralPath $setupApiLogPath -Tail 300 -ErrorAction SilentlyContinue - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue - throw "Windows driver installer timed out after 5 minutes." - } - if ($process.ExitCode -notin @(0, 3010)) { - Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue - Get-Content -LiteralPath $driverLogPath -ErrorAction SilentlyContinue - Get-Content -LiteralPath $setupApiLogPath -Tail 300 -ErrorAction SilentlyContinue - throw "Windows driver installer exited with code $($process.ExitCode)." - } - - - name: Enable GitHub Actions evaluation window - if: runner.os == 'Windows' - shell: pwsh - run: | - $serviceName = "libvirtualhid_broker" - $serviceRegistryPath = "HKLM:\SYSTEM\CurrentControlSet\Services\$serviceName" - New-ItemProperty ` - -LiteralPath $serviceRegistryPath ` - -Name Environment ` - -PropertyType MultiString ` - -Value @("GITHUB_ACTIONS=true") ` - -Force | Out-Null - Restart-Service -Name $serviceName -Force - (Get-Service -Name $serviceName).WaitForStatus("Running", [TimeSpan]::FromSeconds(15)) - - - name: Verify Windows test driver package - if: runner.os == 'Windows' - shell: pwsh - run: | - if ("${{ matrix.kind }}" -eq "msys2") { - $env:PATH = "C:\msys64\${{ matrix.msystem }}\bin;C:\msys64\usr\bin;$env:PATH" - $gamepadAdapterPath = "$env:GITHUB_WORKSPACE\cmake-build-ci\examples\gamepad_adapter.exe" - } else { - $gamepadAdapterPath = Join-Path ` - "$env:GITHUB_WORKSPACE\cmake-build-ci\examples\$env:CMAKE_BUILD_CONFIG" ` - "gamepad_adapter.exe" - } - $profiles = @("generic", "xone", "xseries", "ds4", "ds5", "switch") - foreach ($profile in $profiles) { - .\scripts\windows\test-installed-driver.ps1 ` - -GamepadAdapterPath $gamepadAdapterPath ` - -Profile $profile ` - -Verbose - } - - - name: Run gamepad adapter example - run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --target run_gamepad_adapter_example - - - name: Prepare report directory - run: cmake -E make_directory cmake-build-ci/reports - - - 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." - } - - # The broker test hook compiles a private copy only for failure injection. - & $openCppCoverage ` - --sources "$env:GITHUB_WORKSPACE\examples" ` - --sources "$env:GITHUB_WORKSPACE\src" ` - --sources "$env:GITHUB_WORKSPACE\tools" ` - --excluded_sources "$env:GITHUB_WORKSPACE\src\platform\windows\broker" ` - "--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\$env:CMAKE_BUILD_CONFIG\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: | - # The broker test hook compiles a private copy only for failure injection. - uv run --project ../third-party/lizardbyte-common --locked --no-sync gcovr . -r .. \ - --filter ../examples/ \ - --filter ../src/ \ - --filter ../tools/ \ - --gcov-executable "${GCOV_EXECUTABLE}" \ - --exclude ../src/platform/windows/broker/ \ - --exclude ../tests/ \ - --exclude ../third-party/ \ - --exclude-noncode-lines \ - --exclude-throw-branches \ - --exclude-unreachable-branches \ - --verbose \ - --xml-pretty \ - -o reports/coverage.xml - - - name: Uninstall Windows driver installer - if: >- - always() && - runner.os == 'Windows' - shell: pwsh - run: | - $installer = Get-ChildItem -LiteralPath .\windows-driver-installer ` - -Filter *.msi ` - -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($installer) { - $logPath = Join-Path $env:RUNNER_TEMP "libvirtualhid-driver-uninstall.log" - $process = Start-Process ` - -FilePath msiexec.exe ` - -ArgumentList @("/x", $installer.FullName, "/qn", "/norestart", "/L*v", $logPath) ` - -PassThru ` - -NoNewWindow - if (!$process.WaitForExit([int] [TimeSpan]::FromMinutes(5).TotalMilliseconds)) { - Get-Content -LiteralPath $logPath -Tail 200 -ErrorAction SilentlyContinue - Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue - throw "Windows driver installer uninstall timed out after 5 minutes." - } - if ($process.ExitCode -notin @(0, 3010)) { - Get-Content -LiteralPath $logPath -ErrorAction SilentlyContinue - throw "Windows driver installer uninstall exited with code $($process.ExitCode)." - } - } - - - name: Install - run: cmake --install cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --prefix cmake-build-ci/install - - - 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 - - package_consumer: - name: Installed Package Consumer (Linux) + driver: + name: Windows Driver + needs: setup_release permissions: contents: read - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: recursive - - - name: Install package dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - build-essential \ - cmake \ - libevdev-dev \ - libx11-dev \ - libxtst-dev \ - ninja-build \ - pkg-config - - # This intentionally uses a separate BUILD_TESTS=OFF configuration. Test - # builds add lizardbyte-common for test support and therefore cannot prove - # that a normal installed package is independently consumable. Release also - # keeps an optimized GCC library build under warnings-as-errors without - # changing or publishing the ordinary library artifacts. - - name: Configure tests-disabled package - run: | - cmake \ - -DBUILD_DOCS=OFF \ - -DBUILD_EXAMPLES=OFF \ - -DBUILD_TESTS=OFF \ - -DCMAKE_BUILD_TYPE:STRING=Release \ - -DCMAKE_INSTALL_PREFIX="${GITHUB_WORKSPACE}/cmake-build-package/install" \ - -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ - -DLIBVIRTUALHID_ENABLE_PACKAGING=OFF \ - -B cmake-build-package \ - -G Ninja \ - -S . - - - name: Build and install package - run: | - cmake --build cmake-build-package --parallel 2 - cmake --install cmake-build-package - test ! -d cmake-build-package/install/lib/cmake/lizardbyte-common - - # A separate CMake project and real symbol reference force find_package, - # imported-target generation, compilation, and the final static-library link. - - name: Configure, compile, and link downstream consumer - run: | - cmake \ - -DCMAKE_FIND_USE_PACKAGE_REGISTRY=OFF \ - -DCMAKE_FIND_USE_SYSTEM_PACKAGE_REGISTRY=OFF \ - -DCMAKE_PREFIX_PATH="${GITHUB_WORKSPACE}/cmake-build-package/install" \ - -B cmake-build-package-consumer \ - -G Ninja \ - -S tests/package-consumer - cmake --build cmake-build-package-consumer --parallel 2 + uses: ./.github/workflows/ci-driver.yml + with: + azure_signing_account: ${{ vars.AZURE_SIGNING_ACCOUNT }} + azure_signing_cert_profile: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} + azure_signing_endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} + publish_release: ${{ needs.setup_release.outputs.publish_release }} + release_commit: ${{ needs.setup_release.outputs.release_commit }} + release_version: ${{ needs.setup_release.outputs.release_version }} + secrets: + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - windows_driver: - name: Windows Driver Installer - needs: setup_release + build: + name: Library + needs: + - driver + - setup_release permissions: contents: read - runs-on: windows-2022 - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - submodules: recursive - - - name: Setup dotnet - uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 - with: - dotnet-version: '10.x' - - - name: Configure Windows driver package - shell: pwsh - env: - BRANCH: ${{ github.head_ref || github.ref_name }} - BUILD_VERSION: ${{ needs.setup_release.outputs.release_version }} - COMMIT: ${{ needs.setup_release.outputs.release_commit }} - run: | - $certificatePath = Join-Path $env:GITHUB_WORKSPACE "cmake-build-driver\certificates\libvirtualhid-ci-test.cer" - cmake ` - -DBUILD_DOCS=OFF ` - -DBUILD_EXAMPLES=ON ` - -DBUILD_TESTS=OFF ` - -DLIBVIRTUALHID_BUILD_WINDOWS_DRIVER=ON ` - -DLIBVIRTUALHID_ENABLE_PACKAGING=ON ` - "-DLIBVIRTUALHID_DRIVER_TEST_CERTIFICATE=$certificatePath" ` - -A x64 ` - -B cmake-build-driver ` - -G "Visual Studio 17 2022" ` - -S . - - - name: Build Windows driver package - shell: pwsh - run: >- - cmake --build cmake-build-driver - --config ${{ env.DRIVER_BUILD_CONFIG }} - --target libvirtualhid_windows_catalog libvirtualhid_broker gamepad_adapter virtualhid_control - --parallel 2 - - - name: Validate Azure signing configuration - if: >- - github.event_name == 'push' && - needs.setup_release.outputs.publish_release == 'true' && - vars.AZURE_SIGNING_ACCOUNT == '' - shell: pwsh - run: throw "Release builds must use Azure Trusted Signing for the Windows driver package." - - - name: Sign Windows driver package with local test certificate - if: >- - github.event_name == 'pull_request' || - needs.setup_release.outputs.publish_release != 'true' - shell: pwsh - run: | - $packagePath = Join-Path ` - $env:GITHUB_WORKSPACE ` - "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG" - $certificatePath = Join-Path ` - $env:GITHUB_WORKSPACE ` - "cmake-build-driver\certificates\libvirtualhid-ci-test.cer" - .\scripts\windows\sign-driver-package.ps1 ` - -PackagePath $packagePath ` - -CertificatePath $certificatePath - - - name: Locate Windows driver catalog - id: driver_catalog - if: >- - github.event_name == 'push' && - needs.setup_release.outputs.publish_release == 'true' && - vars.AZURE_SIGNING_ACCOUNT != '' - shell: pwsh - run: | - $catalogPath = Join-Path ` - $env:GITHUB_WORKSPACE ` - "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat" - "path=$catalogPath" >> $env:GITHUB_OUTPUT - - - name: Sign Windows driver package with Azure Trusted Signing - if: >- - github.event_name == 'push' && - needs.setup_release.outputs.publish_release == 'true' && - vars.AZURE_SIGNING_ACCOUNT != '' - uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 - with: - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - certificate-profile-name: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} - endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} - files: | - ${{ steps.driver_catalog.outputs.path }} - signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} - - - name: Package Windows driver installer - shell: pwsh - run: | - Push-Location .\cmake-build-driver - cpack -G WIX -C $env:DRIVER_BUILD_CONFIG - $packageExitCode = $LASTEXITCODE - Pop-Location - if ($packageExitCode -ne 0) { - exit $packageExitCode - } - New-Item -ItemType Directory -Force -Path artifacts | Out-Null - Copy-Item ` - -LiteralPath .\cmake-build-driver\cpack_artifacts\libvirtualhid.msi ` - -Destination ` - ".\artifacts\libvirtualhid-Windows-AMD64-driver-installer.msi" - - - name: Export Azure driver signing certificate - if: >- - github.event_name == 'push' && - needs.setup_release.outputs.publish_release == 'true' && - vars.AZURE_SIGNING_ACCOUNT != '' - shell: pwsh - run: | - $catalogPath = Join-Path ` - $env:GITHUB_WORKSPACE ` - "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat" - $signature = Get-AuthenticodeSignature -FilePath $catalogPath - if ($signature.Status -ne "Valid") { - throw "Azure signed driver catalog is not valid: $($signature.StatusMessage)" - } - if (!$signature.SignerCertificate) { - throw "Azure signed driver catalog did not expose a signer certificate." - } - - $certificatePath = Join-Path $env:GITHUB_WORKSPACE "artifacts\libvirtualhid-driver-signing.cer" - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $certificatePath) | Out-Null - Export-Certificate -Cert $signature.SignerCertificate -FilePath $certificatePath -Force | Out-Null - Write-Host ( - "Exported Azure driver signing certificate " + - "$($signature.SignerCertificate.Subject) [$($signature.SignerCertificate.Thumbprint)]." - ) - - - name: Sign Windows driver installer with Azure Trusted Signing - if: >- - github.event_name == 'push' && - needs.setup_release.outputs.publish_release == 'true' && - vars.AZURE_SIGNING_ACCOUNT != '' - uses: azure/trusted-signing-action@c7ab2a863ab5f9a846ddb8265964877ef296ee82 # v2.0.0 - with: - azure-client-id: ${{ secrets.AZURE_CLIENT_ID }} - azure-client-secret: ${{ secrets.AZURE_CLIENT_SECRET }} - azure-tenant-id: ${{ secrets.AZURE_TENANT_ID }} - certificate-profile-name: ${{ vars.AZURE_SIGNING_CERT_PROFILE }} - endpoint: ${{ vars.AZURE_SIGNING_ENDPOINT }} - files-folder: artifacts - files-folder-filter: msi - files-folder-recurse: false - signing-account-name: ${{ vars.AZURE_SIGNING_ACCOUNT }} - - - name: Validate release signing identities - if: >- - github.event_name == 'push' && - needs.setup_release.outputs.publish_release == 'true' && - vars.AZURE_SIGNING_ACCOUNT != '' - shell: pwsh - run: | - $catalogPath = Join-Path ` - $env:GITHUB_WORKSPACE ` - "cmake-build-driver\src\platform\windows\driver\package\$env:DRIVER_BUILD_CONFIG\libvirtualhid.cat" - $installerPath = Get-ChildItem -LiteralPath .\artifacts -Filter *.msi | - Select-Object -ExpandProperty FullName -First 1 - if (!$installerPath) { - throw "The signed Windows driver installer was not found." - } - - $catalogSignature = Get-AuthenticodeSignature -FilePath $catalogPath - $installerSignature = Get-AuthenticodeSignature -FilePath $installerPath - foreach ($signature in @($catalogSignature, $installerSignature)) { - if ($signature.Status -ne "Valid" -or !$signature.SignerCertificate) { - throw "A release signature is invalid: $($signature.StatusMessage)" - } - } - if ($catalogSignature.SignerCertificate.Subject -cne ` - $installerSignature.SignerCertificate.Subject) { - throw "The catalog and MSI were signed with different identities." - } - Write-Host ( - "Validated release signer " + - "$($installerSignature.SignerCertificate.Subject) " + - "[$($installerSignature.SignerCertificate.Thumbprint)]." - ) - - - name: Debug wix - if: always() - shell: pwsh - run: | - Get-Content .\cmake-build-driver\cpack_artifacts\_CPack_Packages\win64\WIX\wix.log ` - -ErrorAction SilentlyContinue - - - name: Upload Windows driver installer artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: windows-driver-installer - path: artifacts - if-no-files-found: error - - codecov: - name: Codecov-${{ matrix.flag }} + uses: ./.github/workflows/ci-build.yml + with: + python_version: ${{ needs.setup_release.outputs.python_version }} + release_commit: ${{ needs.setup_release.outputs.release_commit }} + release_version: ${{ needs.setup_release.outputs.release_version }} + + results: + name: Coverage and Test Results if: >- always() && (needs.build.result == 'success' || needs.build.result == 'failure') && @@ -750,62 +84,9 @@ jobs: 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - 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 + uses: ./.github/workflows/ci-results.yml + secrets: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} release: name: Release @@ -813,13 +94,11 @@ jobs: always() && needs.setup_release.outputs.publish_release == 'true' && needs.build.result == 'success' && - needs.package_consumer.result == 'success' && - needs.windows_driver.result == 'success' && + needs.driver.result == 'success' && startsWith(github.repository, 'LizardByte/') needs: - build - - package_consumer - - windows_driver + - driver - setup_release permissions: contents: read diff --git a/CMakeLists.txt b/CMakeLists.txt index 37560a7..7873af9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,7 +40,7 @@ option(LIBVIRTUALHID_BUILD_TOOLS "Build libvirtualhid diagnostic tools" ${LIBVIR option(LIBVIRTUALHID_TOOLS_STATIC_RUNTIME "Link libvirtualhid tools to static compiler runtimes where supported" ON) option(LIBVIRTUALHID_TOOLS_FULLY_STATIC "Attempt to link libvirtualhid tools as fully static binaries" OFF) option(LIBVIRTUALHID_TOOLS_STATIC_SDL3 "Prefer a static SDL3 diagnostic UI dependency when available" ON) -option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on Linux" ON) +option(LIBVIRTUALHID_ENABLE_XTEST "Enable X11/XTest keyboard and mouse fallback on evdev platforms" ON) option(LIBVIRTUALHID_BUILD_WINDOWS_DRIVER "Build the Windows UMDF2 driver package with the WDK/MSVC toolchain" OFF) option(LIBVIRTUALHID_BUILD_WINDOWS_BROKER "Build the Windows broker service used by the monetized UMDF driver package" @@ -57,7 +57,7 @@ include(CMakePackageConfigHelpers) include(GNUInstallDirs) set(LIBVIRTUALHID_USES_THREADS OFF) -if(CMAKE_SYSTEM_NAME STREQUAL "Linux") +if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") set(LIBVIRTUALHID_USES_THREADS ON) endif() set(LIBVIRTUALHID_USES_XTEST OFF) diff --git a/docs/platform-support.md b/docs/platform-support.md index a72e69f..62a241a 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -19,7 +19,7 @@ Use capability queries for behavior such as: - Whether gamepad output reports are supported. - Whether keyboard, mouse, touchscreen, trackpad, or pen tablet creation is available. -- Whether the Linux XTest fallback is active. +- Whether the X11/XTest keyboard and mouse fallback is active. - Whether a Windows driver package must be installed. ## Windows @@ -210,6 +210,39 @@ the `input` group, then log out and back in: sudo usermod -aG input $USER ``` +## FreeBSD + +The FreeBSD backend uses the native evdev compatibility stack through +`libevdev` and uinput. It accepts both `/dev/input/uinput`, which is the native +FreeBSD path, and `/dev/uinput` for environments that provide the Linux-style +alias. It supports the same uinput device categories as the Linux backend: + +- Generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch + Pro gamepads. +- Keyboard and mouse devices, with X11/XTest available as a fallback. +- Touchscreen, trackpad, and pen tablet devices. + +FreeBSD's [uhid(4)](https://man.freebsd.org/cgi/man.cgi?query=uhid&sektion=4) +is not the Linux UHID transport. It exposes an existing physical USB HID +interface through `/dev/uhid?`; it does not let a process register a new device +with the kernel HID bus. FreeBSD CUSE applications such as +[uhidd(8)](https://man.freebsd.org/cgi/man.cgi?query=uhidd&sektion=8) can emulate +a `uhid(4)`-compatible character device for direct consumers, but that is a +different integration surface and is not used by the current backend. + +Generic, Xbox-family, Switch Pro, DualShock 4, and DualSense behavior therefore +uses uinput. Ordinary buttons, sticks, analog triggers, and rumble are available, +but raw HID reports and descriptor-driven features are not. + +For each created gamepad, `Gamepad::profile()` reports the effective FreeBSD +uinput capability subset. Motion, touchpad contacts and click, battery state, +RGB LED output, adaptive-trigger output, and raw HID output reports are disabled. +This includes Switch Pro motion and battery state as well as the +PlayStation-specific features. Streaming-host adapters can reject those +operations instead of silently accepting state that uinput cannot expose. + +The `uinput` kernel module and a writable uinput device node are required. + ## macOS The macOS backend currently uses CoreGraphics event injection for keyboard and diff --git a/docs/streaming-host-integration.md b/docs/streaming-host-integration.md index d0f8c65..286d13d 100644 --- a/docs/streaming-host-integration.md +++ b/docs/streaming-host-integration.md @@ -55,6 +55,8 @@ The core API and adapter shape cover the major streaming-host requirements: through `uinput`, and `uinput` keyboard/pointer devices. - Linux DualSense and DualShock 4 USB/Bluetooth report handling. - Linux touchscreen, trackpad, and pen tablet device types. +- FreeBSD uinput gamepads and pointer devices, with basic PlayStation input and + rumble but without Linux UHID-only PlayStation features. - Windows UMDF/VHF gamepad creation through an installed driver package. Remaining replacement work is validation and packaging, not broad API shape: @@ -67,7 +69,8 @@ Remaining replacement work is validation and packaging, not broad API shape: libvirtualhid driver-package checks. - Add and validate the Linux host adapter for the selected controller profile names used by the consuming application. -- Define the FreeBSD backend subset explicitly instead of assuming Linux - `uhid` behavior applies. +- Evaluate an optional FreeBSD CUSE-backed `uhid(4)`-compatible device for + direct HID consumers. This would supplement uinput; it is not equivalent to + registering a virtual device with FreeBSD's kernel HID bus. - Validate macOS CoreGraphics keyboard and mouse support in a streaming host, and keep native macOS virtual HID device work scoped separately. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fe29eb3..f242a22 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -98,6 +98,13 @@ target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_23) set_target_properties(${PROJECT_NAME} PROPERTIES EXPORT_NAME libvirtualhid OUTPUT_NAME virtualhid) +if(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD" AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # FreeBSD's libc++ keeps std::jthread behind its experimental-library + # switch on the LLVM toolchain used by CI. The static library's consumers + # also need the matching link option for the thread support symbols. + target_compile_options(${PROJECT_NAME} PRIVATE -fexperimental-library) + target_link_options(${PROJECT_NAME} PUBLIC -fexperimental-library) +endif() if(MSVC AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) set_property(TARGET ${PROJECT_NAME} PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") diff --git a/src/core/backend.hpp b/src/core/backend.hpp index d4a4553..4fa5f02 100644 --- a/src/core/backend.hpp +++ b/src/core/backend.hpp @@ -7,6 +7,7 @@ // standard includes #include #include +#include #include // local includes @@ -340,6 +341,12 @@ namespace lvh::detail { */ std::unique_ptr gamepad; + /** + * @brief Backend-adjusted profile when the native transport exposes a + * strict subset of the requested profile. + */ + std::optional effective_profile = std::nullopt; + /** * @brief Check whether creation succeeded. * diff --git a/src/core/runtime.cpp b/src/core/runtime.cpp index 3717a45..0f39df1 100644 --- a/src/core/runtime.cpp +++ b/src/core/runtime.cpp @@ -1074,7 +1074,16 @@ namespace lvh { return {std::move(backend_result.status), nullptr}; } - auto device = std::make_shared(id, options, std::move(backend_result.gamepad)); + auto effective_options = options; + if (backend_result.effective_profile.has_value()) { + effective_options.profile = std::move(*backend_result.effective_profile); + } + + auto device = std::make_shared( + id, + std::move(effective_options), + std::move(backend_result.gamepad) + ); state_->with_lock([this, &device]() { state_->gamepads.emplace_back(device); }); diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 00906ba..4756490 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -40,8 +40,10 @@ #define __user #endif #include -#include #include +#if defined(__linux__) + #include +#endif #include #include #include @@ -60,7 +62,9 @@ // local includes #include "core/backend.hpp" -#include "shared/playstation_feature_reports.hpp" +#if defined(__linux__) + #include "shared/playstation_feature_reports.hpp" +#endif #include #include @@ -68,8 +72,14 @@ namespace lvh::detail { namespace { // NOSONAR(cpp:S1000): Linux backend internals need internal linkage; tests include this file with syscall overrides. +#if defined(__linux__) constexpr auto uhid_path = "/dev/uhid"; - constexpr auto uinput_path = "/dev/uinput"; +#endif +#if defined(__FreeBSD__) + constexpr std::array uinput_paths {"/dev/input/uinput", "/dev/uinput"}; +#else + constexpr std::array uinput_paths {"/dev/uinput"}; +#endif constexpr auto absolute_axis_max = 65535; constexpr auto touch_axis_max_x = 19200; constexpr auto touch_axis_max_y = 10800; @@ -86,8 +96,10 @@ namespace lvh::detail { constexpr auto xbox_sparse_uinput_bus = BUS_BLUETOOTH; constexpr std::uint16_t xbox_wireless_uinput_product_id = 0x0B20; constexpr std::uint16_t xbox_series_uinput_product_id = 0x0B13; +#if defined(__linux__) namespace ps = playstation_feature_reports; constexpr auto playstation_periodic_report_ms = 10; +#endif int system_access(const char *path, int mode) { return ::access(path, mode); @@ -126,13 +138,30 @@ namespace lvh::detail { } bool can_access_uhid() { +#if defined(__linux__) return system_access(uhid_path, R_OK | W_OK) == 0; +#else + return false; +#endif } bool can_access_uinput() { - return system_access(uinput_path, R_OK | W_OK) == 0; + return std::ranges::any_of(uinput_paths, [](const char *path) { + return system_access(path, R_OK | W_OK) == 0; + }); } + int open_uinput(int flags) { + for (const auto *path : uinput_paths) { + const auto fd = system_open(path, flags); + if (fd >= 0) { + return fd; + } + } + return -1; + } + +#if defined(__linux__) std::array generated_mac_address(DeviceId id) { return { 0x02, @@ -200,9 +229,13 @@ namespace lvh::detail { buffer[3] = static_cast((value >> 24U) & 0xFFU); } +#endif + +#if defined(__linux__) bool is_playstation_profile(GamepadProfileKind kind) { return kind == GamepadProfileKind::dualshock4 || kind == GamepadProfileKind::dualsense; } +#endif bool uses_uinput_gamepad_profile(GamepadProfileKind kind) { switch (kind) { @@ -216,7 +249,11 @@ namespace lvh::detail { return true; case dualshock4: case dualsense: +#if defined(__FreeBSD__) + return true; +#else return false; +#endif } return false; @@ -228,13 +265,18 @@ namespace lvh::detail { case generic: case xbox_series: +#if defined(__FreeBSD__) + case dualsense: +#endif return KEY_RECORD; case switch_pro: return BTN_Z; case xbox_360: case xbox_one: case dualshock4: +#if !defined(__FreeBSD__) case dualsense: +#endif return std::nullopt; } @@ -254,17 +296,20 @@ namespace lvh::detail { return BUS_USB; } +#if defined(__linux__) std::uint16_t to_uhid_bus(const DeviceProfile &profile) { if (profile.gamepad_kind == GamepadProfileKind::switch_pro) { return BUS_VIRTUAL; } return to_uhid_bus(profile.bus_type); } +#endif std::uint16_t to_uinput_bus(BusType bus_type) { return to_uhid_bus(bus_type); } +#if defined(__linux__) template void copy_string(__u8 (&destination)[Size], std::string_view source) { const auto length = std::min(source.size(), Size - 1); @@ -285,6 +330,7 @@ namespace lvh::detail { std::memcpy(destination.data(), source.data(), length); destination[length] = 0; } +#endif std::optional read_first_line(const std::filesystem::path &path) { std::ifstream file {path}; @@ -301,6 +347,7 @@ namespace lvh::detail { nodes.push_back({.kind = kind, .path = path.string()}); } +#if defined(__linux__) void append_node_if_missing(std::vector &nodes, DeviceNodeKind kind, const std::filesystem::path &path) { const auto path_string = path.string(); const auto existing = std::ranges::find_if(nodes, [kind, &path_string](const DeviceNode &node) { @@ -310,6 +357,7 @@ namespace lvh::detail { nodes.push_back({.kind = kind, .path = path_string}); } } +#endif bool hidraw_name_matches(const std::filesystem::path &uevent_path, std::string_view name) { std::ifstream file {uevent_path}; @@ -328,6 +376,7 @@ namespace lvh::detail { return false; } +#if defined(__linux__) bool hidraw_metadata_matches( const std::filesystem::path &uevent_path, std::string_view name, @@ -401,6 +450,7 @@ namespace lvh::detail { return nodes; } +#endif std::vector discover_input_nodes_by_name( const std::string &name, @@ -806,6 +856,8 @@ namespace lvh::detail { protected: OperationStatus create_uinput_device(const DeviceProfile &profile, DeviceId id); + std::vector uinput_device_nodes(const std::string &device_name) const; + 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); @@ -1316,6 +1368,17 @@ namespace lvh::detail { return OperationStatus::success(); } + std::vector UinputDevice::uinput_device_nodes(const std::string &device_name) const { +#if defined(__FreeBSD__) + if (uinput_device_ != nullptr) { + if (const auto *devnode = libevdev_uinput_get_devnode(uinput_device_); devnode != nullptr) { + return {{.kind = DeviceNodeKind::input_event, .path = devnode}}; + } + } +#endif + return discover_input_nodes_by_name(device_name); + } + /** * @brief Backend keyboard backed by one Linux uinput file descriptor. */ @@ -1361,7 +1424,7 @@ namespace lvh::detail { } std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); + return uinput_device_nodes(device_name_); } private: @@ -1470,7 +1533,7 @@ namespace lvh::detail { } std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); + return uinput_device_nodes(device_name_); } private: @@ -1554,7 +1617,7 @@ namespace lvh::detail { } std::vector touch_device_nodes() const { - return discover_input_nodes_by_name(device_name_); + return uinput_device_nodes(device_name_); } protected: @@ -1884,7 +1947,7 @@ namespace lvh::detail { } std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); + return uinput_device_nodes(device_name_); } private: @@ -2292,7 +2355,7 @@ namespace lvh::detail { } std::vector device_nodes() const override { - return discover_input_nodes_by_name(device_name_); + return uinput_device_nodes(device_name_); } OperationStatus close() override { @@ -2690,6 +2753,7 @@ namespace lvh::detail { std::jthread reader_; }; +#if defined(__linux__) /** * @brief Backend gamepad backed by one Linux UHID file descriptor. */ @@ -3054,9 +3118,26 @@ namespace lvh::detail { std::mutex callback_mutex_; OutputCallback output_callback_; }; +#endif + + std::optional effective_uinput_profile(const DeviceProfile &requested_profile) { +#if defined(__FreeBSD__) + auto effective_profile = requested_profile; + effective_profile.output_report_size = 0; + effective_profile.capabilities.supports_motion = false; + effective_profile.capabilities.supports_touchpad = false; + effective_profile.capabilities.supports_rgb_led = false; + effective_profile.capabilities.supports_battery = false; + effective_profile.capabilities.supports_adaptive_triggers = false; + return effective_profile; +#else + static_cast(requested_profile); + return std::nullopt; +#endif + } /** - * @brief Linux platform backend backed by UHID. + * @brief Linux/FreeBSD platform backend backed by UHID and/or uinput. */ class LinuxUhidBackend final: public Backend { public: @@ -3064,7 +3145,11 @@ namespace lvh::detail { const auto uhid_accessible = can_access_uhid(); const auto uinput_accessible = can_access_uinput(); const auto xtest_accessible = can_use_xtest(); +#if defined(__FreeBSD__) + capabilities_.backend_name = "freebsd-uinput"; +#else capabilities_.backend_name = "linux-uhid-uinput"; +#endif capabilities_.supports_virtual_hid = uhid_accessible || uinput_accessible; capabilities_.supports_gamepad = uhid_accessible || uinput_accessible; capabilities_.supports_keyboard = uinput_accessible || xtest_accessible; @@ -3082,9 +3167,9 @@ namespace lvh::detail { BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { if (uses_uinput_gamepad_profile(options.profile.gamepad_kind)) { - const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { - return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; + return {system_error_status(ErrorCode::backend_unavailable, "failed to open uinput device", errno), nullptr}; } auto gamepad = std::make_unique(fd, options.profile.gamepad_kind); @@ -3092,9 +3177,14 @@ namespace lvh::detail { static_cast(gamepad->close()); return {status, nullptr}; } - return {OperationStatus::success(), std::move(gamepad)}; + return { + OperationStatus::success(), + std::move(gamepad), + effective_uinput_profile(options.profile), + }; } +#if defined(__linux__) const auto fd = system_open(uhid_path, O_RDWR | O_CLOEXEC); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uhid", errno), nullptr}; @@ -3107,10 +3197,16 @@ namespace lvh::detail { } return {OperationStatus::success(), std::move(gamepad)}; +#else + return { + OperationStatus::failure(ErrorCode::unsupported_profile, "gamepad profile requires Linux UHID"), + nullptr, + }; +#endif } BackendKeyboardCreationResult create_keyboard(DeviceId id, const CreateKeyboardOptions &options) override { - const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return create_xtest_keyboard(); } @@ -3129,7 +3225,7 @@ namespace lvh::detail { } BackendMouseCreationResult create_mouse(DeviceId id, const CreateMouseOptions &options) override { - const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return create_xtest_mouse(); } @@ -3151,7 +3247,7 @@ namespace lvh::detail { DeviceId id, const CreateTouchscreenOptions &options ) override { - const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; } @@ -3166,7 +3262,7 @@ namespace lvh::detail { } BackendTrackpadCreationResult create_trackpad(DeviceId id, const CreateTrackpadOptions &options) override { - const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; } @@ -3181,7 +3277,7 @@ namespace lvh::detail { } BackendPenTabletCreationResult create_pen_tablet(DeviceId id, const CreatePenTabletOptions &options) override { - const auto fd = system_open(uinput_path, O_RDWR | O_CLOEXEC | O_NONBLOCK); + const auto fd = open_uinput(O_RDWR | O_CLOEXEC | O_NONBLOCK); if (fd < 0) { return {system_error_status(ErrorCode::backend_unavailable, "failed to open /dev/uinput", errno), nullptr}; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a9c2db8..729e3ae 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -52,6 +52,9 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") if(LIBVIRTUALHID_ENABLE_XTEST) find_package(X11 QUIET) endif() +elseif(CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") + list(APPEND LIBVIRTUALHID_TEST_SOURCES + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_freebsd_backend.cpp") elseif(WIN32) if(NOT TARGET nlohmann_json::nlohmann_json) include("${PROJECT_SOURCE_DIR}/cmake/cpm/CPM.cmake") diff --git a/tests/unit/test_freebsd_backend.cpp b/tests/unit/test_freebsd_backend.cpp new file mode 100644 index 0000000..37391d3 --- /dev/null +++ b/tests/unit/test_freebsd_backend.cpp @@ -0,0 +1,126 @@ +/** + * @file tests/unit/test_freebsd_backend.cpp + * @brief FreeBSD uinput backend integration tests. + */ + +// standard includes +#include +#include +#include + +// lib includes +#include + +// local includes +#include +#include +#include + +namespace { + + std::unique_ptr create_platform_runtime() { + lvh::RuntimeOptions options; + options.backend = lvh::BackendKind::platform_default; + return lvh::Runtime::create(options); + } + + void expect_uinput_node(const std::vector &nodes) { + ASSERT_FALSE(nodes.empty()); + EXPECT_EQ(nodes.front().kind, lvh::DeviceNodeKind::input_event); + EXPECT_TRUE( + nodes.front().path.starts_with("/dev/input/event") || + nodes.front().path.starts_with("/dev/event") + ) << nodes.front().path; + } + +} // namespace + +TEST(FreeBsdBackendTest, ReportsTheUinputDeviceSurface) { + auto runtime = create_platform_runtime(); + ASSERT_NE(runtime, nullptr); + + const auto &capabilities = runtime->capabilities(); + EXPECT_EQ(capabilities.backend_name, "freebsd-uinput"); + EXPECT_TRUE(capabilities.supports_virtual_hid); + EXPECT_TRUE(capabilities.supports_gamepad); + EXPECT_TRUE(capabilities.supports_keyboard); + EXPECT_TRUE(capabilities.supports_mouse); + EXPECT_TRUE(capabilities.supports_touchscreen); + EXPECT_TRUE(capabilities.supports_trackpad); + EXPECT_TRUE(capabilities.supports_pen_tablet); + EXPECT_TRUE(capabilities.supports_output_reports); + EXPECT_FALSE(capabilities.requires_installed_driver); +} + +TEST(FreeBsdBackendTest, CreatesEveryGamepadWithTheExpectedPlayStationSubset) { + auto runtime = create_platform_runtime(); + ASSERT_NE(runtime, nullptr); + + const std::array profiles { + lvh::profiles::generic_gamepad(), + lvh::profiles::xbox_360(), + lvh::profiles::xbox_one(), + lvh::profiles::xbox_series(), + lvh::profiles::dualshock4(), + lvh::profiles::dualsense(), + lvh::profiles::switch_pro(), + }; + + for (const auto &profile : profiles) { + SCOPED_TRACE(profile.name); + auto created = runtime->create_gamepad(profile); + ASSERT_TRUE(created) << created.status.message(); + + const auto &effective_profile = created.gamepad->profile(); + const auto support = lvh::gamepad_profile_support(effective_profile); + EXPECT_TRUE(support.supports_rumble); + EXPECT_FALSE(support.supports_motion); + EXPECT_FALSE(support.supports_touchpad); + EXPECT_FALSE(support.supports_battery); + EXPECT_FALSE(support.supports_rgb_led); + EXPECT_FALSE(support.supports_adaptive_triggers); + EXPECT_EQ(effective_profile.output_report_size, 0U); + EXPECT_FALSE(lvh::supports_gamepad_output(effective_profile, lvh::GamepadOutputKind::raw_report)); + + if ( + effective_profile.gamepad_kind == lvh::GamepadProfileKind::dualshock4 || + effective_profile.gamepad_kind == lvh::GamepadProfileKind::dualsense + ) { + EXPECT_FALSE(support.supports_touchpad_button); + } + + expect_uinput_node(created.gamepad->device_nodes()); + EXPECT_TRUE(created.gamepad->submit({}).ok()); + EXPECT_TRUE(created.gamepad->close().ok()); + } +} + +TEST(FreeBsdBackendTest, CreatesEveryNonGamepadUinputDevice) { + auto runtime = create_platform_runtime(); + ASSERT_NE(runtime, nullptr); + + auto keyboard = runtime->create_keyboard(); + ASSERT_TRUE(keyboard) << keyboard.status.message(); + expect_uinput_node(keyboard.keyboard->device_nodes()); + EXPECT_TRUE(keyboard.keyboard->close().ok()); + + auto mouse = runtime->create_mouse(); + ASSERT_TRUE(mouse) << mouse.status.message(); + expect_uinput_node(mouse.mouse->device_nodes()); + EXPECT_TRUE(mouse.mouse->close().ok()); + + auto touchscreen = runtime->create_touchscreen(); + ASSERT_TRUE(touchscreen) << touchscreen.status.message(); + expect_uinput_node(touchscreen.touchscreen->device_nodes()); + EXPECT_TRUE(touchscreen.touchscreen->close().ok()); + + auto trackpad = runtime->create_trackpad(); + ASSERT_TRUE(trackpad) << trackpad.status.message(); + expect_uinput_node(trackpad.trackpad->device_nodes()); + EXPECT_TRUE(trackpad.trackpad->close().ok()); + + auto pen_tablet = runtime->create_pen_tablet(); + ASSERT_TRUE(pen_tablet) << pen_tablet.status.message(); + expect_uinput_node(pen_tablet.pen_tablet->device_nodes()); + EXPECT_TRUE(pen_tablet.pen_tablet->close().ok()); +} diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index e83d6ff..2476133 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -42,6 +42,14 @@ TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { #if defined(__linux__) EXPECT_EQ(runtime->capabilities().backend_name, "linux-uhid-uinput"); EXPECT_FALSE(runtime->capabilities().requires_installed_driver); +#elif defined(__FreeBSD__) + EXPECT_EQ(runtime->capabilities().backend_name, "freebsd-uinput"); + EXPECT_FALSE(runtime->capabilities().requires_installed_driver); + EXPECT_TRUE(runtime->capabilities().supports_gamepad); + + auto created = runtime->create_gamepad(lvh::profiles::xbox_360()); + ASSERT_TRUE(created) << created.status.message(); + EXPECT_TRUE(created.gamepad->close().ok()); #elif defined(_WIN32) EXPECT_EQ(runtime->capabilities().backend_name, "windows-umdf"); EXPECT_TRUE(runtime->capabilities().requires_installed_driver);