From 589f3ec5b3adbf62fc79206d8d3210d7faba4ed7 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:18:50 -0400 Subject: [PATCH 01/32] feat(macOS): add gamepad broker --- .github/workflows/ci-build.yml | 14 +- .github/workflows/ci-driver.yml | 2 +- .github/workflows/ci-macos-broker.yml | 99 +++ .github/workflows/ci.yml | 28 + CMakeLists.txt | 8 + LICENSE.md | 5 +- LICENSES/license-map.md | 8 +- README.md | 20 +- cmake/build_with_max_jobs.cmake | 14 + docs/development.md | 20 +- docs/end-user-gamepad-guide.md | 2 + docs/macos-gamepad.md | 169 +++++ docs/platform-support.md | 50 +- docs/usage.md | 18 +- examples/CMakeLists.txt | 5 + .../dev.lizardbyte.app.libvirtualhid.plist | 13 + scripts/macos/install.command | 53 ++ scripts/macos/package-dmg.sh | 73 +++ src/CMakeLists.txt | 2 +- src/include/libvirtualhid/license.hpp | 6 +- src/platform/macos/broker/CMakeLists.txt | 59 ++ src/platform/macos/broker/Info.plist | 15 + src/platform/macos/broker/entitlements.plist | 7 + src/platform/macos/broker/io.hpp | 93 +++ .../broker/libvirtualhid_macos_broker.cpp | 417 ++++++++++++ src/platform/macos/broker/license_cli.cpp | 83 +++ src/platform/macos/broker/license_manager.hpp | 63 ++ src/platform/macos/broker/license_manager.mm | 594 ++++++++++++++++++ src/platform/macos/broker/protocol.hpp | 62 ++ src/platform/macos/macos_backend.cpp | 31 +- src/platform/macos/macos_broker_client.cpp | 333 ++++++++++ src/platform/macos/macos_broker_client.hpp | 11 + tests/CMakeLists.txt | 1 + tests/unit/test_macos_backend.cpp | 8 +- tests/unit/test_macos_broker_protocol.cpp | 54 ++ 35 files changed, 2359 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/ci-macos-broker.yml create mode 100644 cmake/build_with_max_jobs.cmake create mode 100644 docs/macos-gamepad.md create mode 100644 scripts/macos/dev.lizardbyte.app.libvirtualhid.plist create mode 100755 scripts/macos/install.command create mode 100755 scripts/macos/package-dmg.sh create mode 100644 src/platform/macos/broker/CMakeLists.txt create mode 100644 src/platform/macos/broker/Info.plist create mode 100644 src/platform/macos/broker/entitlements.plist create mode 100644 src/platform/macos/broker/io.hpp create mode 100644 src/platform/macos/broker/libvirtualhid_macos_broker.cpp create mode 100644 src/platform/macos/broker/license_cli.cpp create mode 100644 src/platform/macos/broker/license_manager.hpp create mode 100644 src/platform/macos/broker/license_manager.mm create mode 100644 src/platform/macos/broker/protocol.hpp create mode 100644 src/platform/macos/macos_broker_client.cpp create mode 100644 src/platform/macos/macos_broker_client.hpp create mode 100644 tests/unit/test_macos_broker_protocol.cpp diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 0cba480f..4aeb5701 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -304,7 +304,11 @@ jobs: -S . - name: Build - run: cmake --build cmake-build-ci --config ${{ env.CMAKE_BUILD_CONFIG }} --parallel 2 + run: >- + cmake + -DLIBVIRTUALHID_BUILD_DIRECTORY=cmake-build-ci + -DLIBVIRTUALHID_BUILD_CONFIG=${{ env.CMAKE_BUILD_CONFIG }} + -P cmake/build_with_max_jobs.cmake - name: Download Windows driver installer artifact if: runner.os == 'Windows' @@ -598,7 +602,7 @@ jobs: - name: Build and install FreeBSD package if: matrix.kind == 'freebsd' run: | - cmake --build cmake-build-package --parallel 2 + cmake --build cmake-build-package --parallel "$(sysctl -n hw.ncpu)" cmake --install cmake-build-package test ! -d cmake-build-package/install/lib/cmake/lizardbyte-common @@ -616,7 +620,7 @@ jobs: -B cmake-build-package-consumer \ -G Ninja \ -S tests/package-consumer - cmake --build cmake-build-package-consumer --parallel 2 + cmake --build cmake-build-package-consumer --parallel "$(sysctl -n hw.ncpu)" - name: Upload report artifact if: >- @@ -676,7 +680,7 @@ jobs: - name: Build and install package run: | - cmake --build cmake-build-package --parallel 2 + cmake --build cmake-build-package --parallel "$(nproc)" cmake --install cmake-build-package test ! -d cmake-build-package/install/lib/cmake/lizardbyte-common @@ -691,4 +695,4 @@ jobs: -B cmake-build-package-consumer \ -G Ninja \ -S tests/package-consumer - cmake --build cmake-build-package-consumer --parallel 2 + cmake --build cmake-build-package-consumer --parallel "$(nproc)" diff --git a/.github/workflows/ci-driver.yml b/.github/workflows/ci-driver.yml index 30ffc965..6f309b87 100644 --- a/.github/workflows/ci-driver.yml +++ b/.github/workflows/ci-driver.yml @@ -85,7 +85,7 @@ jobs: libvirtualhid_broker gamepad_adapter virtualhid_control - --parallel 2 + --parallel $([Environment]::ProcessorCount) - name: Validate Windows driver setup helper shell: pwsh diff --git a/.github/workflows/ci-macos-broker.yml b/.github/workflows/ci-macos-broker.yml new file mode 100644 index 00000000..256a8c63 --- /dev/null +++ b/.github/workflows/ci-macos-broker.yml @@ -0,0 +1,99 @@ +--- +name: CI-macOS-Broker +permissions: {} + +on: + workflow_call: + inputs: + publish_release: + required: true + type: string + release_version: + required: true + type: string + secrets: + APPLE_ID: + required: false + APPLE_TEAM_ID: + required: false + APPLE_NOTARYTOOL_PASSWORD: + required: false + APPLE_CODESIGN_IDENTITY: + required: false + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: + required: false + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: + required: false + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64: + required: false + +env: + MACOSX_DEPLOYMENT_TARGET: '14.2' + +jobs: + macos_broker: + name: Universal macOS broker + permissions: + contents: read + runs-on: macos-15 + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Install dependencies + run: brew install cmake + + - name: Configure universal build + env: + BUILD_VERSION: ${{ inputs.release_version }} + run: | + cmake -B cmake-build-macos-universal -S . \ + -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ + -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_DOCS=OFF \ + -DBUILD_TESTS=OFF \ + -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ + -DLIBVIRTUALHID_WARNINGS_AS_ERRORS=ON + + - name: Build and inspect architectures + run: | + cmake --build cmake-build-macos-universal --parallel "$(sysctl -n hw.ncpu)" + xcrun lipo -verify_arch arm64 x86_64 \ + cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker + xcrun lipo -verify_arch arm64 x86_64 \ + cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license + xcrun lipo -verify_arch arm64 x86_64 \ + cmake-build-macos-universal/src/libvirtualhid.a + + - name: Import Developer ID certificate + if: inputs.publish_release == 'true' + uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 + with: + p12-file-base64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + p12-password: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + + - name: Sign, notarize, and package + if: inputs.publish_release == 'true' + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + PROFILE_BASE64: ${{ secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 }} + run: | + test -n "${PROFILE_BASE64}" || { echo 'Approved macOS virtual HID provisioning profile is required'; exit 1; } + test -n "${APPLE_ID}" && test -n "${APPLE_TEAM_ID}" && test -n "${APPLE_NOTARYTOOL_PASSWORD}" + profile_path="${RUNNER_TEMP}/libvirtualhid-broker.provisionprofile" + printf '%s' "${PROFILE_BASE64}" | base64 -D > "${profile_path}" + export APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE="${profile_path}" + bash scripts/macos/package-dmg.sh cmake-build-macos-universal cmake-build-macos-universal/artifacts + + - name: Upload disk image + if: inputs.publish_release == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-virtual-hid-installer + path: cmake-build-macos-universal/artifacts/libvirtualhid-macOS-universal.dmg + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff3e7165..d5e6a4e2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,24 @@ jobs: AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + macos_broker: + name: macOS Broker + needs: setup_release + permissions: + contents: read + uses: ./.github/workflows/ci-macos-broker.yml + with: + publish_release: ${{ needs.setup_release.outputs.publish_release }} + release_version: ${{ needs.setup_release.outputs.release_version }} + secrets: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} + APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 }} + build: name: Library needs: @@ -104,10 +122,12 @@ jobs: needs.setup_release.outputs.publish_release == 'true' && needs.build.result == 'success' && needs.driver.result == 'success' && + needs.macos_broker.result == 'success' && startsWith(github.repository, 'LizardByte/') needs: - build - driver + - macos_broker - setup_release permissions: contents: read @@ -119,10 +139,17 @@ jobs: name: windows-driver-installer path: windows-driver-installer + - name: Download macOS disk image + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: macos-virtual-hid-installer + path: macos-virtual-hid-installer + - name: Prepare release artifact run: | mkdir -p artifacts cp windows-driver-installer/*.msi artifacts/ + cp macos-virtual-hid-installer/*.dmg artifacts/ - name: Validate release metadata env: @@ -134,6 +161,7 @@ jobs: test -n "${RELEASE_VERSION}" test "${RELEASE_COMMIT}" = "${GITHUB_SHA}" test -s "artifacts/libvirtualhid-Windows-AMD64-driver-installer.msi" + test -s "artifacts/libvirtualhid-macOS-universal.dmg" - name: Create/Update GitHub Release if: needs.setup_release.outputs.publish_release == 'true' diff --git a/CMakeLists.txt b/CMakeLists.txt index bad58376..2579eaa5 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,9 @@ option(LIBVIRTUALHID_BUILD_WINDOWS_DRIVER "Build the Windows UMDF2 driver packag option(LIBVIRTUALHID_BUILD_WINDOWS_BROKER "Build the Windows broker service used by the monetized UMDF driver package" ${LIBVIRTUALHID_BUILD_WINDOWS_DRIVER}) +option(LIBVIRTUALHID_BUILD_MACOS_BROKER + "Build the licensed macOS virtual HID broker" + ${APPLE}) option(LIBVIRTUALHID_INSTALL "Install libvirtualhid targets, headers, and CMake package files" ${LIBVIRTUALHID_IS_TOP_LEVEL}) option(LIBVIRTUALHID_ENABLE_PACKAGING "Enable CPack package metadata" ${LIBVIRTUALHID_INSTALL}) @@ -136,6 +139,11 @@ if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME) add_subdirectory(src/platform/windows/broker) endif() + if(APPLE AND LIBVIRTUALHID_BUILD_MACOS_BROKER) + enable_language(OBJCXX) + add_subdirectory(src/platform/macos/broker) + endif() + if(BUILD_DOCS) include(third-party/dockle/cmake/Dockle.cmake) dockle_add_docs(docs TARGETS api) diff --git a/LICENSE.md b/LICENSE.md index aa31b754..dd133c00 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -6,8 +6,9 @@ summary and does not replace the complete license texts. - The cross-platform library source, public headers, non-driver backends, examples, tests, build scripts, and documentation are licensed under the [MIT License](LICENSES/MIT.md), except where the license map says otherwise. -- The Windows UMDF driver, broker, proprietary entitlement and evaluation - sources, and generated Windows driver package artifacts are licensed under +- The Windows UMDF driver, Windows and macOS brokers, proprietary entitlement + and evaluation sources, and generated Windows driver and macOS broker package + artifacts are licensed under the [LizardByte Source-Available License 1.0](LICENSES/LicenseRef-LizardByte-SAL-1.0.md). See the [license map](LICENSES/license-map.md) for the authoritative repository path diff --git a/LICENSES/license-map.md b/LICENSES/license-map.md index 2e1d3e4c..2f528d73 100644 --- a/LICENSES/license-map.md +++ b/LICENSES/license-map.md @@ -1,7 +1,7 @@ # License Map `libvirtualhid` uses separate licenses for the cross-platform library and the -Windows driver package. +Windows driver and macOS broker packages. - LB-SAL 1.0 SPDX custom identifier: `LicenseRef-LizardByte-SAL-1.0`. - Cross-platform library source, public headers, non-driver backends, examples, @@ -15,8 +15,12 @@ Windows driver package. [LizardByte Source-Available License 1.0](https://github.com/LizardByte/libvirtualhid/blob/master/LICENSES/LicenseRef-LizardByte-SAL-1.0.md). - Generated Windows driver package artifacts, including the driver MSI: [LizardByte Source-Available License 1.0](https://github.com/LizardByte/libvirtualhid/blob/master/LICENSES/LicenseRef-LizardByte-SAL-1.0.md). +- macOS broker source under `src/platform/macos/broker/`, plus generated macOS + broker bundles and DMGs: + [LizardByte Source-Available License 1.0](https://github.com/LizardByte/libvirtualhid/blob/master/LICENSES/LicenseRef-LizardByte-SAL-1.0.md). Every installed library distribution includes the MIT notice under `share/licenses/libvirtualhid`. The Windows driver MSI may include MIT-licensed helper components from this repository; packaged installs include both license -texts for that reason. +texts for that reason. The macOS broker DMG likewise includes the MIT library +and the LB-SAL broker, with both license texts. diff --git a/README.md b/README.md index 5328f7d0..ca3f5799 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,13 @@
-

🎮 Windows Virtual HID Driver License

+

🎮 Virtual HID Gamepad License

- A license is required to create virtual gamepads, keyboards, or Raw Input-visible mice with the Windows driver.
- This requirement is Windows-only; non-Windows backends do not currently require a license.
+ A license is required for Windows driver-backed devices and macOS virtual gamepads.
+ Linux and FreeBSD backends do not currently require a license.
Yearly and lifetime options are available.

- Buy a Windows license + Buy a virtual HID license
# Overview @@ -53,6 +53,9 @@ companion stay behind backend implementations. UMDF2 drivers. Xbox 360 uses a broker-owned XUSB software-device personality; the other profiles use Virtual HID Framework. Win32 keyboard and mouse fallbacks remain available when the licensed driver path is unavailable. +- macOS gamepads through a licensed, entitlement-bearing user-space HID broker. + The generic, Xbox, PlayStation, and Switch Pro HID profiles use the same + public API; keyboard and mouse input continue through CoreGraphics. - Output callbacks for profile-specific feedback such as ordinary and trigger rumble, RGB and player LEDs, adaptive triggers, and raw HID output reports when available. @@ -115,9 +118,8 @@ user-space kernel interfaces. Windows remains user-mode: the C++ library talks to a broker and UMDF2 package. The package publishes Xbox 360 through XUSB and VHF together, and publishes the other gamepads, keyboards, and mice through VHF. -macOS currently provides a limited CoreGraphics synthetic-input backend for -keyboard and mouse only. It is not a virtual-HID backend and does not yet -support gamepads; native macOS virtual-HID gamepad support is planned. +macOS gamepad support requires the signed broker app and an Apple-approved +virtual HID entitlement. See [macOS gamepad setup](docs/macos-gamepad.md). The library is designed around gamepad use first because remote streaming hosts are the first consumer class. Non-gamepad device types are available through the @@ -188,8 +190,8 @@ test app path and does not currently advertise an ARM64 build. The cross-platform `libvirtualhid` library is licensed under the [MIT License](https://github.com/LizardByte/libvirtualhid/blob/master/LICENSES/MIT.md). -The Windows UMDF driver, broker, proprietary entitlement/evaluation sources, -and generated Windows driver package artifacts, including the driver MSI, are +The Windows UMDF driver, Windows and macOS brokers, proprietary +entitlement/evaluation sources, and generated driver package artifacts are licensed under the [LizardByte Source-Available License 1.0](https://github.com/LizardByte/libvirtualhid/blob/master/LICENSES/LicenseRef-LizardByte-SAL-1.0.md) (LB-SAL 1.0). The root diff --git a/cmake/build_with_max_jobs.cmake b/cmake/build_with_max_jobs.cmake new file mode 100644 index 00000000..dd951b83 --- /dev/null +++ b/cmake/build_with_max_jobs.cmake @@ -0,0 +1,14 @@ +if(NOT DEFINED LIBVIRTUALHID_BUILD_DIRECTORY OR LIBVIRTUALHID_BUILD_DIRECTORY STREQUAL "") + message(FATAL_ERROR "LIBVIRTUALHID_BUILD_DIRECTORY is required") +endif() + +cmake_host_system_information(RESULT build_jobs QUERY NUMBER_OF_LOGICAL_CORES) +if(NOT build_jobs MATCHES "^[1-9][0-9]*$") + message(FATAL_ERROR "Unable to determine the available logical processor count") +endif() + +message(STATUS "Building with ${build_jobs} parallel jobs") +execute_process( + COMMAND "${CMAKE_COMMAND}" --build "${LIBVIRTUALHID_BUILD_DIRECTORY}" + --config "${LIBVIRTUALHID_BUILD_CONFIG}" --parallel "${build_jobs}" + COMMAND_ERROR_IS_FATAL ANY) diff --git a/docs/development.md b/docs/development.md index aa0ca400..95d44d9a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -11,7 +11,8 @@ src/core/ Shared profile, descriptor, and report logic src/platform/windows/ Windows client backend and UMDF control channel src/platform/windows/driver/ Windows UMDF2 driver package sources src/platform/linux/ Linux uhid/uinput backend -src/platform/macos/ macOS CoreGraphics keyboard and mouse backend +src/platform/macos/ macOS CoreGraphics and broker client backends +src/platform/macos/broker/ Licensed macOS virtual HID broker examples/ Minimal consumers and platform smoke tests tests/ Unit and integration tests cmake/ Package config and helper modules @@ -49,10 +50,11 @@ cmake-build-debug/tests/test_libvirtualhid ## macOS Build -macOS builds use the same CMake target shape and link the CoreGraphics backend -against the system ApplicationServices, Carbon, CoreFoundation, and IOKit -frameworks. The CI test coverage exercises translation and lifecycle paths -without posting live synthetic input events. +macOS builds link the CoreGraphics keyboard/mouse backend and the broker client +against system frameworks. Top-level builds also compile the broker app. The +ordinary test suite checks translation, protocol capacity, and lifecycle paths +without creating a live virtual HID device. Live testing requires Apple's +approved virtual HID entitlement and a signed installation. ```bash cmake -S . -B cmake-build-debug -G Ninja -DCMAKE_BUILD_TYPE=Debug @@ -60,6 +62,9 @@ cmake --build cmake-build-debug cmake-build-debug/tests/test_libvirtualhid ``` +For the universal Apple silicon and Intel release build, profile setup, and +signing workflow, see [macOS gamepad setup](macos-gamepad.md). + ## Documentation Documentation is generated by Dockle. The authored inputs and project-specific @@ -88,9 +93,8 @@ code and tests provide a better source of truth. ## Roadmap -- Add native macOS virtual-HID gamepad support beyond the current CoreGraphics - keyboard and mouse injection backend, including signing, entitlement, and - installer constraints. +- Validate each macOS HID gamepad profile against SDL, Steam, browsers, and + Game Controller framework consumers after Apple grants the entitlement. - Add bindings for other languages, such as Python, Rust, and C#. Bindings will be considered for any requested language. - Evaluate an optional FreeBSD CUSE-backed `uhid(4)`-compatible device for diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md index a37148b2..99f74cfa 100644 --- a/docs/end-user-gamepad-guide.md +++ b/docs/end-user-gamepad-guide.md @@ -204,6 +204,8 @@ battery or LED data. 2. Confirm that controller input is enabled in Sunshine. 3. On Windows, check the Virtual HID Driver version and license status on Sunshine's **Troubleshooting** page. + On macOS, check that the signed broker is running and its machine license + is active; see [macOS gamepad setup](macos-gamepad.md). 4. End and reconnect the stream, then check whether the host operating system sees a newly created controller. 5. Review the Sunshine log for controller creation, driver, permission, or diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md new file mode 100644 index 00000000..106f97c6 --- /dev/null +++ b/docs/macos-gamepad.md @@ -0,0 +1,169 @@ +# macOS virtual gamepads + +The macOS backend creates descriptor-driven virtual gamepads through a separate +root-owned broker. The broker alone calls Apple's `IOHIDUserDevice` API and holds +the virtual HID entitlement. The ordinary C++ library has no Apple entitlement +and continues to use CoreGraphics for keyboard and mouse input. + +The built-in generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, +and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation +variants, are accepted as HID descriptors. Their VID/PID, transport, input +reports, output reports, and PlayStation feature reports are carried through the +broker. Xbox 360 is an ordinary HID device on macOS; the Windows XUSB/XInput +personality is Windows-specific. Individual games may use Apple's Game +Controller framework or their own HID mappings, so a signed installed build +still needs consumer testing for each profile. + +## What to do in Apple Developer + +The Sunshine **Developer ID Application** signing certificate and existing +notarization credentials can be reused. Apple's HID Virtual Device approval may +be assigned to the team or to a particular App ID. A separate provisioning +profile for this broker's App ID is required even if Sunshine already has one. + +1. Sign in to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) + as the Apple Developer team's **Account Holder**. If the team is an + organization, Apple says the Account Holder must submit managed-capability + requests. +2. Under **Identifiers**, register an explicit macOS App ID with bundle ID + **`dev.lizardbyte.app.libvirtualhid`**. This follows Sunshine's + `dev.lizardbyte.app.Sunshine` naming pattern. If it already exists, open it. +3. If the Sunshine request is still pending, wait for its decision. Then open + the new App ID's **Capabilities** tab. If **HID Virtual Device** is + available from that approval, enable it and save. Otherwise, in + **Capability Requests**, request **HID Virtual Device** + (`com.apple.developer.hid.virtual.device`) for this App ID. Explain that + libvirtualhid is a signed, root-owned user-space broker that publishes + descriptor-driven gamepads to other local applications for remote streaming + hosts. It does not attach to physical hardware or install a kernel driver. + List the generic, Xbox, PlayStation, and Switch Pro profiles and the + broker's paid-license gate. After approval, enable the capability and save. +4. Under **Profiles**, create a **Developer ID** distribution provisioning + profile for `dev.lizardbyte.app.libvirtualhid`, selecting the same Developer + ID Application certificate used for Sunshine. Download the resulting + `.provisionprofile` file. The profile must contain the virtual HID + entitlement. A Mac App Development profile is for local development and + cannot replace the Developer ID distribution profile in the release DMG. +5. In this repository's GitHub Actions secrets, add + **`APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64`** containing a + single-line base64 encoding of the downloaded profile. The downloaded + `.provisionprofile` itself is a signed binary file; base64 is only the text + encoding used to store it in a GitHub secret. On macOS, run + `base64 -i broker.provisionprofile | tr -d '\n'`. On Windows, run this in + PowerShell, replacing the path with the downloaded file's location: + + ```powershell + [Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\to\broker.provisionprofile")) | Set-Clipboard + ``` + + Paste the clipboard contents as the secret value. Configure the existing + Sunshine secret names here as well: `APPLE_ID`, `APPLE_TEAM_ID`, + `APPLE_NOTARYTOOL_PASSWORD`, `APPLE_CODESIGN_IDENTITY`, + `APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64`, and + `APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD`. + +Apple documents the [virtual HID entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.hid.virtual.device), +the [managed-capability request steps](https://developer.apple.com/help/account/capabilities/capability-requests), +and why a [daemon with a restricted entitlement needs an app-like bundle and +embedded profile](https://developer.apple.com/documentation/xcode/signing-a-daemon-with-a-restricted-entitlement). +Approval is controlled by Apple; the same certificate does not itself grant +this entitlement. A profile issued for `dev.lizardbyte.app.Sunshine` cannot +authorize `dev.lizardbyte.app.libvirtualhid`. + +## Build and distribute + +On macOS with Xcode and CMake installed: + +```sh +export MACOSX_DEPLOYMENT_TARGET=14.2 +cmake -S . -B cmake-build-macos-universal \ + -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ + -DBUILD_DOCS=OFF -DBUILD_TESTS=OFF +cmake --build cmake-build-macos-universal --parallel "$(sysctl -n hw.ncpu)" +xcrun lipo -info cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker +``` + +The single resulting executable contains both Apple silicon and Intel slices. +CI sets `MACOSX_DEPLOYMENT_TARGET` at the workflow level, as Sunshine does. +The CI job checks the broker, license CLI, and +`libvirtualhid.a` with `lipo`. + +For a release, set `APPLE_CODESIGN_IDENTITY` to the Sunshine Developer ID +Application identity, set `APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE` to +the downloaded profile path, and set the existing Sunshine notarization +variables (`APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_NOTARYTOOL_PASSWORD`). Then run: + +```sh +bash scripts/macos/package-dmg.sh cmake-build-macos-universal +``` + +The script embeds the profile, signs the broker app with Hardened Runtime and +a secure timestamp, verifies its signature, makes one universal DMG, submits +it using `notarytool`, and staples the ticket. Release CI performs these steps +using the same certificate and notarization secret names as Sunshine. The +profile secret is the only new secret. + +Mount the DMG and double-click **Install libvirtualhid.command**. It asks for +administrator authorization, installs the signed broker app under +`/Library/Application Support/libvirtualhid`, installs the static library and +headers under `/usr/local`, and starts the `dev.lizardbyte.app.libvirtualhid` +LaunchDaemon. Run a host process in the normal user session. The broker socket +is `/var/run/libvirtualhid/broker.sock`; only the root-owned installed broker +can answer the library's requests. + +To activate a purchased license, open Terminal and run: + +```sh +/usr/local/bin/libvirtualhid-license activate +/usr/local/bin/libvirtualhid-license status +``` + +The command prompts for the key without echoing it or placing it in the process +arguments. `validate` refreshes from Polar and `deactivate` releases this +machine activation. These commands do not require `sudo` after installation. + +With the broker installed and licensed, hold a test controller for a minute: + +```sh +cmake-build-macos-universal/examples/gamepad_adapter generic --hold-seconds 60 +``` + +While it is running, use another Terminal window to inspect HID enumeration: + +```sh +hidutil list +``` + +Repeat with `x360`, `xone`, `xseries`, `ds4`, `ds5`, and `switch`. Check each +device in the intended macOS game or streaming client; enumeration alone does +not prove that a particular consumer recognizes its profile. + +The DMG contains both the MIT library and the LB-SAL broker. A release must +retain both license texts. + +## License and validation + +The macOS broker uses the same Polar organization, yearly and lifetime benefit +IDs, purchase URL, and customer portal as Windows. No unlicensed production +gamepad is created. `lvh::get_license_status()`, `activate_license()`, +`validate_license()`, and `deactivate_license()` talk to the installed broker; +the license key never enters the virtual gamepad report stream. A five-minute +GitHub Actions evaluation is available only when the broker itself starts in +the GitHub Actions environment. + +The broker stores its machine activation in a root-only file under +`/Library/Application Support/libvirtualhid`. It revalidates with Polar every +24 hours. On a network outage, a previously validated license may create one +gamepad while authorization remains current. Existing virtual gamepads close +when their authorization expires or is revoked. A yearly activation needs +online validation after the broker restarts; this deliberately fails closed +when trusted elapsed time cannot be reconstructed. + +If creation returns `backend_unavailable`, inspect the launchd job with +`sudo launchctl print system/dev.lizardbyte.app.libvirtualhid`. If it returns +`backend_failure` with an entitlement message, inspect the embedded profile +and signature with +`codesign -d --entitlements :- '/Library/Application Support/libvirtualhid/VirtualHIDBroker.app'` +and inspect the embedded profile with +`security cms -D -i '/Library/Application Support/libvirtualhid/VirtualHIDBroker.app/Contents/embedded.provisionprofile'`. +If it returns `license_required`, run `libvirtualhid-license activate`. diff --git a/docs/platform-support.md b/docs/platform-support.md index 37b23585..dfab5997 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -429,35 +429,21 @@ The `uinput` kernel module and a writable uinput device node are required. ## macOS -The macOS backend currently uses CoreGraphics event injection for keyboard and -mouse input. It keeps the same public device model as the other backends: -consumers create keyboard and mouse devices through the runtime and submit the -same normalized event types. Platform details such as macOS virtual key-code -translation, modifier flag tracking, display coordinate scaling, scroll-wheel -preference handling, and CoreGraphics event posting stay inside the backend. - -This first backend is not a virtual HID implementation. It does not require a -driver package, but consuming applications still need the normal macOS -permission path for synthetic input, such as Accessibility/Input Monitoring -approval when the host environment enforces it. - -Current macOS capabilities: - -- Keyboard key press and release using the existing Windows-style portable key - codes. -- UTF-8 keyboard text input, converted to the UTF-16 strings expected by - CoreGraphics keyboard events. -- Mouse relative movement, absolute movement on the main display, left/middle/ - right button transitions, and pixel-based vertical/horizontal scroll. -- Shared keyboard modifier state on mouse events, so combinations such as - shift-click continue to work. - -Unsupported macOS capabilities currently return `unsupported_profile`: - -- Gamepad devices and output reports. -- Touchscreen, trackpad, and pen tablet devices. - -Native macOS virtual-HID gamepad support is planned. A future backend may use -`IOHIDUserDevice`, DriverKit/HIDDriverKit, or a combination that preserves the -same public API while documenting any signing, entitlement, and installer -requirements. +Gamepads use a licensed, signed user-space `IOHIDUserDevice` broker installed +as a LaunchDaemon. The public C++ API and packed reports stay platform-neutral; +only the broker owns the Apple virtual HID entitlement. All built-in gamepad +profile descriptors are accepted, including Xbox-family, DualShock 4, +DualSense, and Switch Pro. The broker handles input, output, PlayStation +feature reports, and Switch Pro initialization replies. macOS presents Xbox +360 as HID rather than Windows XInput/XUSB. Consumer recognition still depends +on each game's macOS controller stack and needs installed validation. + +Keyboard and mouse input still use CoreGraphics, including UTF-8 text, +portable key translation, modifier state, relative and absolute motion, and +pixel-based scrolling. They follow the normal macOS synthetic-input permission +path where the host requires it. Touchscreen, trackpad, and pen tablet creation +return `unsupported_profile`. + +The broker uses the same paid Polar benefits and license API as Windows. See +[macOS gamepad setup](macos-gamepad.md) for the Apple portal request, universal +build, signing, installation, and diagnostics. diff --git a/docs/usage.md b/docs/usage.md index d8a9ff43..c1c07306 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -47,7 +47,7 @@ FetchContent_MakeAvailable(libvirtualhid) target_link_libraries(your_app PRIVATE libvirtualhid::libvirtualhid) ``` -Examples, tests, docs, and the Windows driver package are top-level or opt-in +Examples, tests, docs, and the Windows driver and macOS broker packages are top-level or opt-in builds. Normal vendored and `FetchContent` consumers only get the library target unless they explicitly enable additional options. @@ -59,6 +59,9 @@ unless they explicitly enable additional options. level project. - `BUILD_DOCS`: build Doxygen documentation when this repository is the top level project. +- `LIBVIRTUALHID_BUILD_MACOS_BROKER`: build the entitlement-bearing macOS + broker app. Enabled for top-level macOS builds; see + [macOS gamepad setup](macos-gamepad.md) for provisioning and installation. - `LIBVIRTUALHID_BUILD_TOOLS`: build diagnostic tool binaries, including `virtualhid_control`, when this repository is the top level project. - `LIBVIRTUALHID_TOOLS_STATIC_RUNTIME`: link diagnostic tools against static @@ -98,7 +101,7 @@ artifact. virtualhid_control ``` -The UI is built from the repository CPM lockfile so Windows, Linux, and future +The UI is built from the repository CPM lockfile so Windows, Linux, and macOS builds share the same frontend stack. Builds prefer static SDL3 by default when a static target is available. @@ -108,7 +111,7 @@ capabilities, list device nodes reported for UI-created devices, and display normalized gamepad output such as rumble, RGB LED, player LED, adaptive trigger, trigger rumble, and raw report events delivered through the normal callback path. Button controls are momentary by default, so they behave like physical gamepad buttons; -on Windows, the UI also displays broker license status and can activate, +on Windows and macOS, the UI also displays broker license status and can activate, refresh, or deactivate a machine license without elevation. Windows UMDF virtual HID device creation requires a current machine authorization, but does not perform an online request per device. The broker validates in the @@ -153,10 +156,11 @@ The API centers on portable device concepts: shutdown. - `get_license_status`, `activate_license`, `validate_license`, and `deactivate_license`: provider-neutral machine license operations for host - applications. On Windows these call the installed local broker; license keys - are not retained by the client library or returned to the application. The - client verifies that the named-pipe server is the SCM-registered running - broker before sending any request. + applications. On Windows and macOS these call the installed local broker; + license keys are not retained by the client library or returned to the + application. The Windows client verifies that the named-pipe server is the + SCM-registered running broker. The macOS client verifies that its Unix socket + and peer are owned by root before sending any request. - `VirtualDevice`: common lifecycle for created devices. - `Gamepad`: submits normalized gamepad state and receives output callbacks. - `Keyboard`: submits key press/release and UTF-8 text input. diff --git a/examples/CMakeLists.txt b/examples/CMakeLists.txt index 23a2986b..74aa907f 100644 --- a/examples/CMakeLists.txt +++ b/examples/CMakeLists.txt @@ -23,6 +23,11 @@ libvirtualhid_copy_mingw_runtime(keyboard_mouse_adapter) if(CMAKE_SYSTEM_NAME STREQUAL "Linux" OR WIN32) set(RUN_GAMEPAD_EXAMPLE_COMMAND "$") set(RUN_GAMEPAD_EXAMPLE_COMMENT "Running gamepad_adapter example") +elseif(APPLE) + set(RUN_GAMEPAD_EXAMPLE_COMMAND + "${CMAKE_COMMAND}" -E echo + "Install and activate the macOS virtual HID broker before running gamepad_adapter") + set(RUN_GAMEPAD_EXAMPLE_COMMENT "macOS gamepad_adapter requires the installed broker") else() set(RUN_GAMEPAD_EXAMPLE_COMMAND "${CMAKE_COMMAND}" -E echo diff --git a/scripts/macos/dev.lizardbyte.app.libvirtualhid.plist b/scripts/macos/dev.lizardbyte.app.libvirtualhid.plist new file mode 100644 index 00000000..b931e4e7 --- /dev/null +++ b/scripts/macos/dev.lizardbyte.app.libvirtualhid.plist @@ -0,0 +1,13 @@ + + + + + Labeldev.lizardbyte.app.libvirtualhid + ProgramArguments + + /Library/Application Support/libvirtualhid/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker + + RunAtLoad + KeepAlive + + diff --git a/scripts/macos/install.command b/scripts/macos/install.command new file mode 100755 index 00000000..c21b29c9 --- /dev/null +++ b/scripts/macos/install.command @@ -0,0 +1,53 @@ +#!/bin/bash +set -euo pipefail + +image_root="$(cd "$(dirname "$0")" && pwd)" +broker_app="${image_root}/usr/local/libexec/libvirtualhid/VirtualHIDBroker.app" +installed_app="/Library/Application Support/libvirtualhid/VirtualHIDBroker.app" +service_plist="/Library/LaunchDaemons/dev.lizardbyte.app.libvirtualhid.plist" + +if [[ ! -d "${broker_app}" ]]; then + echo "The virtual HID broker is missing from this disk image." >&2 + exit 1 +fi + +/usr/bin/codesign --verify --deep --strict "${broker_app}" +/usr/bin/sudo -v +if [[ -L "/Library/Application Support/libvirtualhid" ]]; then + echo "The libvirtualhid state directory must not be a symbolic link." >&2 + exit 1 +fi +/usr/bin/sudo /bin/mkdir -p "/Library/Application Support/libvirtualhid" /usr/local/lib /usr/local/include /usr/local/share/licenses +/usr/bin/sudo /usr/sbin/chown root:wheel "/Library/Application Support/libvirtualhid" +/usr/bin/sudo /bin/chmod 700 "/Library/Application Support/libvirtualhid" +/usr/bin/sudo /bin/launchctl bootout system/dev.lizardbyte.app.libvirtualhid 2>/dev/null || true +/usr/bin/sudo /bin/rm -rf "${installed_app}" +/usr/bin/sudo /usr/bin/ditto "${broker_app}" "${installed_app}" +/usr/bin/sudo /usr/sbin/chown -R root:wheel "${installed_app}" +/usr/bin/sudo /bin/chmod -R go-w "${installed_app}" +/usr/bin/sudo /usr/bin/codesign --verify --deep --strict "${installed_app}" +/usr/bin/sudo /usr/bin/install -m 0644 \ + "${image_root}/dev.lizardbyte.app.libvirtualhid.plist" "${service_plist}" +/usr/bin/sudo /usr/sbin/chown root:wheel "${service_plist}" + +if [[ -d "${image_root}/usr/local/include/libvirtualhid" ]]; then + /usr/bin/sudo /usr/bin/ditto "${image_root}/usr/local/include/libvirtualhid" /usr/local/include/libvirtualhid +fi +if [[ -f "${image_root}/usr/local/lib/libvirtualhid.a" ]]; then + /usr/bin/sudo /usr/bin/install -m 0644 "${image_root}/usr/local/lib/libvirtualhid.a" /usr/local/lib/libvirtualhid.a +fi +if [[ -f "${image_root}/usr/local/bin/libvirtualhid-license" ]]; then + /usr/bin/sudo /bin/mkdir -p /usr/local/bin + /usr/bin/sudo /usr/bin/install -m 0755 "${image_root}/usr/local/bin/libvirtualhid-license" /usr/local/bin/libvirtualhid-license +fi +if [[ -d "${image_root}/usr/local/lib/cmake/libvirtualhid" ]]; then + /usr/bin/sudo /bin/mkdir -p /usr/local/lib/cmake + /usr/bin/sudo /usr/bin/ditto "${image_root}/usr/local/lib/cmake/libvirtualhid" /usr/local/lib/cmake/libvirtualhid +fi +if [[ -d "${image_root}/usr/local/share/licenses/libvirtualhid" ]]; then + /usr/bin/sudo /usr/bin/ditto "${image_root}/usr/local/share/licenses/libvirtualhid" /usr/local/share/licenses/libvirtualhid +fi + +/usr/bin/sudo /bin/launchctl bootstrap system "${service_plist}" +/usr/bin/sudo /bin/launchctl kickstart -k system/dev.lizardbyte.app.libvirtualhid +echo "libvirtualhid broker installed. Run: /usr/local/bin/libvirtualhid-license activate" diff --git a/scripts/macos/package-dmg.sh b/scripts/macos/package-dmg.sh new file mode 100755 index 00000000..bd7226b7 --- /dev/null +++ b/scripts/macos/package-dmg.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# Build one universal macOS disk image. The release path requires an Apple-granted +# virtual HID entitlement in a Developer ID provisioning profile. +set -euo pipefail + +repository_root="$(cd "$(dirname "$0")/../.." && pwd)" +build_directory="${1:-${repository_root}/cmake-build-macos-universal}" +output_directory="${2:-${repository_root}/cmake-build-macos-universal/artifacts}" +build_directory="$(cd "${build_directory}" && pwd)" +mkdir -p "${output_directory}" +output_directory="$(cd "${output_directory}" && pwd)" +stage_directory="${build_directory}/macos-dmg-stage" +image_root="${stage_directory}/image" +broker_app="${image_root}/usr/local/libexec/libvirtualhid/VirtualHIDBroker.app" +profile_path="${APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE:-}" +signing_identity="${APPLE_CODESIGN_IDENTITY:-}" + +if [[ -z "${signing_identity}" || -z "${profile_path}" || ! -f "${profile_path}" || + -z "${APPLE_ID:-}" || -z "${APPLE_TEAM_ID:-}" || -z "${APPLE_NOTARYTOOL_PASSWORD:-}" ]]; then + echo "A Developer ID identity, approved virtual HID profile, and Sunshine notarization credentials are required." >&2 + exit 1 +fi + +rm -rf "${stage_directory}" +mkdir -p "${image_root}" +DESTDIR="${image_root}" cmake --install "${build_directory}" --prefix /usr/local + +profile_details="${stage_directory}/profile.plist" +/usr/bin/security cms -D -i "${profile_path}" > "${profile_details}" +/usr/bin/python3 - "${profile_details}" "${APPLE_TEAM_ID}" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], "rb") as profile_file: + profile = plistlib.load(profile_file) +entitlements = profile.get("Entitlements", {}) +app_id = entitlements.get("com.apple.application-identifier", "") +if app_id != f"{sys.argv[2]}.dev.lizardbyte.app.libvirtualhid": + raise SystemExit("Provisioning profile has the wrong App ID") +if sys.argv[2] not in profile.get("TeamIdentifier", []): + raise SystemExit("Provisioning profile belongs to another Apple team") +if entitlements.get("com.apple.developer.hid.virtual.device") is not True: + raise SystemExit("Provisioning profile lacks Apple's virtual HID entitlement") +PY + +cp "${profile_path}" "${broker_app}/Contents/embedded.provisionprofile" +/usr/bin/codesign --force --timestamp --options runtime \ + --sign "${signing_identity}" \ + --entitlements "${repository_root}/src/platform/macos/broker/entitlements.plist" \ + "${broker_app}" +/usr/bin/codesign --verify --deep --strict --verbose=2 "${broker_app}" +/usr/bin/codesign --force --timestamp --options runtime \ + --sign "${signing_identity}" "${image_root}/usr/local/bin/libvirtualhid-license" +/usr/bin/codesign --verify --strict --verbose=2 \ + "${image_root}/usr/local/bin/libvirtualhid-license" + +cp "${repository_root}/scripts/macos/install.command" "${image_root}/Install libvirtualhid.command" +cp "${repository_root}/scripts/macos/dev.lizardbyte.app.libvirtualhid.plist" "${image_root}/" +cp "${repository_root}/LICENSE.md" "${image_root}/" +cp -R "${repository_root}/LICENSES" "${image_root}/" + +output_image="${output_directory}/libvirtualhid-macOS-universal.dmg" +/usr/bin/hdiutil create -volname libvirtualhid -srcfolder "${image_root}" \ + -format UDZO -ov "${output_image}" + +xcrun notarytool submit "${output_image}" \ + --apple-id "${APPLE_ID}" \ + --team-id "${APPLE_TEAM_ID}" \ + --password "${APPLE_NOTARYTOOL_PASSWORD}" \ + --wait +xcrun stapler staple "${output_image}" + +echo "${output_image}" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f242a229..c5de376a 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -66,7 +66,7 @@ elseif(WIN32) elseif(APPLE) target_sources(${PROJECT_NAME} PRIVATE - "${CMAKE_CURRENT_SOURCE_DIR}/platform/license_unavailable.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/platform/macos/macos_broker_client.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/platform/macos/macos_backend.cpp") target_link_libraries(${PROJECT_NAME} PRIVATE diff --git a/src/include/libvirtualhid/license.hpp b/src/include/libvirtualhid/license.hpp index c95d9f91..c33bd79b 100644 --- a/src/include/libvirtualhid/license.hpp +++ b/src/include/libvirtualhid/license.hpp @@ -80,7 +80,8 @@ namespace lvh { * * The license key is sent directly to the platform license service. The library does not * persist a copy or expose it in the returned status. - * On Windows, authenticated local clients can activate or replace a license without elevation. + * On Windows and macOS, authenticated local clients can activate or replace a + * machine license without elevation. * * @param license_key License key supplied by the customer. * @param instance_name Optional customer-visible name for this machine activation. @@ -98,7 +99,8 @@ namespace lvh { /** * @brief Deactivate the stored license from this machine. * - * On Windows, authenticated local clients can deactivate a license without elevation. + * On Windows and macOS, authenticated local clients can deactivate a license + * without elevation. * * @return Deactivation result and latest license details. */ diff --git a/src/platform/macos/broker/CMakeLists.txt b/src/platform/macos/broker/CMakeLists.txt new file mode 100644 index 00000000..0dc30420 --- /dev/null +++ b/src/platform/macos/broker/CMakeLists.txt @@ -0,0 +1,59 @@ +if(NOT APPLE) + message(FATAL_ERROR "The macOS broker can only be built on macOS.") +endif() + +set(LIBVIRTUALHID_MACOS_BUNDLE_VERSION "0.0.0") +if(PROJECT_VERSION MATCHES "^([0-9]+)\\.([0-9]+)\\.([0-9]+)") + set(LIBVIRTUALHID_MACOS_BUNDLE_VERSION + "${CMAKE_MATCH_1}.${CMAKE_MATCH_2}.${CMAKE_MATCH_3}") +endif() +configure_file("${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" + "${CMAKE_CURRENT_BINARY_DIR}/Info.plist" @ONLY) + +add_executable(libvirtualhid_macos_broker MACOSX_BUNDLE + "${CMAKE_CURRENT_SOURCE_DIR}/libvirtualhid_macos_broker.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/license_manager.mm") +target_compile_features(libvirtualhid_macos_broker PRIVATE cxx_std_23) +set_target_properties(libvirtualhid_macos_broker PROPERTIES + OUTPUT_NAME VirtualHIDBroker + OBJCXX_STANDARD 23 + OBJCXX_STANDARD_REQUIRED ON + OBJCXX_EXTENSIONS OFF + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/Info.plist") +target_include_directories(libvirtualhid_macos_broker PRIVATE + "${PROJECT_SOURCE_DIR}/src" + "${PROJECT_SOURCE_DIR}/src/include") +target_compile_options(libvirtualhid_macos_broker PRIVATE + -Wall -Wextra -Wpedantic -fblocks) +set_source_files_properties("${CMAKE_CURRENT_SOURCE_DIR}/license_manager.mm" + PROPERTIES COMPILE_OPTIONS "-fobjc-arc") +if(LIBVIRTUALHID_WARNINGS_AS_ERRORS) + target_compile_options(libvirtualhid_macos_broker PRIVATE -Werror) +endif() +target_link_libraries(libvirtualhid_macos_broker PRIVATE + "-framework CoreFoundation" + "-framework Foundation" + "-framework IOKit") + +add_executable(libvirtualhid_macos_license + "${CMAKE_CURRENT_SOURCE_DIR}/license_cli.cpp") +set_target_properties(libvirtualhid_macos_license PROPERTIES + OUTPUT_NAME libvirtualhid-license) +target_compile_features(libvirtualhid_macos_license PRIVATE cxx_std_23) +target_compile_options(libvirtualhid_macos_license PRIVATE -Wall -Wextra -Wpedantic) +if(LIBVIRTUALHID_WARNINGS_AS_ERRORS) + target_compile_options(libvirtualhid_macos_license PRIVATE -Werror) +endif() +target_link_libraries(libvirtualhid_macos_license PRIVATE libvirtualhid::libvirtualhid) + +if(LIBVIRTUALHID_INSTALL) + install(TARGETS libvirtualhid_macos_broker + BUNDLE DESTINATION "${CMAKE_INSTALL_LIBEXECDIR}/libvirtualhid" + COMPONENT broker) + install(TARGETS libvirtualhid_macos_license + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}" + COMPONENT broker) + install(FILES "${PROJECT_SOURCE_DIR}/LICENSES/LicenseRef-LizardByte-SAL-1.0.md" + DESTINATION "${CMAKE_INSTALL_DATADIR}/licenses/libvirtualhid" + COMPONENT broker) +endif() diff --git a/src/platform/macos/broker/Info.plist b/src/platform/macos/broker/Info.plist new file mode 100644 index 00000000..cc6802ea --- /dev/null +++ b/src/platform/macos/broker/Info.plist @@ -0,0 +1,15 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableVirtualHIDBroker + CFBundleIdentifierdev.lizardbyte.app.libvirtualhid + CFBundleInfoDictionaryVersion6.0 + CFBundleNameVirtualHIDBroker + CFBundlePackageTypeAPPL + CFBundleShortVersionString@LIBVIRTUALHID_MACOS_BUNDLE_VERSION@ + CFBundleVersion@LIBVIRTUALHID_MACOS_BUNDLE_VERSION@ + LSBackgroundOnly + + diff --git a/src/platform/macos/broker/entitlements.plist b/src/platform/macos/broker/entitlements.plist new file mode 100644 index 00000000..eb811ce9 --- /dev/null +++ b/src/platform/macos/broker/entitlements.plist @@ -0,0 +1,7 @@ + + + + + com.apple.developer.hid.virtual.device + + diff --git a/src/platform/macos/broker/io.hpp b/src/platform/macos/broker/io.hpp new file mode 100644 index 00000000..5cf95970 --- /dev/null +++ b/src/platform/macos/broker/io.hpp @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +#pragma once + +#include "protocol.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::macos_broker { + + inline bool transfer(int fd, void *buffer, std::size_t size, bool sending) { + auto *bytes = static_cast(buffer); + while (size != 0) { + const auto count = sending ? + ::send(fd, bytes, size, 0) : + ::recv(fd, bytes, size, 0); + if (count < 0 && errno == EINTR) { + continue; + } + if (count <= 0) { + return false; + } + bytes += count; + size -= static_cast(count); + } + return true; + } + + inline bool send_message(int fd, const Message &message) { + return transfer(fd, const_cast(&message), sizeof(message), true); + } + + inline bool receive_message(int fd, Message &message) { + return transfer(fd, &message, sizeof(message), false) && message.version == protocol_version; + } + + inline int connect_to_broker(std::string &error) { + struct stat directory_stat {}; + if (::lstat("/var/run/libvirtualhid", &directory_stat) != 0 || + !S_ISDIR(directory_stat.st_mode) || directory_stat.st_uid != 0 || + (directory_stat.st_mode & 0022) != 0) { + error = "macOS broker directory is missing or insecure"; + return -1; + } + + struct stat socket_stat {}; + if (::lstat(socket_path, &socket_stat) != 0 || !S_ISSOCK(socket_stat.st_mode) || socket_stat.st_uid != 0) { + error = "installed macOS broker socket is missing or untrusted"; + return -1; + } + + const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + error = std::strerror(errno); + return -1; + } + const int no_sigpipe = 1; + if (::setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, sizeof(no_sigpipe)) != 0) { + error = std::strerror(errno); + ::close(fd); + return -1; + } + timeval send_timeout {.tv_sec = 5, .tv_usec = 0}; + timeval receive_timeout {.tv_sec = 30, .tv_usec = 0}; + static_cast(::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &send_timeout, sizeof(send_timeout))); + static_cast(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &receive_timeout, sizeof(receive_timeout))); + sockaddr_un address {}; + address.sun_family = AF_UNIX; + std::strncpy(address.sun_path, socket_path, sizeof(address.sun_path) - 1U); + if (::connect(fd, reinterpret_cast(&address), sizeof(address)) != 0) { + error = std::strerror(errno); + ::close(fd); + return -1; + } + + uid_t peer_uid = static_cast(-1); + gid_t peer_gid = static_cast(-1); + if (::getpeereid(fd, &peer_uid, &peer_gid) != 0 || peer_uid != 0) { + error = "macOS broker peer is not root"; + ::close(fd); + return -1; + } + return fd; + } + +} // namespace lvh::detail::macos_broker diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp new file mode 100644 index 00000000..340372e1 --- /dev/null +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -0,0 +1,417 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/macos/broker/libvirtualhid_macos_broker.cpp + * @brief Root-owned licensed macOS virtual HID service. + */ + +#include "io.hpp" +#include "license_manager.hpp" +#include "shared/playstation_feature_reports.hpp" +#include "shared/switch_pro_protocol.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::macos_broker { + namespace { + + namespace ps = playstation_feature_reports; + + template + void set_text(std::array &destination, const char *text) { + std::strncpy(destination.data(), text, destination.size() - 1U); + } + + Message response_with_error(lvh::ErrorCode code, const char *message) { + Message response; + response.type = MessageType::response; + response.status = static_cast(code); + set_text(response.message, message); + return response; + } + + bool terminated(const std::array &text) { + return std::memchr(text.data(), '\0', text.size()) != nullptr; + } + + void set_property(CFMutableDictionaryRef properties, CFStringRef key, const char *value) { + CFStringRef string = CFStringCreateWithCString(kCFAllocatorDefault, value, kCFStringEncodingUTF8); + if (string) { + CFDictionarySetValue(properties, key, string); + CFRelease(string); + } + } + + void set_number(CFMutableDictionaryRef properties, CFStringRef key, std::uint32_t value) { + CFNumberRef number = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &value); + if (number) { + CFDictionarySetValue(properties, key, number); + CFRelease(number); + } + } + + std::uint32_t crc32(std::span bytes, std::uint8_t seed) { + std::uint32_t crc = 0xFFFFFFFFU; + auto advance = [&crc](std::uint8_t value) { + crc ^= value; + for (int bit = 0; bit < 8; ++bit) { + crc = crc & 1U ? (crc >> 1U) ^ 0xEDB88320U : crc >> 1U; + } + }; + advance(seed); + for (const auto byte : bytes) { + advance(byte); + } + return ~crc; + } + + std::array mac_address(const Message &request) { + std::array mac {}; + unsigned bytes[6] {}; + if (std::sscanf(request.stable_id.data(), "%2x:%2x:%2x:%2x:%2x:%2x", &bytes[0], &bytes[1], &bytes[2], &bytes[3], &bytes[4], &bytes[5]) == 6) { + for (std::size_t index = 0; index < mac.size(); ++index) { + mac[index] = static_cast(bytes[index]); + } + return mac; + } + std::uint32_t hash = 2166136261U; + for (const char *value = request.stable_id.data(); *value; ++value) { + hash = (hash ^ static_cast(*value)) * 16777619U; + } + return {0x02, 0x00, static_cast(hash >> 24U), static_cast(hash >> 16U), static_cast(hash >> 8U), static_cast(hash)}; + } + + std::vector feature_report(const Message &request, std::uint32_t report_id) { + using lvh::GamepadProfileKind; + const auto kind = static_cast(request.kind); + auto copy = [](auto payload) { + return std::vector {payload.begin(), payload.end()}; + }; + std::vector result; + if (kind == GamepadProfileKind::dualshock4) { + switch (report_id) { + case ps::dualshock4_usb_calibration_report: + result = copy(ps::dualshock4_usb_calibration_info); + break; + case ps::dualshock4_bluetooth_calibration_report: + if (request.bus == static_cast(lvh::BusType::bluetooth)) { + result = copy(ps::dualshock4_bluetooth_calibration_info); + } + break; + case ps::dualshock4_pairing_report: + result = copy(ps::dualshock4_pairing_info); + break; + case ps::dualshock4_firmware_report: + result = copy(ps::dualshock4_firmware_info); + break; + default: + break; + } + } else if (kind == GamepadProfileKind::dualsense) { + switch (report_id) { + case ps::dualsense_calibration_report: + result = copy(ps::dualsense_calibration_info); + break; + case ps::dualsense_pairing_report: + result = copy(ps::dualsense_pairing_info); + break; + case ps::dualsense_firmware_report: + result = copy(ps::dualsense_firmware_info); + break; + default: + break; + } + } + if (!result.empty() && + ((report_id == ps::dualshock4_pairing_report && kind == GamepadProfileKind::dualshock4) || + (report_id == ps::dualsense_pairing_report && kind == GamepadProfileKind::dualsense))) { + const auto mac = mac_address(request); + for (std::size_t index = 0; index < mac.size(); ++index) { + result[1U + index] = mac[mac.size() - 1U - index]; + } + } + if (!result.empty() && + request.bus == static_cast(lvh::BusType::bluetooth) && + result.size() >= 4U) { + const auto value = crc32(std::span {result.data(), result.size() - 4U}, ps::playstation_feature_crc_seed); + for (std::size_t index = 0; index < 4U; ++index) { + result[result.size() - 4U + index] = static_cast(value >> (8U * index)); + } + } + return result; + } + + struct DeviceSession { + int fd = -1; + IOHIDUserDeviceRef device = nullptr; + std::mutex writer; + std::atomic open {true}; + + bool send(const Message &message) { + std::lock_guard lock {writer}; + if (!open.load()) { + return false; + } + if (!send_message(fd, message)) { + open = false; + ::shutdown(fd, SHUT_RDWR); + return false; + } + return true; + } + + void output(const std::uint8_t *data, std::size_t size, std::uint32_t report_id, const Message &profile) { + if (size == 0 || size > max_report_size) { + return; + } + Message event; + event.type = MessageType::output; + if (report_id != 0 && data[0] != report_id) { + event.data[0] = static_cast(report_id); + ++event.size; + } + if (event.size + size > max_report_size) { + return; + } + std::copy_n(data, size, event.data.begin() + event.size); + event.size += static_cast(size); + static_cast(send(event)); + + if (profile.kind == static_cast(lvh::GamepadProfileKind::switch_pro)) { + const auto reply = switch_pro_protocol::make_switch_pro_reply(std::span {event.data.data(), event.size}); + if (reply) { + static_cast(IOHIDUserDeviceHandleReportWithTimeStamp(device, mach_absolute_time(), reply->data(), reply->size())); + } + } + } + }; + + IOHIDUserDeviceRef create_device(const Message &request, DeviceSession &session, dispatch_semaphore_t cancelled) { + CFMutableDictionaryRef properties = CFDictionaryCreateMutable( + kCFAllocatorDefault, + 0, + &kCFTypeDictionaryKeyCallBacks, + &kCFTypeDictionaryValueCallBacks + ); + if (!properties) { + return nullptr; + } + CFDataRef descriptor = CFDataCreate(kCFAllocatorDefault, request.data.data(), request.descriptor_size); + if (!descriptor) { + CFRelease(properties); + return nullptr; + } + CFDictionarySetValue(properties, CFSTR(kIOHIDReportDescriptorKey), descriptor); + CFRelease(descriptor); + set_property(properties, CFSTR(kIOHIDProductKey), request.name.data()); + set_property(properties, CFSTR(kIOHIDManufacturerKey), request.manufacturer.data()); + set_property(properties, CFSTR(kIOHIDTransportKey), request.bus == static_cast(lvh::BusType::bluetooth) ? kIOHIDTransportBluetoothValue : kIOHIDTransportUSBValue); + set_property(properties, CFSTR(kIOHIDSerialNumberKey), request.stable_id.data()); + set_number(properties, CFSTR(kIOHIDVendorIDKey), request.vendor_id); + set_number(properties, CFSTR(kIOHIDProductIDKey), request.product_id); + set_number(properties, CFSTR(kIOHIDVersionNumberKey), request.device_version); + IOHIDUserDeviceRef device = IOHIDUserDeviceCreateWithProperties( + kCFAllocatorDefault, + properties, + IOHIDUserDeviceOptionsCreateOnActivate + ); + CFRelease(properties); + if (!device) { + return nullptr; + } + session.device = device; + DeviceSession *session_ptr = &session; + const Message profile = request; + IOHIDUserDeviceRegisterSetReportBlock(device, ^IOReturn(IOHIDReportType type, uint32_t report_id, const uint8_t *data, CFIndex size) { + if (type != kIOHIDReportTypeOutput || size < 0 || (size > 0 && !data)) { + return kIOReturnUnsupported; + } + session_ptr->output(data, static_cast(size), report_id, profile); + return kIOReturnSuccess; + }); + IOHIDUserDeviceRegisterGetReportBlock(device, ^IOReturn(IOHIDReportType type, uint32_t report_id, uint8_t *data, CFIndex *size) { + if (type != kIOHIDReportTypeFeature || !data || !size || *size < 0) { + return kIOReturnUnsupported; + } + const auto report = feature_report(profile, report_id); + if (report.empty() || report.size() > static_cast(*size)) { + return kIOReturnBadArgument; + } + std::copy(report.begin(), report.end(), data); + *size = static_cast(report.size()); + return kIOReturnSuccess; + }); + IOHIDUserDeviceSetDispatchQueue(device, dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0)); + IOHIDUserDeviceSetCancelHandler(device, ^{ + dispatch_semaphore_signal(cancelled); + }); + IOHIDUserDeviceActivate(device); + return device; + } + + void serve_client(int fd, LicenseManager &licenses) { + timeval receive_timeout {.tv_sec = 5, .tv_usec = 0}; + timeval send_timeout {.tv_sec = 5, .tv_usec = 0}; + static_cast(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &receive_timeout, sizeof(receive_timeout))); + static_cast(::setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &send_timeout, sizeof(send_timeout))); + Message request; + if (!receive_message(fd, request)) { + ::close(fd); + return; + } + if (request.type == MessageType::status || request.type == MessageType::activate || + request.type == MessageType::validate || request.type == MessageType::deactivate) { + const auto response = licenses.handle(request); + static_cast(send_message(fd, response)); + ::close(fd); + return; + } + if (request.type != MessageType::create || request.descriptor_size == 0 || + request.descriptor_size > max_descriptor_size || request.input_report_size == 0 || + request.input_report_size > max_report_size || request.output_report_size > max_report_size || + !terminated(request.name) || !terminated(request.manufacturer) || !terminated(request.stable_id) || + request.kind > static_cast(lvh::GamepadProfileKind::dualshock4) || + request.bus > static_cast(lvh::BusType::bluetooth)) { + static_cast(send_message(fd, response_with_error(lvh::ErrorCode::invalid_argument, "Invalid macOS gamepad request"))); + ::close(fd); + return; + } + Message response; + bool evaluation = false; + if (!licenses.authorize_create(response, evaluation)) { + static_cast(send_message(fd, response)); + ::close(fd); + return; + } + DeviceSession session; + session.fd = fd; + dispatch_semaphore_t cancelled = dispatch_semaphore_create(0); + const auto device = create_device(request, session, cancelled); + if (!device) { + static_cast(send_message(fd, response_with_error(lvh::ErrorCode::backend_failure, "Virtual HID creation failed; check the broker's Apple virtual HID entitlement and provisioning profile"))); + ::close(fd); + return; + } + licenses.add_device(evaluation); + response.type = MessageType::response; + response.status = 0; + static_cast(session.send(response)); + const auto expected_input_size = request.input_report_size; + + for (;;) { + pollfd descriptor {.fd = fd, .events = POLLIN, .revents = 0}; + const int polled = ::poll(&descriptor, 1, 1000); + if (!licenses.device_is_authorized(evaluation) || (polled < 0 && errno != EINTR)) { + break; + } + if (polled == 0 || polled < 0) { + continue; + } + if ((descriptor.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0 || !receive_message(fd, request)) { + break; + } + if (request.type == MessageType::close) { + break; + } + if (request.type != MessageType::submit || request.size != expected_input_size || + request.size > max_report_size) { + auto failure = response_with_error(lvh::ErrorCode::invalid_argument, "Invalid gamepad input report"); + static_cast(session.send(failure)); + continue; + } + const auto result = IOHIDUserDeviceHandleReportWithTimeStamp(device, mach_absolute_time(), request.data.data(), request.size); + auto submit_response = Message {}; + submit_response.type = MessageType::response; + if (result != kIOReturnSuccess) { + submit_response = response_with_error(lvh::ErrorCode::backend_failure, "macOS rejected virtual HID input report"); + } + if (!session.send(submit_response)) { + break; + } + } + session.open = false; + IOHIDUserDeviceCancel(device); + static_cast(dispatch_semaphore_wait(cancelled, DISPATCH_TIME_FOREVER)); + CFRelease(device); + licenses.remove_device(evaluation); + ::close(fd); + } + + } // namespace +} // namespace lvh::detail::macos_broker + +int main() { + using namespace lvh::detail::macos_broker; + if (::geteuid() != 0) { + return 1; + } + ::umask(0077); + ::signal(SIGPIPE, SIG_IGN); + if (::mkdir("/var/run/libvirtualhid", 0755) != 0 && errno != EEXIST) { + return 1; + } + struct stat directory {}; + if (::lstat("/var/run/libvirtualhid", &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || + ::chmod("/var/run/libvirtualhid", 0755) != 0) { + return 1; + } + const int lock_fd = ::open("/var/run/libvirtualhid/broker.lock", O_CREAT | O_RDWR | O_NOFOLLOW, 0600); + if (lock_fd < 0 || ::flock(lock_fd, LOCK_EX | LOCK_NB) != 0) { + return 1; + } + ::unlink(socket_path); + const int listener = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (listener < 0) { + return 1; + } + sockaddr_un address {}; + address.sun_family = AF_UNIX; + std::strncpy(address.sun_path, socket_path, sizeof(address.sun_path) - 1U); + if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || + ::chmod(socket_path, 0666) != 0 || ::listen(listener, 32) != 0) { + return 1; + } + LicenseManager licenses; + while (true) { + const int client = ::accept(listener, nullptr, nullptr); + if (client < 0) { + if (errno == EINTR) { + continue; + } + break; + } + uid_t uid = static_cast(-1); + gid_t gid = static_cast(-1); + if (::getpeereid(client, &uid, &gid) != 0) { + ::close(client); + continue; + } + std::thread {[client, &licenses] { + serve_client(client, licenses); + }}.detach(); + } + ::close(listener); + return 1; +} diff --git a/src/platform/macos/broker/license_cli.cpp b/src/platform/macos/broker/license_cli.cpp new file mode 100644 index 00000000..2849b0a1 --- /dev/null +++ b/src/platform/macos/broker/license_cli.cpp @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/macos/broker/license_cli.cpp + * @brief macOS command-line interface for machine license management. + */ + +#include +#include +#include +#include +#include +#include +#include + +namespace { + + void print_status(const lvh::LicenseResult &result) { + const auto &license = result.license; + std::cout << license.message << '\n'; + if (!license.plan_name.empty()) { + std::cout << "Plan: " << license.plan_name << '\n'; + } + if (!license.customer_email.empty()) { + std::cout << "Customer: " << license.customer_email << '\n'; + } + std::cout << "Active gamepads: " << license.active_devices << '\n'; + if (!license.purchase_url.empty()) { + std::cout << "Purchase: " << license.purchase_url << '\n'; + } + if (!license.manage_account_url.empty()) { + std::cout << "Manage: " << license.manage_account_url << '\n'; + } + if (!result.status.ok() && license.message != result.status.message()) { + std::cerr << result.status.message() << '\n'; + } + } + + std::string read_key() { + termios previous {}; + const bool terminal = ::isatty(STDIN_FILENO) && ::tcgetattr(STDIN_FILENO, &previous) == 0; + if (terminal) { + auto no_echo = previous; + no_echo.c_lflag &= static_cast(~ECHO); + static_cast(::tcsetattr(STDIN_FILENO, TCSAFLUSH, &no_echo)); + std::cout << "License key: " << std::flush; + } + std::string key; + std::getline(std::cin, key); + if (terminal) { + static_cast(::tcsetattr(STDIN_FILENO, TCSAFLUSH, &previous)); + std::cout << '\n'; + } + return key; + } + +} // namespace + +int main(int argc, char **argv) { + if (argc > 3) { + std::cerr << "Usage: libvirtualhid-license [status|activate [machine-name]|validate|deactivate]\n"; + return 2; + } + const auto action = argc > 1 ? std::string_view {argv[1]} : std::string_view {"status"}; + lvh::LicenseResult result; + if (action == "status" && argc <= 2) { + result = lvh::get_license_status(); + } else if (action == "activate") { + auto key = read_key(); + result = lvh::activate_license(key, argc == 3 ? argv[2] : ""); + std::fill(key.begin(), key.end(), '\0'); + } else if (action == "validate" && argc == 2) { + result = lvh::validate_license(); + } else if (action == "deactivate" && argc == 2) { + result = lvh::deactivate_license(); + } else { + std::cerr << "Usage: libvirtualhid-license [status|activate [machine-name]|validate|deactivate]\n"; + return 2; + } + print_status(result); + return result.status.ok() ? 0 : 1; +} diff --git a/src/platform/macos/broker/license_manager.hpp b/src/platform/macos/broker/license_manager.hpp new file mode 100644 index 00000000..7d2a17f4 --- /dev/null +++ b/src/platform/macos/broker/license_manager.hpp @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +#pragma once + +#include "protocol.hpp" + +#include +#include +#include +#include +#include +#include + +namespace lvh::detail::macos_broker { + + class LicenseManager { + public: + LicenseManager(); + ~LicenseManager(); + LicenseManager(const LicenseManager &) = delete; + LicenseManager &operator=(const LicenseManager &) = delete; + + Message handle(const Message &request); + bool authorize_create(Message &response, bool &evaluation); + bool device_is_authorized(bool evaluation); + void add_device(bool evaluation); + void remove_device(bool evaluation); + + private: + struct State { + std::string key; + std::string activation_id; + std::string status; + std::string organization_id; + std::string benefit_id; + std::string customer_email; + std::uint32_t activation_limit = 0; + }; + + Message activate(const Message &request); + Message validate(); + Message deactivate(); + Message status(); + bool licensed_locked() const; + bool yearly_locked() const; + void fill_status_locked(Message &response) const; + void background_validation(std::stop_token stop); + + std::mutex operation_mutex_; + std::mutex mutex_; + std::optional state_; + std::optional validated_at_; + std::optional unavailable_since_; + std::optional evaluation_started_at_; + std::uint32_t active_devices_ = 0; + std::uint32_t active_licensed_devices_ = 0; + bool github_actions_ = false; + bool online_confirmed_ = false; + std::jthread validator_; + }; + +} // namespace lvh::detail::macos_broker diff --git a/src/platform/macos/broker/license_manager.mm b/src/platform/macos/broker/license_manager.mm new file mode 100644 index 00000000..7428da18 --- /dev/null +++ b/src/platform/macos/broker/license_manager.mm @@ -0,0 +1,594 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/macos/broker/license_manager.mm + * @brief Machine-scoped Polar licensing for the macOS broker. + */ + +#include "license_manager.hpp" + +#include "platform/windows/shared/lvh_windows_broker_config.hpp" + +#include +#include +#include +#import +#include +#include +#include +#include + +namespace lvh::detail::macos_broker { + namespace { + + constexpr auto state_directory = "/Library/Application Support/libvirtualhid"; + constexpr auto state_path = "/Library/Application Support/libvirtualhid/license.json"; + constexpr auto evaluation_path = "/Library/Application Support/libvirtualhid/evaluation.json"; + constexpr auto validation_interval = std::chrono::hours {24}; + constexpr auto subscription_max_age = std::chrono::hours {25}; + constexpr auto outage_retention = std::chrono::hours {1}; + constexpr auto evaluation_duration = std::chrono::minutes {5}; + + template + void set_text(std::array &destination, std::string_view value) { + const auto count = std::min(value.size(), destination.size() - 1U); + std::copy_n(value.begin(), count, destination.begin()); + destination[count] = '\0'; + } + + std::string from_ns(NSString *value) { + return value ? std::string {[value UTF8String]} : std::string {}; + } + + NSString *to_ns(std::string_view value) { + return [[NSString alloc] initWithBytes:value.data() length:value.size() encoding:NSUTF8StringEncoding]; + } + + NSString *json_string(NSDictionary *json, NSString *key) { + id value = json[key]; + return [value isKindOfClass:[NSString class]] ? value : nil; + } + + NSDictionary *read_protected_json(const char *path) { + struct stat directory {}; + struct stat info {}; + if (::lstat(state_directory, &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || + (directory.st_mode & 0077) != 0 || + ::lstat(path, &info) != 0 || !S_ISREG(info.st_mode) || info.st_uid != 0 || (info.st_mode & 0077) != 0) { + return nil; + } + NSData *data = [NSData dataWithContentsOfFile:@(path)]; + if (!data) { + return nil; + } + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [parsed isKindOfClass:[NSDictionary class]] ? parsed : nil; + } + + bool write_protected_json(const char *path, NSDictionary *json) { + NSError *error = nil; + if (![[NSFileManager defaultManager] createDirectoryAtPath:@(state_directory) + withIntermediateDirectories:YES + attributes:@{NSFilePosixPermissions: @0700} + error:&error]) { + return false; + } + struct stat directory {}; + if (::lstat(state_directory, &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || + ::chmod(state_directory, 0700) != 0) { + return false; + } + NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:&error]; + if (!data || ![data writeToFile:@(path) options:NSDataWritingAtomic error:&error]) { + return false; + } + return ::chmod(path, 0600) == 0; + } + + struct ApiResult { + bool transport_ok = false; + NSInteger status = 0; + bool trusted_time = false; + NSDictionary *body = nil; + std::string error; + }; + + ApiResult polar_request(NSString *endpoint, NSDictionary *body) { + ApiResult result; + @autoreleasepool { + NSError *json_error = nil; + NSData *payload = [NSJSONSerialization dataWithJSONObject:body options:0 error:&json_error]; + if (!payload) { + result.error = "Unable to encode license request"; + return result; + } + NSURL *url = [NSURL URLWithString:[@"https://api.polar.sh" stringByAppendingString:endpoint]]; + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:url]; + request.HTTPMethod = @"POST"; + request.HTTPBody = payload; + [request setValue:@"application/json" forHTTPHeaderField:@"Accept"]; + [request setValue:@"application/json" forHTTPHeaderField:@"Content-Type"]; + [request setValue:@"2026-04" forHTTPHeaderField:@"Polar-Version"]; + NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + configuration.timeoutIntervalForRequest = 15; + configuration.timeoutIntervalForResource = 20; + NSURLSession *session = [NSURLSession sessionWithConfiguration:configuration]; + dispatch_semaphore_t completed = dispatch_semaphore_create(0); + __block NSData *response_data = nil; + __block NSHTTPURLResponse *http_response = nil; + __block NSError *request_error = nil; + NSURLSessionDataTask *task = [session dataTaskWithRequest:request + completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) { + response_data = data; + http_response = [response isKindOfClass:[NSHTTPURLResponse class]] ? (NSHTTPURLResponse *) response : nil; + request_error = error; + dispatch_semaphore_signal(completed); + }]; + [task resume]; + if (dispatch_semaphore_wait(completed, dispatch_time(DISPATCH_TIME_NOW, 22 * NSEC_PER_SEC)) != 0) { + [task cancel]; + result.error = "License service timed out"; + [session invalidateAndCancel]; + return result; + } + [session finishTasksAndInvalidate]; + if (request_error || !http_response) { + result.error = request_error ? from_ns(request_error.localizedDescription) : "License service did not return HTTP"; + return result; + } + result.transport_ok = true; + result.status = http_response.statusCode; + for (id key in http_response.allHeaderFields) { + if ([key isKindOfClass:[NSString class]] && [key caseInsensitiveCompare:@"Date"] == NSOrderedSame) { + NSString *date_header = http_response.allHeaderFields[key]; + NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + formatter.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + formatter.timeZone = [NSTimeZone timeZoneForSecondsFromGMT:0]; + formatter.dateFormat = @"EEE, dd MMM yyyy HH:mm:ss zzz"; + result.trusted_time = [formatter dateFromString:date_header] != nil; + break; + } + } + if (response_data.length != 0) { + id parsed = [NSJSONSerialization JSONObjectWithData:response_data options:0 error:nil]; + if ([parsed isKindOfClass:[NSDictionary class]]) { + result.body = parsed; + } + } + if (result.status >= 400) { + NSString *detail = json_string(result.body, @"detail"); + NSString *error = json_string(result.body, @"error"); + result.error = from_ns(detail ? detail : error ? error : + @"License service rejected the request"); + } + } + return result; + } + + bool allowed_benefit(std::string_view benefit_id, bool &yearly) { + for (const auto &benefit : windows::broker_config::allowed_benefits) { + if (benefit.id == benefit_id) { + yearly = benefit.subscription_backed; + return true; + } + } + return false; + } + + std::string plan_name(std::string_view benefit_id) { + for (const auto &benefit : windows::broker_config::allowed_benefits) { + if (benefit.id == benefit_id) { + return std::string {benefit.plan_name}; + } + } + return ""; + } + + bool valid_c_string(const std::array &value) { + return std::memchr(value.data(), '\0', value.size()) != nullptr; + } + + } // namespace + + LicenseManager::LicenseManager() { + @autoreleasepool { + github_actions_ = std::getenv("GITHUB_ACTIONS") != nullptr && + std::string_view {std::getenv("GITHUB_ACTIONS")} == "true"; + if (NSDictionary *saved = read_protected_json(state_path)) { + State state; + state.key = from_ns(json_string(saved, @"key")); + state.activation_id = from_ns(json_string(saved, @"activation_id")); + state.status = from_ns(json_string(saved, @"status")); + state.organization_id = from_ns(json_string(saved, @"organization_id")); + state.benefit_id = from_ns(json_string(saved, @"benefit_id")); + state.customer_email = from_ns(json_string(saved, @"customer_email")); + state.activation_limit = [saved[@"activation_limit"] unsignedIntValue]; + bool yearly = false; + if (!state.key.empty() && !state.activation_id.empty() && state.status == "granted" && + state.organization_id == windows::broker_config::polar_organization_id && + allowed_benefit(state.benefit_id, yearly)) { + state_ = std::move(state); + } + } + if (github_actions_) { + if (NSDictionary *saved = read_protected_json(evaluation_path)) { + const auto start = [saved[@"started_at"] doubleValue]; + if (start > 0) { + evaluation_started_at_ = std::chrono::system_clock::time_point { + std::chrono::milliseconds {static_cast(start * 1000)} + }; + } + } + } + } + if (state_) { + static_cast(validate()); + } + validator_ = std::jthread {[this](std::stop_token stop) { + background_validation(stop); + }}; + } + + LicenseManager::~LicenseManager() { + validator_.request_stop(); + } + + bool LicenseManager::yearly_locked() const { + bool yearly = false; + return state_ && allowed_benefit(state_->benefit_id, yearly) && yearly; + } + + bool LicenseManager::licensed_locked() const { + if (!state_ || state_->status != "granted" || + state_->organization_id != windows::broker_config::polar_organization_id) { + return false; + } + bool yearly = false; + if (!allowed_benefit(state_->benefit_id, yearly)) { + return false; + } + return !yearly || (validated_at_ && std::chrono::steady_clock::now() - *validated_at_ < subscription_max_age); + } + + void LicenseManager::fill_status_locked(Message &response) const { + response.active_devices = active_devices_; + if (!state_) { + response.license_state = static_cast(LicenseState::unlicensed); + set_text(response.plan_name, github_actions_ ? "GitHub Actions Evaluation" : "Unlicensed"); + return; + } + response.license_state = static_cast(licensed_locked() ? LicenseState::licensed : LicenseState::invalid); + response.activation_limit = state_->activation_limit; + response.activation_usage = 1; + set_text(response.plan_name, plan_name(state_->benefit_id)); + set_text(response.customer_email, state_->customer_email); + } + + Message LicenseManager::status() { + Message response; + response.type = MessageType::response; + std::lock_guard lock {mutex_}; + fill_status_locked(response); + set_text(response.message, licensed_locked() ? "Licensed." : state_ ? "License requires online validation." : + "An active license is required to create virtual HID devices."); + return response; + } + + Message LicenseManager::activate(const Message &request) { + if (!valid_c_string(request.license_key) || !valid_c_string(request.instance_name) || request.license_key[0] == '\0') { + auto response = status(); + response.status = static_cast(ErrorCode::invalid_argument); + set_text(response.message, "Invalid license key or instance name"); + return response; + } + std::lock_guard operation_lock {operation_mutex_}; + @autoreleasepool { + NSString *name = request.instance_name[0] ? @(request.instance_name.data()) : [[NSHost currentHost] localizedName]; + auto result = polar_request(@"/v1/customer-portal/license-keys/activate", @{@"key": @(request.license_key.data()), + @"organization_id": to_ns(windows::broker_config::polar_organization_id), + @"label": name ? name : @"Mac"}); + auto response = status(); + if (!result.transport_ok || result.status != 200) { + const auto code = !result.transport_ok ? ErrorCode::network_unavailable : + result.status == 403 ? ErrorCode::activation_limit_reached : + (result.status == 404 || result.status == 422) ? ErrorCode::license_invalid : + ErrorCode::backend_failure; + response.status = static_cast(code); + set_text(response.message, result.error.empty() ? "License activation failed" : result.error); + return response; + } + if (!result.body || !result.trusted_time) { + response.status = static_cast(ErrorCode::backend_failure); + set_text(response.message, "License activation response is missing state or trusted server time"); + return response; + } + NSString *activation_id = json_string(result.body, @"id"); + NSDictionary *license = result.body[@"license_key"]; + if (![license isKindOfClass:[NSDictionary class]]) { + response.status = static_cast(ErrorCode::backend_failure); + set_text(response.message, "License activation response is missing license state"); + return response; + } + State state; + state.key = request.license_key.data(); + state.activation_id = from_ns(activation_id); + state.status = from_ns(json_string(license, @"status")); + state.organization_id = from_ns(json_string(license, @"organization_id")); + state.benefit_id = from_ns(json_string(license, @"benefit_id")); + state.activation_limit = [license[@"limit_activations"] unsignedIntValue]; + NSDictionary *customer = license[@"customer"]; + if ([customer isKindOfClass:[NSDictionary class]]) { + state.customer_email = from_ns(json_string(customer, @"email")); + } + bool yearly = false; + if (state.activation_id.empty() || state.status != "granted" || + state.organization_id != windows::broker_config::polar_organization_id || + !allowed_benefit(state.benefit_id, yearly)) { + response.status = static_cast(ErrorCode::license_invalid); + set_text(response.message, "License organization, benefit, or activation is not allowed"); + return response; + } + if (!write_protected_json(state_path, @{@"key": to_ns(state.key), + @"activation_id": to_ns(state.activation_id), + @"status": to_ns(state.status), + @"organization_id": to_ns(state.organization_id), + @"benefit_id": to_ns(state.benefit_id), + @"customer_email": to_ns(state.customer_email), + @"activation_limit": @(state.activation_limit)})) { + response.status = static_cast(ErrorCode::backend_failure); + set_text(response.message, "Unable to securely save machine license"); + return response; + } + { + std::lock_guard lock {mutex_}; + state_ = std::move(state); + validated_at_ = std::chrono::steady_clock::now(); + unavailable_since_.reset(); + online_confirmed_ = true; + fill_status_locked(response); + } + set_text(response.message, "License activated on this machine."); + return response; + } + } + + Message LicenseManager::validate() { + std::lock_guard operation_lock {operation_mutex_}; + State state; + { + std::lock_guard lock {mutex_}; + if (!state_) { + auto response = Message {}; + response.type = MessageType::response; + response.status = static_cast(ErrorCode::license_required); + fill_status_locked(response); + set_text(response.message, "No license is activated on this machine."); + return response; + } + state = *state_; + } + @autoreleasepool { + auto result = polar_request(@"/v1/customer-portal/license-keys/validate", @ { + @"key": to_ns(state.key), + @"organization_id": to_ns(windows::broker_config::polar_organization_id), + @"activation_id": to_ns(state.activation_id) + }); + if (!result.transport_ok || result.status != 200 || !result.body || !result.trusted_time) { + { + std::lock_guard lock {mutex_}; + online_confirmed_ = false; + if (!unavailable_since_) { + unavailable_since_ = std::chrono::steady_clock::now(); + } + if (result.status == 404) { + state_.reset(); + validated_at_.reset(); + } + } + if (result.status == 404) { + [[NSFileManager defaultManager] removeItemAtPath:@(state_path) error:nil]; + } + auto response = status(); + response.status = static_cast(result.status == 404 ? ErrorCode::license_invalid : !result.transport_ok ? ErrorCode::network_unavailable : + ErrorCode::backend_failure); + set_text(response.message, result.error.empty() ? "License validation failed" : result.error); + return response; + } + NSDictionary *activation = result.body[@"activation"]; + NSString *activation_id = [activation isKindOfClass:[NSDictionary class]] ? json_string(activation, @"id") : nil; + const auto new_status = from_ns(json_string(result.body, @"status")); + const auto new_organization = from_ns(json_string(result.body, @"organization_id")); + const auto new_benefit = from_ns(json_string(result.body, @"benefit_id")); + bool yearly = false; + if (from_ns(activation_id) != state.activation_id || new_status != "granted" || + new_organization != windows::broker_config::polar_organization_id || + !allowed_benefit(new_benefit, yearly)) { + { + std::lock_guard lock {mutex_}; + state_.reset(); + validated_at_.reset(); + } + [[NSFileManager defaultManager] removeItemAtPath:@(state_path) error:nil]; + auto response = status(); + response.status = static_cast(ErrorCode::license_invalid); + set_text(response.message, "License is revoked, disabled, or has an invalid benefit"); + return response; + } + state.status = new_status; + state.benefit_id = new_benefit; + state.activation_limit = [result.body[@"limit_activations"] unsignedIntValue]; + NSDictionary *customer = result.body[@"customer"]; + if ([customer isKindOfClass:[NSDictionary class]]) { + state.customer_email = from_ns(json_string(customer, @"email")); + } + if (!write_protected_json(state_path, @{@"key": to_ns(state.key), + @"activation_id": to_ns(state.activation_id), + @"status": to_ns(state.status), + @"organization_id": to_ns(state.organization_id), + @"benefit_id": to_ns(state.benefit_id), + @"customer_email": to_ns(state.customer_email), + @"activation_limit": @(state.activation_limit)})) { + auto response = status(); + response.status = static_cast(ErrorCode::backend_failure); + set_text(response.message, "Unable to securely save validated license"); + return response; + } + { + std::lock_guard lock {mutex_}; + state_ = std::move(state); + validated_at_ = std::chrono::steady_clock::now(); + unavailable_since_.reset(); + online_confirmed_ = true; + } + auto response = status(); + set_text(response.message, "License validated."); + return response; + } + } + + Message LicenseManager::deactivate() { + std::lock_guard operation_lock {operation_mutex_}; + State state; + { + std::lock_guard lock {mutex_}; + if (!state_) { + auto response = Message {}; + response.type = MessageType::response; + fill_status_locked(response); + set_text(response.message, "No machine license is active."); + return response; + } + state = *state_; + } + @autoreleasepool { + auto result = polar_request(@"/v1/customer-portal/license-keys/deactivate", @ { + @"key": to_ns(state.key), + @"organization_id": to_ns(windows::broker_config::polar_organization_id), + @"activation_id": to_ns(state.activation_id) + }); + if (!result.transport_ok || (result.status != 204 && result.status != 404)) { + auto response = status(); + response.status = static_cast(!result.transport_ok ? ErrorCode::network_unavailable : ErrorCode::backend_failure); + set_text(response.message, result.error.empty() ? "License deactivation failed" : result.error); + return response; + } + [[NSFileManager defaultManager] removeItemAtPath:@(state_path) error:nil]; + { + std::lock_guard lock {mutex_}; + state_.reset(); + validated_at_.reset(); + online_confirmed_ = false; + } + auto response = status(); + set_text(response.message, "License deactivated on this machine."); + return response; + } + } + + Message LicenseManager::handle(const Message &request) { + switch (request.type) { + case MessageType::status: + return status(); + case MessageType::activate: + return activate(request); + case MessageType::validate: + return validate(); + case MessageType::deactivate: + return deactivate(); + default: + { + auto response = status(); + response.status = static_cast(ErrorCode::invalid_argument); + set_text(response.message, "Invalid license request"); + return response; + } + } + } + + bool LicenseManager::authorize_create(Message &response, bool &evaluation) { + response.type = MessageType::response; + std::lock_guard lock {mutex_}; + fill_status_locked(response); + if (licensed_locked()) { + if (online_confirmed_ || active_licensed_devices_ == 0) { + evaluation = false; + return true; + } + response.status = static_cast(ErrorCode::network_unavailable); + set_text(response.message, "Polar is unavailable; the one-device fallback is already in use"); + return false; + } + if (!state_ && github_actions_) { + const auto now = std::chrono::system_clock::now(); + if (!evaluation_started_at_) { + const auto seconds = std::chrono::duration(now.time_since_epoch()).count(); + bool saved = false; + @autoreleasepool { + saved = write_protected_json(evaluation_path, @{@"started_at": @(seconds)}); + } + if (!saved) { + response.status = static_cast(ErrorCode::backend_failure); + set_text(response.message, "Unable to save GitHub Actions evaluation state"); + return false; + } + evaluation_started_at_ = now; + } + if (now >= *evaluation_started_at_ && now < *evaluation_started_at_ + evaluation_duration) { + evaluation = true; + return true; + } + } + response.status = static_cast(state_ ? ErrorCode::license_invalid : ErrorCode::license_required); + set_text(response.message, state_ ? "License requires online validation" : "An active license is required"); + return false; + } + + bool LicenseManager::device_is_authorized(bool evaluation) { + std::lock_guard lock {mutex_}; + if (evaluation) { + const auto now = std::chrono::system_clock::now(); + return evaluation_started_at_ && now >= *evaluation_started_at_ && now < *evaluation_started_at_ + evaluation_duration; + } + if (!licensed_locked()) { + return false; + } + return !unavailable_since_ || std::chrono::steady_clock::now() - *unavailable_since_ < outage_retention; + } + + void LicenseManager::add_device(bool evaluation) { + std::lock_guard lock {mutex_}; + ++active_devices_; + if (!evaluation) { + ++active_licensed_devices_; + } + } + + void LicenseManager::remove_device(bool evaluation) { + std::lock_guard lock {mutex_}; + --active_devices_; + if (!evaluation) { + --active_licensed_devices_; + } + } + + void LicenseManager::background_validation(std::stop_token stop) { + while (!stop.stop_requested()) { + for (int minute = 0; minute < 60 && !stop.stop_requested(); ++minute) { + std::this_thread::sleep_for(std::chrono::seconds {1}); + } + if (stop.stop_requested()) { + break; + } + bool due = false; + { + std::lock_guard lock {mutex_}; + due = state_ && (!validated_at_ || std::chrono::steady_clock::now() - *validated_at_ >= validation_interval); + } + if (due) { + static_cast(validate()); + } + } + } + +} // namespace lvh::detail::macos_broker diff --git a/src/platform/macos/broker/protocol.hpp b/src/platform/macos/broker/protocol.hpp new file mode 100644 index 00000000..a76f94a1 --- /dev/null +++ b/src/platform/macos/broker/protocol.hpp @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/macos/broker/protocol.hpp + * @brief Private, versioned macOS broker wire protocol. + */ +#pragma once + +#include +#include + +namespace lvh::detail::macos_broker { + + inline constexpr std::uint32_t protocol_version = 1; + inline constexpr auto socket_path = "/var/run/libvirtualhid/broker.sock"; + inline constexpr std::size_t max_descriptor_size = 8192; + inline constexpr std::size_t max_report_size = 1024; + inline constexpr std::size_t max_text_size = 128; + + enum class MessageType : std::uint32_t { + status = 1, + activate, + validate, + deactivate, + create, + submit, + close, + output, + response, + }; + + struct Message { + std::uint32_t version = protocol_version; + MessageType type = MessageType::status; + std::int32_t status = 0; + std::uint32_t size = 0; + std::uint32_t kind = 0; + std::uint32_t bus = 0; + std::uint32_t vendor_id = 0; + std::uint32_t product_id = 0; + std::uint32_t device_version = 0; + std::uint32_t report_id = 0; + std::uint32_t input_report_size = 0; + std::uint32_t output_report_size = 0; + std::uint32_t descriptor_size = 0; + std::uint32_t active_devices = 0; + std::uint32_t activation_limit = 0; + std::uint32_t activation_usage = 0; + std::uint32_t license_state = 0; + std::array name {}; + std::array manufacturer {}; + std::array stable_id {}; + std::array license_key {}; + std::array instance_name {}; + std::array plan_name {}; + std::array customer_email {}; + std::array message {}; + std::array data {}; + }; + +} // namespace lvh::detail::macos_broker diff --git a/src/platform/macos/macos_backend.cpp b/src/platform/macos/macos_backend.cpp index c67ca893..87fe2ad0 100644 --- a/src/platform/macos/macos_backend.cpp +++ b/src/platform/macos/macos_backend.cpp @@ -22,6 +22,7 @@ // local includes #include "core/backend.hpp" +#include "platform/macos/macos_broker_client.hpp" namespace lvh::detail { namespace macos { @@ -777,7 +778,10 @@ namespace lvh::detail { class MacosBackend final: public Backend { public: MacosBackend() { - capabilities_.backend_name = "macos-coregraphics"; + capabilities_.backend_name = "macos-virtual-hid-coregraphics"; + capabilities_.supports_virtual_hid = true; + capabilities_.supports_gamepad = true; + capabilities_.supports_output_reports = true; capabilities_.supports_keyboard = true; capabilities_.supports_mouse = true; } @@ -786,30 +790,32 @@ namespace lvh::detail { return capabilities_; } - BackendGamepadCreationResult create_gamepad(DeviceId /*id*/, const CreateGamepadOptions & /*options*/) override { - return {OperationStatus::failure(ErrorCode::unsupported_profile, "macOS gamepad backend is not implemented"), nullptr}; + BackendGamepadCreationResult create_gamepad(DeviceId id, const CreateGamepadOptions &options) override { + return create_macos_brokered_gamepad(id, options); } BackendKeyboardCreationResult create_keyboard(DeviceId /*id*/, const CreateKeyboardOptions &options) override { if (options.profile.device_type != DeviceType::keyboard) { return {OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a keyboard"), nullptr}; } - if (!state_->keyboard_source) { + auto state = input_state(); + if (!state->keyboard_source) { return {OperationStatus::failure(ErrorCode::backend_failure, "macOS keyboard event source is unavailable"), nullptr}; } - return {OperationStatus::success(), std::make_unique(state_)}; + return {OperationStatus::success(), std::make_unique(std::move(state))}; } BackendMouseCreationResult create_mouse(DeviceId /*id*/, const CreateMouseOptions &options) override { if (options.profile.device_type != DeviceType::mouse) { return {OperationStatus::failure(ErrorCode::unsupported_profile, "device profile is not a mouse"), nullptr}; } - if (!state_->source || !state_->mouse_event) { + auto state = input_state(); + if (!state->source || !state->mouse_event) { return {OperationStatus::failure(ErrorCode::backend_failure, "macOS mouse event source is unavailable"), nullptr}; } - return {OperationStatus::success(), std::make_unique(state_)}; + return {OperationStatus::success(), std::make_unique(std::move(state))}; } BackendTouchscreenCreationResult create_touchscreen( @@ -831,8 +837,17 @@ namespace lvh::detail { } private: + std::shared_ptr input_state() { + std::lock_guard lock {state_mutex_}; + if (!state_) { + state_ = std::make_shared(); + } + return state_; + } + BackendCapabilities capabilities_; - std::shared_ptr state_ {std::make_shared()}; + std::mutex state_mutex_; + std::shared_ptr state_; }; } // namespace macos diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp new file mode 100644 index 00000000..d9084da4 --- /dev/null +++ b/src/platform/macos/macos_broker_client.cpp @@ -0,0 +1,333 @@ +/** + * @file src/platform/macos/macos_broker_client.cpp + * @brief Client side of the licensed macOS virtual HID broker. + */ + +#include "platform/macos/macos_broker_client.hpp" + +#include "platform/macos/broker/io.hpp" +#include "platform/windows/shared/lvh_windows_broker_config.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lvh::detail { + namespace { + + template + bool copy_text(std::array &destination, std::string_view text) { + if (text.size() >= destination.size()) { + return false; + } + std::copy(text.begin(), text.end(), destination.begin()); + return true; + } + + std::string message_text(const macos_broker::Message &message) { + return {message.message.data(), strnlen(message.message.data(), message.message.size())}; + } + + OperationStatus response_status(const macos_broker::Message &response) { + if (response.type != macos_broker::MessageType::response || + response.status < 0 || response.status > static_cast(ErrorCode::backend_failure)) { + return OperationStatus::failure(ErrorCode::backend_failure, "macOS broker returned an invalid response"); + } + if (response.status == 0) { + return OperationStatus::success(); + } + return OperationStatus::failure(static_cast(response.status), message_text(response)); + } + + class MacosGamepad final: public BackendGamepad { + public: + MacosGamepad(int fd, DeviceProfile profile): + fd_ {fd}, + profile_ {std::move(profile)}, + callback_state_ {std::make_shared()}, + reader_ {[this, fd] { + read_loop(fd); + }}, + callback_thread_ {[state = callback_state_, profile = profile_] { + callback_loop(state, profile); + }} {} + + ~MacosGamepad() override { + static_cast(close()); + } + + OperationStatus submit(const GamepadState & /*state*/, const std::vector &report) override { + if (report.empty() || report.size() > macos_broker::max_report_size) { + return OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad report exceeds broker limit"); + } + macos_broker::Message request; + request.type = macos_broker::MessageType::submit; + request.size = static_cast(report.size()); + std::copy(report.begin(), report.end(), request.data.begin()); + return call(request); + } + + void set_output_callback(OutputCallback callback) override { + std::lock_guard lock {callback_state_->mutex}; + callback_state_->callback = std::move(callback); + } + + OperationStatus close() override { + { + std::lock_guard lock {call_mutex_}; + if (fd_ < 0) { + return OperationStatus::success(); + } + macos_broker::Message request; + request.type = macos_broker::MessageType::close; + static_cast(macos_broker::send_message(fd_, request)); + ::shutdown(fd_, SHUT_RDWR); + ::close(fd_); + fd_ = -1; + } + if (reader_.joinable()) { + reader_.join(); + } + { + std::lock_guard lock {callback_state_->mutex}; + callback_state_->stop = true; + } + callback_state_->condition.notify_all(); + if (callback_thread_.joinable()) { + if (callback_thread_.get_id() == std::this_thread::get_id()) { + callback_thread_.detach(); + } else { + callback_thread_.join(); + } + } + return OperationStatus::success(); + } + + private: + struct CallbackState { + std::mutex mutex; + std::condition_variable condition; + std::deque> reports; + OutputCallback callback; + bool stop = false; + }; + + static void callback_loop(const std::shared_ptr &state, const DeviceProfile &profile) { + for (;;) { + std::vector report; + OutputCallback callback; + { + std::unique_lock lock {state->mutex}; + state->condition.wait(lock, [&] { + return state->stop || !state->reports.empty(); + }); + if (state->reports.empty()) { + return; + } + report = std::move(state->reports.front()); + state->reports.pop_front(); + callback = state->callback; + } + if (callback) { + for (const auto &output : reports::parse_output_reports(profile, report)) { + callback(output); + } + } + } + } + + OperationStatus call(const macos_broker::Message &request) { + std::lock_guard call_lock {call_mutex_}; + if (fd_ < 0) { + return OperationStatus::failure(ErrorCode::device_closed, "macOS gamepad is closed"); + } + { + std::lock_guard lock {mutex_}; + response_ready_ = false; + } + if (!macos_broker::send_message(fd_, request)) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker connection closed"); + } + std::unique_lock lock {mutex_}; + if (!response_condition_.wait_for(lock, std::chrono::seconds {10}, [this] { + return response_ready_ || disconnected_; + })) { + lock.unlock(); + ::shutdown(fd_, SHUT_RDWR); + return OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker did not answer gamepad report"); + } + if (!response_ready_) { + return OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker connection closed"); + } + return response_status(response_); + } + + void read_loop(int fd) { + macos_broker::Message event; + while (macos_broker::receive_message(fd, event)) { + if (event.type == macos_broker::MessageType::response) { + { + std::lock_guard lock {mutex_}; + response_ = event; + response_ready_ = true; + } + response_condition_.notify_all(); + } else if (event.type == macos_broker::MessageType::output && + event.size > 0 && event.size <= macos_broker::max_report_size) { + { + std::lock_guard lock {callback_state_->mutex}; + if (callback_state_->reports.size() < 64U) { + callback_state_->reports.emplace_back(event.data.begin(), event.data.begin() + event.size); + } + } + callback_state_->condition.notify_one(); + } + } + { + std::lock_guard lock {mutex_}; + disconnected_ = true; + } + response_condition_.notify_all(); + { + std::lock_guard lock {callback_state_->mutex}; + callback_state_->stop = true; + } + callback_state_->condition.notify_all(); + } + + int fd_; + DeviceProfile profile_; + std::shared_ptr callback_state_; + std::thread reader_; + std::thread callback_thread_; + std::mutex call_mutex_; + std::mutex mutex_; + std::condition_variable response_condition_; + macos_broker::Message response_; + bool response_ready_ = false; + bool disconnected_ = false; + }; + + LicenseResult license_call(macos_broker::Message request) { + LicenseResult result; + result.license.purchase_url = windows::broker_config::buy_url; + result.license.manage_account_url = windows::broker_config::manage_account_url; + std::string error; + const int fd = macos_broker::connect_to_broker(error); + if (fd < 0) { + result.status = OperationStatus::failure(ErrorCode::backend_unavailable, error); + result.license.message = error; + return result; + } + macos_broker::Message response; + const bool exchanged = macos_broker::send_message(fd, request) && macos_broker::receive_message(fd, response); + ::close(fd); + if (!exchanged) { + result.status = OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker did not answer"); + result.license.message = result.status.message(); + return result; + } + result.status = response_status(response); + result.license.service_available = true; + result.license.state = response.license_state <= static_cast(LicenseState::invalid) ? + static_cast(response.license_state) : + LicenseState::invalid; + result.license.active_devices = response.active_devices; + result.license.activation_limit = response.activation_limit; + result.license.activation_usage = response.activation_usage; + result.license.plan_name = response.plan_name.data(); + result.license.customer_email = response.customer_email.data(); + result.license.message = message_text(response); + return result; + } + + } // namespace + + BackendGamepadCreationResult create_macos_brokered_gamepad(DeviceId id, const CreateGamepadOptions &options) { + const auto &profile = options.profile; + if (profile.device_type != DeviceType::gamepad || profile.report_descriptor.empty() || + profile.report_descriptor.size() > macos_broker::max_descriptor_size || + profile.input_report_size == 0 || profile.input_report_size > macos_broker::max_report_size || + profile.output_report_size > macos_broker::max_report_size) { + return {OperationStatus::failure(ErrorCode::unsupported_profile, "macOS broker requires a valid gamepad HID descriptor and report sizes"), nullptr}; + } + macos_broker::Message request; + request.type = macos_broker::MessageType::create; + request.kind = static_cast(profile.gamepad_kind); + request.bus = static_cast(profile.bus_type); + request.vendor_id = profile.vendor_id; + request.product_id = profile.product_id; + request.device_version = profile.version; + request.report_id = profile.report_id; + request.input_report_size = static_cast(profile.input_report_size); + request.output_report_size = static_cast(profile.output_report_size); + request.descriptor_size = static_cast(profile.report_descriptor.size()); + if (options.metadata.stable_id.empty()) { + std::snprintf(request.stable_id.data(), request.stable_id.size(), "02:00:%02x:%02x:%02x:%02x", static_cast((id >> 24U) & 0xFFU), static_cast((id >> 16U) & 0xFFU), static_cast((id >> 8U) & 0xFFU), static_cast(id & 0xFFU)); + } + if (!copy_text(request.name, profile.name) || !copy_text(request.manufacturer, profile.manufacturer) || + (!options.metadata.stable_id.empty() && !copy_text(request.stable_id, options.metadata.stable_id))) { + return {OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad identity exceeds broker limit"), nullptr}; + } + std::copy(profile.report_descriptor.begin(), profile.report_descriptor.end(), request.data.begin()); + + std::string error; + const int fd = macos_broker::connect_to_broker(error); + if (fd < 0) { + return {OperationStatus::failure(ErrorCode::backend_unavailable, error), nullptr}; + } + macos_broker::Message response; + const bool exchanged = macos_broker::send_message(fd, request) && macos_broker::receive_message(fd, response); + if (!exchanged) { + ::close(fd); + return {OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker did not answer gamepad creation"), nullptr}; + } + const auto status = response_status(response); + if (!status.ok()) { + ::close(fd); + return {status, nullptr}; + } + timeval no_receive_timeout {.tv_sec = 0, .tv_usec = 0}; + static_cast(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &no_receive_timeout, sizeof(no_receive_timeout))); + return {OperationStatus::success(), std::make_unique(fd, profile)}; + } + +} // namespace lvh::detail + +namespace lvh { + + LicenseResult get_license_status() { + return detail::license_call({.type = detail::macos_broker::MessageType::status}); + } + + LicenseResult activate_license(std::string_view license_key, std::string_view instance_name) { + detail::macos_broker::Message request; + request.type = detail::macos_broker::MessageType::activate; + if (license_key.empty() || !detail::copy_text(request.license_key, license_key) || + !detail::copy_text(request.instance_name, instance_name)) { + LicenseResult result; + result.status = OperationStatus::failure(ErrorCode::invalid_argument, "invalid macOS license key or instance name"); + result.license.message = result.status.message(); + return result; + } + return detail::license_call(request); + } + + LicenseResult validate_license() { + return detail::license_call({.type = detail::macos_broker::MessageType::validate}); + } + + LicenseResult deactivate_license() { + return detail::license_call({.type = detail::macos_broker::MessageType::deactivate}); + } + +} // namespace lvh diff --git a/src/platform/macos/macos_broker_client.hpp b/src/platform/macos/macos_broker_client.hpp new file mode 100644 index 00000000..c9bc19c0 --- /dev/null +++ b/src/platform/macos/macos_broker_client.hpp @@ -0,0 +1,11 @@ +/** + * @file src/platform/macos/macos_broker_client.hpp + * @brief Internal macOS broker gamepad creation entry point. + */ +#pragma once + +#include "core/backend.hpp" + +namespace lvh::detail { + BackendGamepadCreationResult create_macos_brokered_gamepad(DeviceId id, const CreateGamepadOptions &options); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0c83d514..5eab4586 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -83,6 +83,7 @@ elseif(WIN32) elseif(APPLE) list(APPEND LIBVIRTUALHID_TEST_SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/fixtures/macos_backend_test_hooks.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_macos_broker_protocol.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/unit/test_macos_backend.cpp") endif() diff --git a/tests/unit/test_macos_backend.cpp b/tests/unit/test_macos_backend.cpp index 19a37acc..f5ba3011 100644 --- a/tests/unit/test_macos_backend.cpp +++ b/tests/unit/test_macos_backend.cpp @@ -127,15 +127,15 @@ TEST_F(MacosBackendTest, SelectsMouseMotionMetadataForHeldButtons) { TEST_F(MacosBackendTest, ReportsCapabilitiesAndUnsupportedDevices) { const auto result = lvh::detail::test::macos_backend_utilities(); - EXPECT_EQ(result.capabilities.backend_name, "macos-coregraphics"); - EXPECT_FALSE(result.capabilities.supports_virtual_hid); - EXPECT_FALSE(result.capabilities.supports_gamepad); + EXPECT_EQ(result.capabilities.backend_name, "macos-virtual-hid-coregraphics"); + 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_FALSE(result.capabilities.supports_touchscreen); EXPECT_FALSE(result.capabilities.supports_trackpad); EXPECT_FALSE(result.capabilities.supports_pen_tablet); - EXPECT_FALSE(result.capabilities.supports_output_reports); + EXPECT_TRUE(result.capabilities.supports_output_reports); EXPECT_FALSE(result.capabilities.requires_installed_driver); ASSERT_TRUE(result.keyboard_create_status.ok()) << result.keyboard_create_status.message(); diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp new file mode 100644 index 00000000..9b5a8c4c --- /dev/null +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -0,0 +1,54 @@ +/** + * @file tests/unit/test_macos_broker_protocol.cpp + * @brief macOS broker protocol and built-in profile capacity checks. + */ + +#include "platform/macos/broker/io.hpp" + +#include +#include +#include +#include +#include +#include + +TEST(MacosBrokerProtocolTest, BuiltInProfilesFitBrokerTransport) { + auto profiles = lvh::profiles::built_in_gamepad_profiles(); + profiles.push_back(lvh::profiles::dualshock4_usb()); + profiles.push_back(lvh::profiles::dualshock4_bluetooth()); + profiles.push_back(lvh::profiles::dualsense_usb()); + profiles.push_back(lvh::profiles::dualsense_bluetooth()); + ASSERT_GE(profiles.size(), 11U); + for (const auto &profile : profiles) { + SCOPED_TRACE(profile.name); + EXPECT_FALSE(profile.report_descriptor.empty()); + EXPECT_LE(profile.report_descriptor.size(), lvh::detail::macos_broker::max_descriptor_size); + EXPECT_GT(profile.input_report_size, 0U); + EXPECT_LE(profile.input_report_size, lvh::detail::macos_broker::max_report_size); + EXPECT_LE(profile.output_report_size, lvh::detail::macos_broker::max_report_size); + } +} + +TEST(MacosBrokerProtocolTest, TransfersVersionedMessagesWithoutTruncation) { + std::array sockets {}; + ASSERT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, sockets.data()), 0); + lvh::detail::macos_broker::Message sent; + sent.type = lvh::detail::macos_broker::MessageType::create; + sent.descriptor_size = 3; + sent.data[0] = 0x05; + sent.data[1] = 0x01; + sent.data[2] = 0x09; + bool sent_ok = false; + std::thread sender {[&] { + sent_ok = lvh::detail::macos_broker::send_message(sockets[0], sent); + }}; + lvh::detail::macos_broker::Message received; + EXPECT_TRUE(lvh::detail::macos_broker::receive_message(sockets[1], received)); + sender.join(); + EXPECT_TRUE(sent_ok); + EXPECT_EQ(received.type, sent.type); + EXPECT_EQ(received.descriptor_size, 3U); + EXPECT_EQ(received.data[2], 0x09); + ::close(sockets[0]); + ::close(sockets[1]); +} From f33603a38e7321d561cdae20bf19872aa06d448d Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:45:41 -0400 Subject: [PATCH 02/32] fix(ci): repair macOS build, lint, and documentation --- .github/workflows/ci-macos-broker.yml | 13 ++++++------ .github/workflows/ci.yml | 9 +++++--- dockle.toml | 1 + src/platform/macos/broker/CMakeLists.txt | 5 +++++ src/platform/macos/broker/license_manager.mm | 22 ++++++-------------- src/platform/macos/macos_broker_client.cpp | 17 +++++---------- tests/unit/test_runtime.cpp | 9 ++++---- 7 files changed, 34 insertions(+), 42 deletions(-) diff --git a/.github/workflows/ci-macos-broker.yml b/.github/workflows/ci-macos-broker.yml index 256a8c63..30033062 100644 --- a/.github/workflows/ci-macos-broker.yml +++ b/.github/workflows/ci-macos-broker.yml @@ -60,12 +60,13 @@ jobs: - name: Build and inspect architectures run: | cmake --build cmake-build-macos-universal --parallel "$(sysctl -n hw.ncpu)" - xcrun lipo -verify_arch arm64 x86_64 \ - cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker - xcrun lipo -verify_arch arm64 x86_64 \ - cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license - xcrun lipo -verify_arch arm64 x86_64 \ - cmake-build-macos-universal/src/libvirtualhid.a + for binary in \ + cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker \ + cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license \ + cmake-build-macos-universal/src/libvirtualhid.a; do + xcrun lipo -verify_arch arm64 "${binary}" + xcrun lipo -verify_arch x86_64 "${binary}" + done - name: Import Developer ID certificate if: inputs.publish_release == 'true' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5e6a4e2..f8235dea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,9 +76,12 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} - APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} - APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} - APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64: ${{ secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: >- + ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64: >- + ${{ secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 }} build: name: Library diff --git a/dockle.toml b/dockle.toml index 7e90e2d3..f971dd28 100644 --- a/dockle.toml +++ b/dockle.toml @@ -26,6 +26,7 @@ inputs = [ "docs/usage.md", "docs/platform-support.md", "docs/windows-driver.md", + "docs/macos-gamepad.md", "docs/todo.md", "docs/streaming-host-integration.md", "docs/development.md", diff --git a/src/platform/macos/broker/CMakeLists.txt b/src/platform/macos/broker/CMakeLists.txt index 0dc30420..7174e725 100644 --- a/src/platform/macos/broker/CMakeLists.txt +++ b/src/platform/macos/broker/CMakeLists.txt @@ -25,6 +25,11 @@ target_include_directories(libvirtualhid_macos_broker PRIVATE "${PROJECT_SOURCE_DIR}/src/include") target_compile_options(libvirtualhid_macos_broker PRIVATE -Wall -Wextra -Wpedantic -fblocks) +if(CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # Apple's libc++ keeps std::jthread behind this switch on some Xcode versions. + target_compile_options(libvirtualhid_macos_broker PRIVATE -fexperimental-library) + target_link_options(libvirtualhid_macos_broker PRIVATE -fexperimental-library) +endif() set_source_files_properties("${CMAKE_CURRENT_SOURCE_DIR}/license_manager.mm" PROPERTIES COMPILE_OPTIONS "-fobjc-arc") if(LIBVIRTUALHID_WARNINGS_AS_ERRORS) diff --git a/src/platform/macos/broker/license_manager.mm b/src/platform/macos/broker/license_manager.mm index 7428da18..8a3284a9 100644 --- a/src/platform/macos/broker/license_manager.mm +++ b/src/platform/macos/broker/license_manager.mm @@ -53,9 +53,7 @@ void set_text(std::array &destination, std::string_view value) { NSDictionary *read_protected_json(const char *path) { struct stat directory {}; struct stat info {}; - if (::lstat(state_directory, &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || - (directory.st_mode & 0077) != 0 || - ::lstat(path, &info) != 0 || !S_ISREG(info.st_mode) || info.st_uid != 0 || (info.st_mode & 0077) != 0) { + if (::lstat(state_directory, &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || (directory.st_mode & 0077) != 0 || ::lstat(path, &info) != 0 || !S_ISREG(info.st_mode) || info.st_uid != 0 || (info.st_mode & 0077) != 0) { return nil; } NSData *data = [NSData dataWithContentsOfFile:@(path)]; @@ -75,8 +73,7 @@ bool write_protected_json(const char *path, NSDictionary *json) { return false; } struct stat directory {}; - if (::lstat(state_directory, &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || - ::chmod(state_directory, 0700) != 0) { + if (::lstat(state_directory, &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || ::chmod(state_directory, 0700) != 0) { return false; } NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:&error]; @@ -205,9 +202,7 @@ bool valid_c_string(const std::array &value) { state.customer_email = from_ns(json_string(saved, @"customer_email")); state.activation_limit = [saved[@"activation_limit"] unsignedIntValue]; bool yearly = false; - if (!state.key.empty() && !state.activation_id.empty() && state.status == "granted" && - state.organization_id == windows::broker_config::polar_organization_id && - allowed_benefit(state.benefit_id, yearly)) { + if (!state.key.empty() && !state.activation_id.empty() && state.status == "granted" && state.organization_id == windows::broker_config::polar_organization_id && allowed_benefit(state.benefit_id, yearly)) { state_ = std::move(state); } } @@ -240,8 +235,7 @@ bool valid_c_string(const std::array &value) { } bool LicenseManager::licensed_locked() const { - if (!state_ || state_->status != "granted" || - state_->organization_id != windows::broker_config::polar_organization_id) { + if (!state_ || state_->status != "granted" || state_->organization_id != windows::broker_config::polar_organization_id) { return false; } bool yearly = false; @@ -322,9 +316,7 @@ bool valid_c_string(const std::array &value) { state.customer_email = from_ns(json_string(customer, @"email")); } bool yearly = false; - if (state.activation_id.empty() || state.status != "granted" || - state.organization_id != windows::broker_config::polar_organization_id || - !allowed_benefit(state.benefit_id, yearly)) { + if (state.activation_id.empty() || state.status != "granted" || state.organization_id != windows::broker_config::polar_organization_id || !allowed_benefit(state.benefit_id, yearly)) { response.status = static_cast(ErrorCode::license_invalid); set_text(response.message, "License organization, benefit, or activation is not allowed"); return response; @@ -401,9 +393,7 @@ bool valid_c_string(const std::array &value) { const auto new_organization = from_ns(json_string(result.body, @"organization_id")); const auto new_benefit = from_ns(json_string(result.body, @"benefit_id")); bool yearly = false; - if (from_ns(activation_id) != state.activation_id || new_status != "granted" || - new_organization != windows::broker_config::polar_organization_id || - !allowed_benefit(new_benefit, yearly)) { + if (from_ns(activation_id) != state.activation_id || new_status != "granted" || new_organization != windows::broker_config::polar_organization_id || !allowed_benefit(new_benefit, yearly)) { { std::lock_guard lock {mutex_}; state_.reset(); diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index d9084da4..d28fe759 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -38,8 +38,7 @@ namespace lvh::detail { } OperationStatus response_status(const macos_broker::Message &response) { - if (response.type != macos_broker::MessageType::response || - response.status < 0 || response.status > static_cast(ErrorCode::backend_failure)) { + if (response.type != macos_broker::MessageType::response || response.status < 0 || response.status > static_cast(ErrorCode::backend_failure)) { return OperationStatus::failure(ErrorCode::backend_failure, "macOS broker returned an invalid response"); } if (response.status == 0) { @@ -181,8 +180,7 @@ namespace lvh::detail { response_ready_ = true; } response_condition_.notify_all(); - } else if (event.type == macos_broker::MessageType::output && - event.size > 0 && event.size <= macos_broker::max_report_size) { + } else if (event.type == macos_broker::MessageType::output && event.size > 0 && event.size <= macos_broker::max_report_size) { { std::lock_guard lock {callback_state_->mutex}; if (callback_state_->reports.size() < 64U) { @@ -254,10 +252,7 @@ namespace lvh::detail { BackendGamepadCreationResult create_macos_brokered_gamepad(DeviceId id, const CreateGamepadOptions &options) { const auto &profile = options.profile; - if (profile.device_type != DeviceType::gamepad || profile.report_descriptor.empty() || - profile.report_descriptor.size() > macos_broker::max_descriptor_size || - profile.input_report_size == 0 || profile.input_report_size > macos_broker::max_report_size || - profile.output_report_size > macos_broker::max_report_size) { + if (profile.device_type != DeviceType::gamepad || profile.report_descriptor.empty() || profile.report_descriptor.size() > macos_broker::max_descriptor_size || profile.input_report_size == 0 || profile.input_report_size > macos_broker::max_report_size || profile.output_report_size > macos_broker::max_report_size) { return {OperationStatus::failure(ErrorCode::unsupported_profile, "macOS broker requires a valid gamepad HID descriptor and report sizes"), nullptr}; } macos_broker::Message request; @@ -274,8 +269,7 @@ namespace lvh::detail { if (options.metadata.stable_id.empty()) { std::snprintf(request.stable_id.data(), request.stable_id.size(), "02:00:%02x:%02x:%02x:%02x", static_cast((id >> 24U) & 0xFFU), static_cast((id >> 16U) & 0xFFU), static_cast((id >> 8U) & 0xFFU), static_cast(id & 0xFFU)); } - if (!copy_text(request.name, profile.name) || !copy_text(request.manufacturer, profile.manufacturer) || - (!options.metadata.stable_id.empty() && !copy_text(request.stable_id, options.metadata.stable_id))) { + if (!copy_text(request.name, profile.name) || !copy_text(request.manufacturer, profile.manufacturer) || (!options.metadata.stable_id.empty() && !copy_text(request.stable_id, options.metadata.stable_id))) { return {OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad identity exceeds broker limit"), nullptr}; } std::copy(profile.report_descriptor.begin(), profile.report_descriptor.end(), request.data.begin()); @@ -312,8 +306,7 @@ namespace lvh { LicenseResult activate_license(std::string_view license_key, std::string_view instance_name) { detail::macos_broker::Message request; request.type = detail::macos_broker::MessageType::activate; - if (license_key.empty() || !detail::copy_text(request.license_key, license_key) || - !detail::copy_text(request.instance_name, instance_name)) { + if (license_key.empty() || !detail::copy_text(request.license_key, license_key) || !detail::copy_text(request.instance_name, instance_name)) { LicenseResult result; result.status = OperationStatus::failure(ErrorCode::invalid_argument, "invalid macOS license key or instance name"); result.license.message = result.status.message(); diff --git a/tests/unit/test_runtime.cpp b/tests/unit/test_runtime.cpp index 631276a6..c301e7ac 100644 --- a/tests/unit/test_runtime.cpp +++ b/tests/unit/test_runtime.cpp @@ -121,16 +121,16 @@ TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { invalid_profile.output_report_size = LVH_WINDOWS_MAX_OUTPUT_REPORT_SIZE + 1U; EXPECT_EQ(runtime->create_gamepad(invalid_profile).status.code(), lvh::ErrorCode::invalid_argument); #elif defined(__APPLE__) && defined(__MACH__) - EXPECT_EQ(runtime->capabilities().backend_name, "macos-coregraphics"); + EXPECT_EQ(runtime->capabilities().backend_name, "macos-virtual-hid-coregraphics"); EXPECT_FALSE(runtime->capabilities().requires_installed_driver); - EXPECT_FALSE(runtime->capabilities().supports_virtual_hid); - EXPECT_FALSE(runtime->capabilities().supports_gamepad); + EXPECT_TRUE(runtime->capabilities().supports_virtual_hid); + EXPECT_TRUE(runtime->capabilities().supports_gamepad); EXPECT_TRUE(runtime->capabilities().supports_keyboard); EXPECT_TRUE(runtime->capabilities().supports_mouse); EXPECT_FALSE(runtime->capabilities().supports_touchscreen); EXPECT_FALSE(runtime->capabilities().supports_trackpad); EXPECT_FALSE(runtime->capabilities().supports_pen_tablet); - EXPECT_FALSE(runtime->capabilities().supports_output_reports); + EXPECT_TRUE(runtime->capabilities().supports_output_reports); auto keyboard = runtime->create_keyboard(); ASSERT_TRUE(keyboard) << keyboard.status.message(); @@ -141,7 +141,6 @@ TEST(RuntimeTest, PlatformDefaultReportsCurrentPlatformCapabilities) { ASSERT_TRUE(mouse) << mouse.status.message(); EXPECT_TRUE(mouse.mouse->close().ok()); - EXPECT_EQ(runtime->create_gamepad(lvh::profiles::xbox_360()).status.code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(runtime->create_touchscreen().status.code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(runtime->create_trackpad().status.code(), lvh::ErrorCode::unsupported_profile); EXPECT_EQ(runtime->create_pen_tablet().status.code(), lvh::ErrorCode::unsupported_profile); From 3dc2c91a5e03fa9280c33e4014409083751fd4ff Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:54:03 -0400 Subject: [PATCH 03/32] fix(ci): correct lipo syntax and remaining formatting --- .github/workflows/ci-macos-broker.yml | 4 +-- src/platform/macos/broker/io.hpp | 4 +-- .../broker/libvirtualhid_macos_broker.cpp | 27 +++++-------------- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci-macos-broker.yml b/.github/workflows/ci-macos-broker.yml index 30033062..12893449 100644 --- a/.github/workflows/ci-macos-broker.yml +++ b/.github/workflows/ci-macos-broker.yml @@ -64,8 +64,8 @@ jobs: cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker \ cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license \ cmake-build-macos-universal/src/libvirtualhid.a; do - xcrun lipo -verify_arch arm64 "${binary}" - xcrun lipo -verify_arch x86_64 "${binary}" + xcrun lipo "${binary}" -verify_arch arm64 + xcrun lipo "${binary}" -verify_arch x86_64 done - name: Import Developer ID certificate diff --git a/src/platform/macos/broker/io.hpp b/src/platform/macos/broker/io.hpp index 5cf95970..841f412c 100644 --- a/src/platform/macos/broker/io.hpp +++ b/src/platform/macos/broker/io.hpp @@ -43,9 +43,7 @@ namespace lvh::detail::macos_broker { inline int connect_to_broker(std::string &error) { struct stat directory_stat {}; - if (::lstat("/var/run/libvirtualhid", &directory_stat) != 0 || - !S_ISDIR(directory_stat.st_mode) || directory_stat.st_uid != 0 || - (directory_stat.st_mode & 0022) != 0) { + if (::lstat("/var/run/libvirtualhid", &directory_stat) != 0 || !S_ISDIR(directory_stat.st_mode) || directory_stat.st_uid != 0 || (directory_stat.st_mode & 0022) != 0) { error = "macOS broker directory is missing or insecure"; return -1; } diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index 340372e1..1970cb09 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -144,17 +144,13 @@ namespace lvh::detail::macos_broker { break; } } - if (!result.empty() && - ((report_id == ps::dualshock4_pairing_report && kind == GamepadProfileKind::dualshock4) || - (report_id == ps::dualsense_pairing_report && kind == GamepadProfileKind::dualsense))) { + if (!result.empty() && ((report_id == ps::dualshock4_pairing_report && kind == GamepadProfileKind::dualshock4) || (report_id == ps::dualsense_pairing_report && kind == GamepadProfileKind::dualsense))) { const auto mac = mac_address(request); for (std::size_t index = 0; index < mac.size(); ++index) { result[1U + index] = mac[mac.size() - 1U - index]; } } - if (!result.empty() && - request.bus == static_cast(lvh::BusType::bluetooth) && - result.size() >= 4U) { + if (!result.empty() && request.bus == static_cast(lvh::BusType::bluetooth) && result.size() >= 4U) { const auto value = crc32(std::span {result.data(), result.size() - 4U}, ps::playstation_feature_crc_seed); for (std::size_t index = 0; index < 4U; ++index) { result[result.size() - 4U + index] = static_cast(value >> (8U * index)); @@ -281,19 +277,13 @@ namespace lvh::detail::macos_broker { ::close(fd); return; } - if (request.type == MessageType::status || request.type == MessageType::activate || - request.type == MessageType::validate || request.type == MessageType::deactivate) { + if (request.type == MessageType::status || request.type == MessageType::activate || request.type == MessageType::validate || request.type == MessageType::deactivate) { const auto response = licenses.handle(request); static_cast(send_message(fd, response)); ::close(fd); return; } - if (request.type != MessageType::create || request.descriptor_size == 0 || - request.descriptor_size > max_descriptor_size || request.input_report_size == 0 || - request.input_report_size > max_report_size || request.output_report_size > max_report_size || - !terminated(request.name) || !terminated(request.manufacturer) || !terminated(request.stable_id) || - request.kind > static_cast(lvh::GamepadProfileKind::dualshock4) || - request.bus > static_cast(lvh::BusType::bluetooth)) { + if (request.type != MessageType::create || request.descriptor_size == 0 || request.descriptor_size > max_descriptor_size || request.input_report_size == 0 || request.input_report_size > max_report_size || request.output_report_size > max_report_size || !terminated(request.name) || !terminated(request.manufacturer) || !terminated(request.stable_id) || request.kind > static_cast(lvh::GamepadProfileKind::dualshock4) || request.bus > static_cast(lvh::BusType::bluetooth)) { static_cast(send_message(fd, response_with_error(lvh::ErrorCode::invalid_argument, "Invalid macOS gamepad request"))); ::close(fd); return; @@ -335,8 +325,7 @@ namespace lvh::detail::macos_broker { if (request.type == MessageType::close) { break; } - if (request.type != MessageType::submit || request.size != expected_input_size || - request.size > max_report_size) { + if (request.type != MessageType::submit || request.size != expected_input_size || request.size > max_report_size) { auto failure = response_with_error(lvh::ErrorCode::invalid_argument, "Invalid gamepad input report"); static_cast(session.send(failure)); continue; @@ -373,8 +362,7 @@ int main() { return 1; } struct stat directory {}; - if (::lstat("/var/run/libvirtualhid", &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || - ::chmod("/var/run/libvirtualhid", 0755) != 0) { + if (::lstat("/var/run/libvirtualhid", &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || ::chmod("/var/run/libvirtualhid", 0755) != 0) { return 1; } const int lock_fd = ::open("/var/run/libvirtualhid/broker.lock", O_CREAT | O_RDWR | O_NOFOLLOW, 0600); @@ -389,8 +377,7 @@ int main() { sockaddr_un address {}; address.sun_family = AF_UNIX; std::strncpy(address.sun_path, socket_path, sizeof(address.sun_path) - 1U); - if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || - ::chmod(socket_path, 0666) != 0 || ::listen(listener, 32) != 0) { + if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || ::chmod(socket_path, 0666) != 0 || ::listen(listener, 32) != 0) { return 1; } LicenseManager licenses; From 5bb01abdfb22cd1a8aa557f2269b0619f50c6cb5 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:58:18 -0400 Subject: [PATCH 04/32] refactor(macos): use jthread across broker workers --- docs/macos-gamepad.md | 2 ++ src/CMakeLists.txt | 7 +++---- src/platform/macos/broker/libvirtualhid_macos_broker.cpp | 2 +- src/platform/macos/macos_broker_client.cpp | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 106f97c6..a538a052 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -85,6 +85,8 @@ xcrun lipo -info cmake-build-macos-universal/src/platform/macos/broker/VirtualHI The single resulting executable contains both Apple silicon and Intel slices. CI sets `MACOSX_DEPLOYMENT_TARGET` at the workflow level, as Sunshine does. +The Apple builds use `-fexperimental-library` for libc++'s `std::jthread` +support, following Sunshine's macOS build configuration. The CI job checks the broker, license CLI, and `libvirtualhid.a` with `lipo`. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c5de376a..2a7c7ba9 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -98,10 +98,9 @@ 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. +if((APPLE OR CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") + # Apple's and FreeBSD's libc++ keep std::jthread behind this switch on + # toolchains used by CI. Consumers need the matching link option too. target_compile_options(${PROJECT_NAME} PRIVATE -fexperimental-library) target_link_options(${PROJECT_NAME} PUBLIC -fexperimental-library) endif() diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index 1970cb09..ce87e863 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -395,7 +395,7 @@ int main() { ::close(client); continue; } - std::thread {[client, &licenses] { + std::jthread {[client, &licenses] { serve_client(client, licenses); }}.detach(); } diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index d28fe759..13c3f4cc 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -205,8 +205,8 @@ namespace lvh::detail { int fd_; DeviceProfile profile_; std::shared_ptr callback_state_; - std::thread reader_; - std::thread callback_thread_; + std::jthread reader_; + std::jthread callback_thread_; std::mutex call_mutex_; std::mutex mutex_; std::condition_variable response_condition_; From a4021fe5d725d34c5571f8f16d73c22702e8318d Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:10:17 -0400 Subject: [PATCH 05/32] fix(macos): harden broker IPC and address code analysis --- docs/macos-gamepad.md | 4 + src/CMakeLists.txt | 2 +- src/platform/macos/broker/io.hpp | 33 ++++-- .../broker/libvirtualhid_macos_broker.cpp | 101 ++++++++++-------- src/platform/macos/broker/license_cli.cpp | 2 +- src/platform/macos/macos_broker_client.cpp | 43 +++++--- tests/unit/test_macos_broker_protocol.cpp | 2 +- 7 files changed, 113 insertions(+), 74 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index a538a052..450981c0 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -5,6 +5,10 @@ root-owned broker. The broker alone calls Apple's `IOHIDUserDevice` API and hold the virtual HID entitlement. The ordinary C++ library has no Apple entitlement and continues to use CoreGraphics for keyboard and mouse input. +The client checks root ownership of the broker directory, socket, and +connected peer before exchanging versioned messages. Socket transfers handle +partial reads and writes so truncated messages are not treated as complete. + The built-in generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation variants, are accepted as HID descriptors. Their VID/PID, transport, input diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2a7c7ba9..aa18bec7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -101,7 +101,7 @@ set_target_properties(${PROJECT_NAME} PROPERTIES if((APPLE OR CMAKE_SYSTEM_NAME STREQUAL "FreeBSD") AND CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Apple's and FreeBSD's libc++ keep std::jthread behind this switch on # toolchains used by CI. Consumers need the matching link option too. - target_compile_options(${PROJECT_NAME} PRIVATE -fexperimental-library) + target_compile_options(${PROJECT_NAME} PUBLIC -fexperimental-library) target_link_options(${PROJECT_NAME} PUBLIC -fexperimental-library) endif() if(MSVC AND LIBVIRTUALHID_BUILD_WINDOWS_DRIVER) diff --git a/src/platform/macos/broker/io.hpp b/src/platform/macos/broker/io.hpp index 841f412c..98ede60e 100644 --- a/src/platform/macos/broker/io.hpp +++ b/src/platform/macos/broker/io.hpp @@ -5,22 +5,33 @@ #include "protocol.hpp" +#include #include +#include #include +#include #include +#include #include #include #include +#include #include namespace lvh::detail::macos_broker { - inline bool transfer(int fd, void *buffer, std::size_t size, bool sending) { - auto *bytes = static_cast(buffer); + template + bool transfer(int fd, std::span buffer) { + static_assert(std::is_same_v, std::byte>); + auto *bytes = buffer.data(); + auto size = buffer.size_bytes(); while (size != 0) { - const auto count = sending ? - ::send(fd, bytes, size, 0) : - ::recv(fd, bytes, size, 0); + ssize_t count; + if constexpr (std::is_const_v) { + count = ::send(fd, bytes, size, 0); + } else { + count = ::recv(fd, bytes, size, 0); + } if (count < 0 && errno == EINTR) { continue; } @@ -34,11 +45,11 @@ namespace lvh::detail::macos_broker { } inline bool send_message(int fd, const Message &message) { - return transfer(fd, const_cast(&message), sizeof(message), true); + return transfer(fd, std::as_bytes(std::span {&message, 1})); } inline bool receive_message(int fd, Message &message) { - return transfer(fd, &message, sizeof(message), false) && message.version == protocol_version; + return transfer(fd, std::as_writable_bytes(std::span {&message, 1})) && message.version == protocol_version; } inline int connect_to_broker(std::string &error) { @@ -71,15 +82,17 @@ namespace lvh::detail::macos_broker { static_cast(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &receive_timeout, sizeof(receive_timeout))); sockaddr_un address {}; address.sun_family = AF_UNIX; - std::strncpy(address.sun_path, socket_path, sizeof(address.sun_path) - 1U); + constexpr std::string_view path {socket_path}; + static_assert(path.size() < sizeof(address.sun_path)); + std::ranges::copy(path, address.sun_path); if (::connect(fd, reinterpret_cast(&address), sizeof(address)) != 0) { error = std::strerror(errno); ::close(fd); return -1; } - uid_t peer_uid = static_cast(-1); - gid_t peer_gid = static_cast(-1); + auto peer_uid = static_cast(-1); + auto peer_gid = static_cast(-1); if (::getpeereid(fd, &peer_uid, &peer_gid) != 0 || peer_uid != 0) { error = "macOS broker peer is not root"; ::close(fd); diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index ce87e863..72ea2a45 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -28,10 +28,12 @@ #include #include #include +#include #include #include #include #include +#include #include namespace lvh::detail::macos_broker { @@ -41,13 +43,15 @@ namespace lvh::detail::macos_broker { template void set_text(std::array &destination, const char *text) { - std::strncpy(destination.data(), text, destination.size() - 1U); + const auto length = std::min(std::strlen(text), destination.size() - 1U); + std::ranges::copy_n(text, length, destination.begin()); + destination[length] = '\0'; } Message response_with_error(lvh::ErrorCode code, const char *message) { Message response; response.type = MessageType::response; - response.status = static_cast(code); + response.status = std::to_underlying(code); set_text(response.message, message); return response; } @@ -89,8 +93,7 @@ namespace lvh::detail::macos_broker { std::array mac_address(const Message &request) { std::array mac {}; - unsigned bytes[6] {}; - if (std::sscanf(request.stable_id.data(), "%2x:%2x:%2x:%2x:%2x:%2x", &bytes[0], &bytes[1], &bytes[2], &bytes[3], &bytes[4], &bytes[5]) == 6) { + if (std::array bytes {}; std::sscanf(request.stable_id.data(), "%2x:%2x:%2x:%2x:%2x:%2x", &bytes[0], &bytes[1], &bytes[2], &bytes[3], &bytes[4], &bytes[5]) == 6) { for (std::size_t index = 0; index < mac.size(); ++index) { mac[index] = static_cast(bytes[index]); } @@ -116,7 +119,7 @@ namespace lvh::detail::macos_broker { result = copy(ps::dualshock4_usb_calibration_info); break; case ps::dualshock4_bluetooth_calibration_report: - if (request.bus == static_cast(lvh::BusType::bluetooth)) { + if (request.bus == static_cast(std::to_underlying(lvh::BusType::bluetooth))) { result = copy(ps::dualshock4_bluetooth_calibration_info); } break; @@ -150,7 +153,7 @@ namespace lvh::detail::macos_broker { result[1U + index] = mac[mac.size() - 1U - index]; } } - if (!result.empty() && request.bus == static_cast(lvh::BusType::bluetooth) && result.size() >= 4U) { + if (!result.empty() && request.bus == static_cast(std::to_underlying(lvh::BusType::bluetooth)) && result.size() >= 4U) { const auto value = crc32(std::span {result.data(), result.size() - 4U}, ps::playstation_feature_crc_seed); for (std::size_t index = 0; index < 4U; ++index) { result[result.size() - 4U + index] = static_cast(value >> (8U * index)); @@ -195,7 +198,7 @@ namespace lvh::detail::macos_broker { event.size += static_cast(size); static_cast(send(event)); - if (profile.kind == static_cast(lvh::GamepadProfileKind::switch_pro)) { + if (profile.kind == static_cast(std::to_underlying(lvh::GamepadProfileKind::switch_pro))) { const auto reply = switch_pro_protocol::make_switch_pro_reply(std::span {event.data.data(), event.size}); if (reply) { static_cast(IOHIDUserDeviceHandleReportWithTimeStamp(device, mach_absolute_time(), reply->data(), reply->size())); @@ -223,7 +226,7 @@ namespace lvh::detail::macos_broker { CFRelease(descriptor); set_property(properties, CFSTR(kIOHIDProductKey), request.name.data()); set_property(properties, CFSTR(kIOHIDManufacturerKey), request.manufacturer.data()); - set_property(properties, CFSTR(kIOHIDTransportKey), request.bus == static_cast(lvh::BusType::bluetooth) ? kIOHIDTransportBluetoothValue : kIOHIDTransportUSBValue); + set_property(properties, CFSTR(kIOHIDTransportKey), request.bus == static_cast(std::to_underlying(lvh::BusType::bluetooth)) ? kIOHIDTransportBluetoothValue : kIOHIDTransportUSBValue); set_property(properties, CFSTR(kIOHIDSerialNumberKey), request.stable_id.data()); set_number(properties, CFSTR(kIOHIDVendorIDKey), request.vendor_id); set_number(properties, CFSTR(kIOHIDProductIDKey), request.product_id); @@ -267,6 +270,41 @@ namespace lvh::detail::macos_broker { return device; } + bool valid_create_request(const Message &request) { + return request.type == MessageType::create && request.descriptor_size != 0 && request.descriptor_size <= max_descriptor_size && request.input_report_size != 0 && request.input_report_size <= max_report_size && request.output_report_size <= max_report_size && terminated(request.name) && terminated(request.manufacturer) && terminated(request.stable_id) && request.kind <= static_cast(std::to_underlying(lvh::GamepadProfileKind::dualshock4)) && request.bus <= static_cast(std::to_underlying(lvh::BusType::bluetooth)); + } + + void receive_reports(DeviceSession &session, LicenseManager &licenses, bool evaluation, std::uint32_t expected_input_size) { + Message request; + for (;;) { + pollfd descriptor {.fd = session.fd, .events = POLLIN, .revents = 0}; + const int polled = ::poll(&descriptor, 1, 1000); + if (!licenses.device_is_authorized(evaluation) || (polled < 0 && errno != EINTR)) { + return; + } + if (polled == 0 || polled < 0) { + continue; + } + if ((descriptor.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0 || !receive_message(session.fd, request) || request.type == MessageType::close) { + return; + } + if (request.type != MessageType::submit || request.size != expected_input_size || request.size > max_report_size) { + auto failure = response_with_error(lvh::ErrorCode::invalid_argument, "Invalid gamepad input report"); + static_cast(session.send(failure)); + continue; + } + const auto result = IOHIDUserDeviceHandleReportWithTimeStamp(session.device, mach_absolute_time(), request.data.data(), request.size); + auto submit_response = Message {}; + submit_response.type = MessageType::response; + if (result != kIOReturnSuccess) { + submit_response = response_with_error(lvh::ErrorCode::backend_failure, "macOS rejected virtual HID input report"); + } + if (!session.send(submit_response)) { + return; + } + } + } + void serve_client(int fd, LicenseManager &licenses) { timeval receive_timeout {.tv_sec = 5, .tv_usec = 0}; timeval send_timeout {.tv_sec = 5, .tv_usec = 0}; @@ -283,7 +321,7 @@ namespace lvh::detail::macos_broker { ::close(fd); return; } - if (request.type != MessageType::create || request.descriptor_size == 0 || request.descriptor_size > max_descriptor_size || request.input_report_size == 0 || request.input_report_size > max_report_size || request.output_report_size > max_report_size || !terminated(request.name) || !terminated(request.manufacturer) || !terminated(request.stable_id) || request.kind > static_cast(lvh::GamepadProfileKind::dualshock4) || request.bus > static_cast(lvh::BusType::bluetooth)) { + if (!valid_create_request(request)) { static_cast(send_message(fd, response_with_error(lvh::ErrorCode::invalid_argument, "Invalid macOS gamepad request"))); ::close(fd); return; @@ -308,38 +346,7 @@ namespace lvh::detail::macos_broker { response.type = MessageType::response; response.status = 0; static_cast(session.send(response)); - const auto expected_input_size = request.input_report_size; - - for (;;) { - pollfd descriptor {.fd = fd, .events = POLLIN, .revents = 0}; - const int polled = ::poll(&descriptor, 1, 1000); - if (!licenses.device_is_authorized(evaluation) || (polled < 0 && errno != EINTR)) { - break; - } - if (polled == 0 || polled < 0) { - continue; - } - if ((descriptor.revents & (POLLHUP | POLLERR | POLLNVAL)) != 0 || !receive_message(fd, request)) { - break; - } - if (request.type == MessageType::close) { - break; - } - if (request.type != MessageType::submit || request.size != expected_input_size || request.size > max_report_size) { - auto failure = response_with_error(lvh::ErrorCode::invalid_argument, "Invalid gamepad input report"); - static_cast(session.send(failure)); - continue; - } - const auto result = IOHIDUserDeviceHandleReportWithTimeStamp(device, mach_absolute_time(), request.data.data(), request.size); - auto submit_response = Message {}; - submit_response.type = MessageType::response; - if (result != kIOReturnSuccess) { - submit_response = response_with_error(lvh::ErrorCode::backend_failure, "macOS rejected virtual HID input report"); - } - if (!session.send(submit_response)) { - break; - } - } + receive_reports(session, licenses, evaluation, request.input_report_size); session.open = false; IOHIDUserDeviceCancel(device); static_cast(dispatch_semaphore_wait(cancelled, DISPATCH_TIME_FOREVER)); @@ -356,12 +363,14 @@ int main() { if (::geteuid() != 0) { return 1; } + // New broker-owned files default to owner-only access. ::umask(0077); ::signal(SIGPIPE, SIG_IGN); if (::mkdir("/var/run/libvirtualhid", 0755) != 0 && errno != EEXIST) { return 1; } struct stat directory {}; + // Clients need traverse access, while root alone can change this directory. if (::lstat("/var/run/libvirtualhid", &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || ::chmod("/var/run/libvirtualhid", 0755) != 0) { return 1; } @@ -376,7 +385,10 @@ int main() { } sockaddr_un address {}; address.sun_family = AF_UNIX; - std::strncpy(address.sun_path, socket_path, sizeof(address.sun_path) - 1U); + constexpr std::string_view path {socket_path}; + static_assert(path.size() < sizeof(address.sun_path)); + std::ranges::copy(path, address.sun_path); + // Local unprivileged clients need to connect; licensing is enforced per request. if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || ::chmod(socket_path, 0666) != 0 || ::listen(listener, 32) != 0) { return 1; } @@ -389,12 +401,13 @@ int main() { } break; } - uid_t uid = static_cast(-1); - gid_t gid = static_cast(-1); + auto uid = static_cast(-1); + auto gid = static_cast(-1); if (::getpeereid(client, &uid, &gid) != 0) { ::close(client); continue; } + // Serve clients concurrently; a session can remain open for a game's lifetime. std::jthread {[client, &licenses] { serve_client(client, licenses); }}.detach(); diff --git a/src/platform/macos/broker/license_cli.cpp b/src/platform/macos/broker/license_cli.cpp index 2849b0a1..936700e8 100644 --- a/src/platform/macos/broker/license_cli.cpp +++ b/src/platform/macos/broker/license_cli.cpp @@ -69,7 +69,7 @@ int main(int argc, char **argv) { } else if (action == "activate") { auto key = read_key(); result = lvh::activate_license(key, argc == 3 ? argv[2] : ""); - std::fill(key.begin(), key.end(), '\0'); + std::ranges::fill(key, '\0'); } else if (action == "validate" && argc == 2) { result = lvh::validate_license(); } else if (action == "deactivate" && argc == 2) { diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 13c3f4cc..62cbf2e0 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -29,7 +29,7 @@ namespace lvh::detail { if (text.size() >= destination.size()) { return false; } - std::copy(text.begin(), text.end(), destination.begin()); + std::ranges::copy(text, destination.begin()); return true; } @@ -38,7 +38,7 @@ namespace lvh::detail { } OperationStatus response_status(const macos_broker::Message &response) { - if (response.type != macos_broker::MessageType::response || response.status < 0 || response.status > static_cast(ErrorCode::backend_failure)) { + if (response.type != macos_broker::MessageType::response || response.status < 0 || response.status > std::to_underlying(ErrorCode::backend_failure)) { return OperationStatus::failure(ErrorCode::backend_failure, "macOS broker returned an invalid response"); } if (response.status == 0) { @@ -52,7 +52,6 @@ namespace lvh::detail { MacosGamepad(int fd, DeviceProfile profile): fd_ {fd}, profile_ {std::move(profile)}, - callback_state_ {std::make_shared()}, reader_ {[this, fd] { read_loop(fd); }}, @@ -71,7 +70,7 @@ namespace lvh::detail { macos_broker::Message request; request.type = macos_broker::MessageType::submit; request.size = static_cast(report.size()); - std::copy(report.begin(), report.end(), request.data.begin()); + std::ranges::copy(report, request.data.begin()); return call(request); } @@ -103,6 +102,7 @@ namespace lvh::detail { callback_state_->condition.notify_all(); if (callback_thread_.joinable()) { if (callback_thread_.get_id() == std::this_thread::get_id()) { + // A callback may close its own gamepad; captured state outlives this thread. callback_thread_.detach(); } else { callback_thread_.join(); @@ -126,7 +126,7 @@ namespace lvh::detail { OutputCallback callback; { std::unique_lock lock {state->mutex}; - state->condition.wait(lock, [&] { + state->condition.wait(lock, [&state] { return state->stop || !state->reports.empty(); }); if (state->reports.empty()) { @@ -156,18 +156,27 @@ namespace lvh::detail { if (!macos_broker::send_message(fd_, request)) { return OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker connection closed"); } - std::unique_lock lock {mutex_}; - if (!response_condition_.wait_for(lock, std::chrono::seconds {10}, [this] { - return response_ready_ || disconnected_; - })) { - lock.unlock(); + bool timed_out = false; + bool response_ready = false; + macos_broker::Message response; + { + std::unique_lock lock {mutex_}; + timed_out = !response_condition_.wait_for(lock, std::chrono::seconds {10}, [this] { + return response_ready_ || disconnected_; + }); + response_ready = response_ready_; + if (response_ready) { + response = response_; + } + } + if (timed_out) { ::shutdown(fd_, SHUT_RDWR); return OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker did not answer gamepad report"); } - if (!response_ready_) { + if (!response_ready) { return OperationStatus::failure(ErrorCode::backend_unavailable, "macOS broker connection closed"); } - return response_status(response_); + return response_status(response); } void read_loop(int fd) { @@ -204,7 +213,7 @@ namespace lvh::detail { int fd_; DeviceProfile profile_; - std::shared_ptr callback_state_; + std::shared_ptr callback_state_ = std::make_shared(); std::jthread reader_; std::jthread callback_thread_; std::mutex call_mutex_; @@ -236,7 +245,7 @@ namespace lvh::detail { } result.status = response_status(response); result.license.service_available = true; - result.license.state = response.license_state <= static_cast(LicenseState::invalid) ? + result.license.state = response.license_state <= static_cast(std::to_underlying(LicenseState::invalid)) ? static_cast(response.license_state) : LicenseState::invalid; result.license.active_devices = response.active_devices; @@ -257,8 +266,8 @@ namespace lvh::detail { } macos_broker::Message request; request.type = macos_broker::MessageType::create; - request.kind = static_cast(profile.gamepad_kind); - request.bus = static_cast(profile.bus_type); + request.kind = static_cast(std::to_underlying(profile.gamepad_kind)); + request.bus = static_cast(std::to_underlying(profile.bus_type)); request.vendor_id = profile.vendor_id; request.product_id = profile.product_id; request.device_version = profile.version; @@ -272,7 +281,7 @@ namespace lvh::detail { if (!copy_text(request.name, profile.name) || !copy_text(request.manufacturer, profile.manufacturer) || (!options.metadata.stable_id.empty() && !copy_text(request.stable_id, options.metadata.stable_id))) { return {OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad identity exceeds broker limit"), nullptr}; } - std::copy(profile.report_descriptor.begin(), profile.report_descriptor.end(), request.data.begin()); + std::ranges::copy(profile.report_descriptor, request.data.begin()); std::string error; const int fd = macos_broker::connect_to_broker(error); diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 9b5a8c4c..18544834 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -39,7 +39,7 @@ TEST(MacosBrokerProtocolTest, TransfersVersionedMessagesWithoutTruncation) { sent.data[1] = 0x01; sent.data[2] = 0x09; bool sent_ok = false; - std::thread sender {[&] { + std::jthread sender {[&sent_ok, &sockets, &sent] { sent_ok = lvh::detail::macos_broker::send_message(sockets[0], sent); }}; lvh::detail::macos_broker::Message received; From 2e33f2d00efd9f25bc33870502f368613d07c620 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:18:08 -0400 Subject: [PATCH 06/32] fix(macos): bound broker errors and format stable IDs --- docs/macos-gamepad.md | 2 ++ .../macos/broker/libvirtualhid_macos_broker.cpp | 12 ++++++------ src/platform/macos/macos_broker_client.cpp | 9 +++++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 450981c0..638d752b 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -17,6 +17,8 @@ broker. Xbox 360 is an ordinary HID device on macOS; the Windows XUSB/XInput personality is Windows-specific. Individual games may use Apple's Game Controller framework or their own HID mappings, so a signed installed build still needs consumer testing for each profile. +When metadata omits a stable ID, the client derives a locally administered +`02:00:xx:xx:xx:xx` identifier from the device ID. ## What to do in Apple Developer diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index 72ea2a45..5a6fdcd4 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -41,14 +41,14 @@ namespace lvh::detail::macos_broker { namespace ps = playstation_feature_reports; - template - void set_text(std::array &destination, const char *text) { - const auto length = std::min(std::strlen(text), destination.size() - 1U); - std::ranges::copy_n(text, length, destination.begin()); - destination[length] = '\0'; + template + void set_text(std::array &destination, const char (&text)[TextSize]) { + static_assert(TextSize <= DestinationSize); + std::ranges::copy(text, destination.begin()); } - Message response_with_error(lvh::ErrorCode code, const char *message) { + template + Message response_with_error(lvh::ErrorCode code, const char (&message)[MessageSize]) { Message response; response.type = MessageType::response; response.status = std::to_underlying(code); diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 62cbf2e0..c7281e30 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -11,9 +11,9 @@ #include #include #include -#include #include #include +#include #include #include #include @@ -275,10 +275,11 @@ namespace lvh::detail { request.input_report_size = static_cast(profile.input_report_size); request.output_report_size = static_cast(profile.output_report_size); request.descriptor_size = static_cast(profile.report_descriptor.size()); - if (options.metadata.stable_id.empty()) { - std::snprintf(request.stable_id.data(), request.stable_id.size(), "02:00:%02x:%02x:%02x:%02x", static_cast((id >> 24U) & 0xFFU), static_cast((id >> 16U) & 0xFFU), static_cast((id >> 8U) & 0xFFU), static_cast(id & 0xFFU)); + auto stable_id = options.metadata.stable_id; + if (stable_id.empty()) { + stable_id = std::format("02:00:{:02x}:{:02x}:{:02x}:{:02x}", (id >> 24U) & 0xFFU, (id >> 16U) & 0xFFU, (id >> 8U) & 0xFFU, id & 0xFFU); } - if (!copy_text(request.name, profile.name) || !copy_text(request.manufacturer, profile.manufacturer) || (!options.metadata.stable_id.empty() && !copy_text(request.stable_id, options.metadata.stable_id))) { + if (!copy_text(request.name, profile.name) || !copy_text(request.manufacturer, profile.manufacturer) || !copy_text(request.stable_id, stable_id)) { return {OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad identity exceeds broker limit"), nullptr}; } std::ranges::copy(profile.report_descriptor, request.data.begin()); From f1811da4074bb3144b48f7f6d8acac3b58fdb84a Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:25:05 -0400 Subject: [PATCH 07/32] fix(macos): document intentional broker security boundaries --- docs/macos-gamepad.md | 2 ++ src/platform/macos/broker/io.hpp | 2 +- .../broker/libvirtualhid_macos_broker.cpp | 28 +++++++++++++------ src/platform/macos/broker/protocol.hpp | 2 +- src/platform/macos/macos_broker_client.cpp | 2 +- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 638d752b..39b00d71 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -8,6 +8,8 @@ and continues to use CoreGraphics for keyboard and mouse input. The client checks root ownership of the broker directory, socket, and connected peer before exchanging versioned messages. Socket transfers handle partial reads and writes so truncated messages are not treated as complete. +The root-owned broker directory permits local clients to reach its socket, and +the broker applies the same machine-license gate to gamepad creation as Windows. The built-in generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation diff --git a/src/platform/macos/broker/io.hpp b/src/platform/macos/broker/io.hpp index 98ede60e..44afb189 100644 --- a/src/platform/macos/broker/io.hpp +++ b/src/platform/macos/broker/io.hpp @@ -85,7 +85,7 @@ namespace lvh::detail::macos_broker { constexpr std::string_view path {socket_path}; static_assert(path.size() < sizeof(address.sun_path)); std::ranges::copy(path, address.sun_path); - if (::connect(fd, reinterpret_cast(&address), sizeof(address)) != 0) { + if (::connect(fd, reinterpret_cast(&address), sizeof(address)) != 0) { // NOSONAR(cpp:S3630): POSIX sockets require a sockaddr pointer for sockaddr_un. error = std::strerror(errno); ::close(fd); return -1; diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index 5a6fdcd4..f7295940 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -364,14 +365,17 @@ int main() { return 1; } // New broker-owned files default to owner-only access. - ::umask(0077); + ::umask(0077); // NOSONAR(cpp:S5849): this restricts newly created files to owner access. ::signal(SIGPIPE, SIG_IGN); - if (::mkdir("/var/run/libvirtualhid", 0755) != 0 && errno != EEXIST) { + if (::mkdir("/var/run/libvirtualhid", 0755) != 0 && errno != EEXIST) { // NOSONAR(cpp:S2612): root owns the directory; others only need traversal. return 1; } struct stat directory {}; // Clients need traverse access, while root alone can change this directory. - if (::lstat("/var/run/libvirtualhid", &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0 || ::chmod("/var/run/libvirtualhid", 0755) != 0) { + if (::lstat("/var/run/libvirtualhid", &directory) != 0 || !S_ISDIR(directory.st_mode) || directory.st_uid != 0) { + return 1; + } + if (::chmod("/var/run/libvirtualhid", 0755) != 0) { // NOSONAR(cpp:S2612): root retains sole write access. return 1; } const int lock_fd = ::open("/var/run/libvirtualhid/broker.lock", O_CREAT | O_RDWR | O_NOFOLLOW, 0600); @@ -388,11 +392,17 @@ int main() { constexpr std::string_view path {socket_path}; static_assert(path.size() < sizeof(address.sun_path)); std::ranges::copy(path, address.sun_path); - // Local unprivileged clients need to connect; licensing is enforced per request. - if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0 || ::chmod(socket_path, 0666) != 0 || ::listen(listener, 32) != 0) { + if (::bind(listener, reinterpret_cast(&address), sizeof(address)) != 0) { // NOSONAR(cpp:S3630): POSIX sockets require a sockaddr pointer for sockaddr_un. + return 1; + } + // Local users need to connect, as with the Windows broker pipe; creation still needs a license. + if (::chmod(socket_path, 0666) != 0) { // NOSONAR(cpp:S2612): only root can replace the socket in its directory. + return 1; + } + if (::listen(listener, 32) != 0) { return 1; } - LicenseManager licenses; + auto licenses = std::make_shared(); while (true) { const int client = ::accept(listener, nullptr, nullptr); if (client < 0) { @@ -408,9 +418,9 @@ int main() { continue; } // Serve clients concurrently; a session can remain open for a game's lifetime. - std::jthread {[client, &licenses] { - serve_client(client, licenses); - }}.detach(); + std::jthread {[client, licenses] { + serve_client(client, *licenses); + }}.detach(); // NOSONAR(cpp:S5962): gamepad sessions can outlive accept; shared ownership keeps licensing alive. } ::close(listener); return 1; diff --git a/src/platform/macos/broker/protocol.hpp b/src/platform/macos/broker/protocol.hpp index a76f94a1..c1c92a35 100644 --- a/src/platform/macos/broker/protocol.hpp +++ b/src/platform/macos/broker/protocol.hpp @@ -30,7 +30,7 @@ namespace lvh::detail::macos_broker { response, }; - struct Message { + struct Message { // NOSONAR(cpp:S1820): the fixed-size versioned wire message is deliberately flat. std::uint32_t version = protocol_version; MessageType type = MessageType::status; std::int32_t status = 0; diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index c7281e30..b1f2821e 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -103,7 +103,7 @@ namespace lvh::detail { if (callback_thread_.joinable()) { if (callback_thread_.get_id() == std::this_thread::get_id()) { // A callback may close its own gamepad; captured state outlives this thread. - callback_thread_.detach(); + callback_thread_.detach(); // NOSONAR(cpp:S5962): a callback can close itself; its shared state outlives the thread. } else { callback_thread_.join(); } From 813ecf4eb83aa37faf7bfcef4a56d65ef058ae08 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:30:54 -0400 Subject: [PATCH 08/32] fix(macos): scope worker analyzer annotation --- src/platform/macos/broker/libvirtualhid_macos_broker.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index f7295940..84faa7ec 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -418,9 +418,10 @@ int main() { continue; } // Serve clients concurrently; a session can remain open for a game's lifetime. - std::jthread {[client, licenses] { + std::jthread {[client, licenses] { // NOSONAR(cpp:S5962): gamepad sessions can outlive accept; shared ownership keeps licensing alive. serve_client(client, *licenses); - }}.detach(); // NOSONAR(cpp:S5962): gamepad sessions can outlive accept; shared ownership keeps licensing alive. + }} + .detach(); } ::close(listener); return 1; From fb9545eaa21bab44451e224114604d1bff136bfe Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:56:48 -0400 Subject: [PATCH 09/32] refactor: share broker license policy and expand macOS CI --- .github/workflows/ci-macos-broker.yml | 32 +++- .github/workflows/ci.yml | 14 +- README.md | 2 +- docs/macos-gamepad.md | 144 +++++++++--------- docs/platform-support.md | 12 +- scripts/macos/install.command | 21 +-- scripts/macos/package-dmg.sh | 10 +- src/platform/macos/broker/license_manager.hpp | 1 - src/platform/macos/broker/license_manager.mm | 64 +++----- src/platform/macos/macos_broker_client.cpp | 6 +- .../shared/lvh_broker_license_policy.hpp | 82 ++++++++++ .../windows/broker/libvirtualhid_broker.cpp | 34 +---- .../shared/lvh_windows_broker_config.hpp | 45 +----- .../lvh_windows_github_actions_evaluation.hpp | 51 +------ tests/unit/test_license.cpp | 35 ++++- 15 files changed, 281 insertions(+), 272 deletions(-) create mode 100644 src/platform/shared/lvh_broker_license_policy.hpp diff --git a/.github/workflows/ci-macos-broker.yml b/.github/workflows/ci-macos-broker.yml index 12893449..b444bab7 100644 --- a/.github/workflows/ci-macos-broker.yml +++ b/.github/workflows/ci-macos-broker.yml @@ -53,7 +53,7 @@ jobs: -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_DOCS=OFF \ - -DBUILD_TESTS=OFF \ + -DBUILD_TESTS=ON \ -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ -DLIBVIRTUALHID_WARNINGS_AS_ERRORS=ON @@ -68,6 +68,36 @@ jobs: xcrun lipo "${binary}" -verify_arch x86_64 done + - name: Test broker policy and protocol + run: | + cmake-build-macos-universal/tests/test_libvirtualhid \ + '--gtest_filter=BrokerLicensePolicyTest.*:GitHubActionsEvaluationTest.*:MacosBrokerProtocolTest.*' + + - name: Smoke test broker and license IPC + run: | + broker=cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker + license_cli=cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license + sudo -n env GITHUB_ACTIONS=true "${broker}" > "${RUNNER_TEMP}/virtualhid-broker.log" 2>&1 & + broker_pid=$! + trap 'sudo -n kill "${broker_pid}" 2>/dev/null || true' EXIT + ready=false + for attempt in {1..20}; do + if status_output="$("${license_cli}" status 2>&1)"; then + ready=true + break + fi + sleep 1 + done + if [[ "${ready}" != true ]]; then + cat "${RUNNER_TEMP}/virtualhid-broker.log" + exit 1 + fi + grep -F 'Plan: GitHub Actions Evaluation' <<< "${status_output}" + if "${license_cli}" validate >/dev/null 2>&1; then + echo 'Unlicensed validation unexpectedly succeeded' >&2 + exit 1 + fi + - name: Import Developer ID certificate if: inputs.publish_release == 'true' uses: apple-actions/import-codesign-certs@5142e029c445c10ffc7149d172e540235a065466 # v7.0.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8235dea..f72546aa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,16 +72,16 @@ jobs: publish_release: ${{ needs.setup_release.outputs.publish_release }} release_version: ${{ needs.setup_release.outputs.release_version }} secrets: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - APPLE_NOTARYTOOL_PASSWORD: ${{ secrets.APPLE_NOTARYTOOL_PASSWORD }} - APPLE_CODESIGN_IDENTITY: ${{ secrets.APPLE_CODESIGN_IDENTITY }} + APPLE_ID: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_ID || '' }} + APPLE_TEAM_ID: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_TEAM_ID || '' }} + APPLE_NOTARYTOOL_PASSWORD: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_NOTARYTOOL_PASSWORD || '' }} + APPLE_CODESIGN_IDENTITY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_CODESIGN_IDENTITY || '' }} APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: >- - ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 }} + ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 || '' }} APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: >- - ${{ secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD }} + ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD || '' }} APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64: >- - ${{ secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 }} + ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 || '' }} build: name: Library diff --git a/README.md b/README.md index ca3f5799..0fd5193d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@

🎮 Virtual HID Gamepad License

A license is required for Windows driver-backed devices and macOS virtual gamepads.
- Linux and FreeBSD backends do not currently require a license.
+ Linux and FreeBSD backends do not require a license.
Yearly and lifetime options are available.

Buy a virtual HID license diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 39b00d71..246e3601 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -3,13 +3,13 @@ The macOS backend creates descriptor-driven virtual gamepads through a separate root-owned broker. The broker alone calls Apple's `IOHIDUserDevice` API and holds the virtual HID entitlement. The ordinary C++ library has no Apple entitlement -and continues to use CoreGraphics for keyboard and mouse input. +and uses CoreGraphics for keyboard and mouse input. The client checks root ownership of the broker directory, socket, and connected peer before exchanging versioned messages. Socket transfers handle partial reads and writes so truncated messages are not treated as complete. The root-owned broker directory permits local clients to reach its socket, and -the broker applies the same machine-license gate to gamepad creation as Windows. +the broker checks the machine license before gamepad creation. The built-in generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation @@ -22,61 +22,28 @@ still needs consumer testing for each profile. When metadata omits a stable ID, the client derives a locally administered `02:00:xx:xx:xx:xx` identifier from the device ID. -## What to do in Apple Developer - -The Sunshine **Developer ID Application** signing certificate and existing -notarization credentials can be reused. Apple's HID Virtual Device approval may -be assigned to the team or to a particular App ID. A separate provisioning -profile for this broker's App ID is required even if Sunshine already has one. - -1. Sign in to [Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/identifiers/list) - as the Apple Developer team's **Account Holder**. If the team is an - organization, Apple says the Account Holder must submit managed-capability - requests. -2. Under **Identifiers**, register an explicit macOS App ID with bundle ID - **`dev.lizardbyte.app.libvirtualhid`**. This follows Sunshine's - `dev.lizardbyte.app.Sunshine` naming pattern. If it already exists, open it. -3. If the Sunshine request is still pending, wait for its decision. Then open - the new App ID's **Capabilities** tab. If **HID Virtual Device** is - available from that approval, enable it and save. Otherwise, in - **Capability Requests**, request **HID Virtual Device** - (`com.apple.developer.hid.virtual.device`) for this App ID. Explain that - libvirtualhid is a signed, root-owned user-space broker that publishes - descriptor-driven gamepads to other local applications for remote streaming - hosts. It does not attach to physical hardware or install a kernel driver. - List the generic, Xbox, PlayStation, and Switch Pro profiles and the - broker's paid-license gate. After approval, enable the capability and save. -4. Under **Profiles**, create a **Developer ID** distribution provisioning - profile for `dev.lizardbyte.app.libvirtualhid`, selecting the same Developer - ID Application certificate used for Sunshine. Download the resulting - `.provisionprofile` file. The profile must contain the virtual HID - entitlement. A Mac App Development profile is for local development and - cannot replace the Developer ID distribution profile in the release DMG. -5. In this repository's GitHub Actions secrets, add - **`APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64`** containing a - single-line base64 encoding of the downloaded profile. The downloaded - `.provisionprofile` itself is a signed binary file; base64 is only the text - encoding used to store it in a GitHub secret. On macOS, run - `base64 -i broker.provisionprofile | tr -d '\n'`. On Windows, run this in - PowerShell, replacing the path with the downloaded file's location: - - ```powershell - [Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\to\broker.provisionprofile")) | Set-Clipboard - ``` - - Paste the clipboard contents as the secret value. Configure the existing - Sunshine secret names here as well: `APPLE_ID`, `APPLE_TEAM_ID`, - `APPLE_NOTARYTOOL_PASSWORD`, `APPLE_CODESIGN_IDENTITY`, - `APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64`, and - `APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD`. - -Apple documents the [virtual HID entitlement](https://developer.apple.com/documentation/bundleresources/entitlements/com.apple.developer.hid.virtual.device), -the [managed-capability request steps](https://developer.apple.com/help/account/capabilities/capability-requests), -and why a [daemon with a restricted entitlement needs an app-like bundle and -embedded profile](https://developer.apple.com/documentation/xcode/signing-a-daemon-with-a-restricted-entitlement). -Approval is controlled by Apple; the same certificate does not itself grant -this entitlement. A profile issued for `dev.lizardbyte.app.Sunshine` cannot -authorize `dev.lizardbyte.app.libvirtualhid`. +## Signing prerequisites + +The broker needs a Developer ID provisioning profile for +`dev.lizardbyte.app.libvirtualhid` containing +`com.apple.developer.hid.virtual.device`. An existing Developer ID Application +certificate and notarization credentials can be reused for this Apple team. A +profile for another bundle ID cannot authorize the broker. Apple explains the +[restricted entitlement bundle and embedded profile](https://developer.apple.com/documentation/xcode/signing-a-daemon-with-a-restricted-entitlement). + +Release CI reads the profile from the +`APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64` secret. The profile is a +binary file; on Windows, encode it with PowerShell and paste the clipboard +contents into that secret: + +```powershell +[Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\to\broker.provisionprofile")) | Set-Clipboard +``` + +Release CI also uses `APPLE_CODESIGN_IDENTITY`, +`APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64`, +`APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD`, `APPLE_ID`, +`APPLE_TEAM_ID`, and `APPLE_NOTARYTOOL_PASSWORD`. ## Build and distribute @@ -86,22 +53,28 @@ On macOS with Xcode and CMake installed: export MACOSX_DEPLOYMENT_TARGET=14.2 cmake -S . -B cmake-build-macos-universal \ -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ - -DBUILD_DOCS=OFF -DBUILD_TESTS=OFF + -DCMAKE_BUILD_TYPE=Release -DBUILD_DOCS=OFF -DBUILD_TESTS=ON cmake --build cmake-build-macos-universal --parallel "$(sysctl -n hw.ncpu)" xcrun lipo -info cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker ``` The single resulting executable contains both Apple silicon and Intel slices. -CI sets `MACOSX_DEPLOYMENT_TARGET` at the workflow level, as Sunshine does. -The Apple builds use `-fexperimental-library` for libc++'s `std::jthread` -support, following Sunshine's macOS build configuration. -The CI job checks the broker, license CLI, and -`libvirtualhid.a` with `lipo`. - -For a release, set `APPLE_CODESIGN_IDENTITY` to the Sunshine Developer ID -Application identity, set `APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE` to -the downloaded profile path, and set the existing Sunshine notarization -variables (`APPLE_ID`, `APPLE_TEAM_ID`, `APPLE_NOTARYTOOL_PASSWORD`). Then run: +CI sets `MACOSX_DEPLOYMENT_TARGET` at the workflow level. The Apple builds use +`-fexperimental-library` for libc++'s `std::jthread` support. +The CI job checks the broker, license CLI, and `libvirtualhid.a` with `lipo`. +It runs the shared license-policy and macOS wire-protocol tests, starts the +broker as root, and checks license IPC. These checks do not prove virtual HID +creation: Apple's restricted entitlement needs a matching profile embedded in +the signed app bundle, independent of the runner's System Integrity Protection +setting. +PR workflows receive no Apple signing or notarization secrets, so PR CI does +not sign a package. Debug mode does not change the entitlement requirement. +Use a Mac with the approved profile for a full device test before merging. + +For a release, set `APPLE_CODESIGN_IDENTITY` to the Developer ID Application +identity, set `APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE` to the broker +profile path, and set the notarization variables (`APPLE_ID`, `APPLE_TEAM_ID`, +`APPLE_NOTARYTOOL_PASSWORD`). Then run: ```sh bash scripts/macos/package-dmg.sh cmake-build-macos-universal @@ -110,8 +83,30 @@ bash scripts/macos/package-dmg.sh cmake-build-macos-universal The script embeds the profile, signs the broker app with Hardened Runtime and a secure timestamp, verifies its signature, makes one universal DMG, submits it using `notarytool`, and staples the ticket. Release CI performs these steps -using the same certificate and notarization secret names as Sunshine. The -profile secret is the only new secret. +with the corresponding certificate, profile, and notarization secrets. + +### Test a PR on a Mac mini + +Install Xcode on the Mac mini and select it with +`sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`. +Confirm `xcodebuild -version` works, install CMake, and check out the PR branch. +Import the Developer ID Application `.p12` file through Keychain Access into +the login keychain, entering its export password. Confirm that +`security find-identity -v -p codesigning` lists the certificate **with its +private key**. Keep the approved libvirtualhid `.provisionprofile` on the Mac +mini. +Set `APPLE_CODESIGN_IDENTITY` to that certificate's identity, +`APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE` to the profile's full path, and +`APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_NOTARYTOOL_PASSWORD` to the Apple +notarization values in the local shell. Do not commit these values. + +Run the universal CMake commands above, then run +`bash scripts/macos/package-dmg.sh cmake-build-macos-universal`. This produces a +signed, notarized, stapled DMG from the PR branch. Install it using the steps +below, activate a license, and test each gamepad profile in a macOS consumer. +The certificate and profile are necessary even when System Integrity Protection +is disabled. A locally built unsigned broker can test IPC and licensing, but +cannot establish that virtual gamepad creation works. Mount the DMG and double-click **Install libvirtualhid.command**. It asks for administrator authorization, installs the signed broker app under @@ -153,10 +148,11 @@ retain both license texts. ## License and validation -The macOS broker uses the same Polar organization, yearly and lifetime benefit -IDs, purchase URL, and customer portal as Windows. No unlicensed production -gamepad is created. `lvh::get_license_status()`, `activate_license()`, -`validate_license()`, and `deactivate_license()` talk to the installed broker; +The Windows and macOS brokers share the Polar organization, Yearly and Lifetime +benefit IDs, purchase URL, customer portal, and license time limits. No +unlicensed production gamepad is created. `lvh::get_license_status()`, +`activate_license()`, `validate_license()`, and `deactivate_license()` talk to +the installed broker; the license key never enters the virtual gamepad report stream. A five-minute GitHub Actions evaluation is available only when the broker itself starts in the GitHub Actions environment. diff --git a/docs/platform-support.md b/docs/platform-support.md index dfab5997..a3c5757b 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -438,12 +438,12 @@ feature reports, and Switch Pro initialization replies. macOS presents Xbox 360 as HID rather than Windows XInput/XUSB. Consumer recognition still depends on each game's macOS controller stack and needs installed validation. -Keyboard and mouse input still use CoreGraphics, including UTF-8 text, +Keyboard and mouse input use CoreGraphics for UTF-8 text, portable key translation, modifier state, relative and absolute motion, and -pixel-based scrolling. They follow the normal macOS synthetic-input permission -path where the host requires it. Touchscreen, trackpad, and pen tablet creation +pixel-based scrolling. The host process needs macOS synthetic-input permission +when the system requires it. Touchscreen, trackpad, and pen tablet creation return `unsupported_profile`. -The broker uses the same paid Polar benefits and license API as Windows. See -[macOS gamepad setup](macos-gamepad.md) for the Apple portal request, universal -build, signing, installation, and diagnostics. +Gamepad creation requires a machine license. The broker accepts the Yearly and +Lifetime Polar benefits. See [macOS gamepad setup](macos-gamepad.md) for the +universal build, signing, installation, and diagnostics. diff --git a/scripts/macos/install.command b/scripts/macos/install.command index c21b29c9..8ecb7040 100755 --- a/scripts/macos/install.command +++ b/scripts/macos/install.command @@ -2,9 +2,12 @@ set -euo pipefail image_root="$(cd "$(dirname "$0")" && pwd)" +bundle_id="dev.lizardbyte.app.libvirtualhid" +support_directory="/Library/Application Support/libvirtualhid" +launchd_plist="${bundle_id}.plist" broker_app="${image_root}/usr/local/libexec/libvirtualhid/VirtualHIDBroker.app" -installed_app="/Library/Application Support/libvirtualhid/VirtualHIDBroker.app" -service_plist="/Library/LaunchDaemons/dev.lizardbyte.app.libvirtualhid.plist" +installed_app="${support_directory}/VirtualHIDBroker.app" +service_plist="/Library/LaunchDaemons/${launchd_plist}" if [[ ! -d "${broker_app}" ]]; then echo "The virtual HID broker is missing from this disk image." >&2 @@ -13,21 +16,21 @@ fi /usr/bin/codesign --verify --deep --strict "${broker_app}" /usr/bin/sudo -v -if [[ -L "/Library/Application Support/libvirtualhid" ]]; then +if [[ -L "${support_directory}" ]]; then echo "The libvirtualhid state directory must not be a symbolic link." >&2 exit 1 fi -/usr/bin/sudo /bin/mkdir -p "/Library/Application Support/libvirtualhid" /usr/local/lib /usr/local/include /usr/local/share/licenses -/usr/bin/sudo /usr/sbin/chown root:wheel "/Library/Application Support/libvirtualhid" -/usr/bin/sudo /bin/chmod 700 "/Library/Application Support/libvirtualhid" -/usr/bin/sudo /bin/launchctl bootout system/dev.lizardbyte.app.libvirtualhid 2>/dev/null || true +/usr/bin/sudo /bin/mkdir -p "${support_directory}" /usr/local/lib /usr/local/include /usr/local/share/licenses +/usr/bin/sudo /usr/sbin/chown root:wheel "${support_directory}" +/usr/bin/sudo /bin/chmod 700 "${support_directory}" +/usr/bin/sudo /bin/launchctl bootout "system/${bundle_id}" 2>/dev/null || true /usr/bin/sudo /bin/rm -rf "${installed_app}" /usr/bin/sudo /usr/bin/ditto "${broker_app}" "${installed_app}" /usr/bin/sudo /usr/sbin/chown -R root:wheel "${installed_app}" /usr/bin/sudo /bin/chmod -R go-w "${installed_app}" /usr/bin/sudo /usr/bin/codesign --verify --deep --strict "${installed_app}" /usr/bin/sudo /usr/bin/install -m 0644 \ - "${image_root}/dev.lizardbyte.app.libvirtualhid.plist" "${service_plist}" + "${image_root}/${launchd_plist}" "${service_plist}" /usr/bin/sudo /usr/sbin/chown root:wheel "${service_plist}" if [[ -d "${image_root}/usr/local/include/libvirtualhid" ]]; then @@ -49,5 +52,5 @@ if [[ -d "${image_root}/usr/local/share/licenses/libvirtualhid" ]]; then fi /usr/bin/sudo /bin/launchctl bootstrap system "${service_plist}" -/usr/bin/sudo /bin/launchctl kickstart -k system/dev.lizardbyte.app.libvirtualhid +/usr/bin/sudo /bin/launchctl kickstart -k "system/${bundle_id}" echo "libvirtualhid broker installed. Run: /usr/local/bin/libvirtualhid-license activate" diff --git a/scripts/macos/package-dmg.sh b/scripts/macos/package-dmg.sh index bd7226b7..389a7833 100755 --- a/scripts/macos/package-dmg.sh +++ b/scripts/macos/package-dmg.sh @@ -4,6 +4,8 @@ set -euo pipefail repository_root="$(cd "$(dirname "$0")/../.." && pwd)" +bundle_id="dev.lizardbyte.app.libvirtualhid" +launchd_plist="${bundle_id}.plist" build_directory="${1:-${repository_root}/cmake-build-macos-universal}" output_directory="${2:-${repository_root}/cmake-build-macos-universal/artifacts}" build_directory="$(cd "${build_directory}" && pwd)" @@ -17,7 +19,7 @@ signing_identity="${APPLE_CODESIGN_IDENTITY:-}" if [[ -z "${signing_identity}" || -z "${profile_path}" || ! -f "${profile_path}" || -z "${APPLE_ID:-}" || -z "${APPLE_TEAM_ID:-}" || -z "${APPLE_NOTARYTOOL_PASSWORD:-}" ]]; then - echo "A Developer ID identity, approved virtual HID profile, and Sunshine notarization credentials are required." >&2 + echo "A Developer ID identity, approved virtual HID profile, and notarization credentials are required." >&2 exit 1 fi @@ -27,7 +29,7 @@ DESTDIR="${image_root}" cmake --install "${build_directory}" --prefix /usr/local profile_details="${stage_directory}/profile.plist" /usr/bin/security cms -D -i "${profile_path}" > "${profile_details}" -/usr/bin/python3 - "${profile_details}" "${APPLE_TEAM_ID}" <<'PY' +/usr/bin/python3 - "${profile_details}" "${APPLE_TEAM_ID}" "${bundle_id}" <<'PY' import plistlib import sys @@ -35,7 +37,7 @@ with open(sys.argv[1], "rb") as profile_file: profile = plistlib.load(profile_file) entitlements = profile.get("Entitlements", {}) app_id = entitlements.get("com.apple.application-identifier", "") -if app_id != f"{sys.argv[2]}.dev.lizardbyte.app.libvirtualhid": +if app_id != f"{sys.argv[2]}.{sys.argv[3]}": raise SystemExit("Provisioning profile has the wrong App ID") if sys.argv[2] not in profile.get("TeamIdentifier", []): raise SystemExit("Provisioning profile belongs to another Apple team") @@ -55,7 +57,7 @@ cp "${profile_path}" "${broker_app}/Contents/embedded.provisionprofile" "${image_root}/usr/local/bin/libvirtualhid-license" cp "${repository_root}/scripts/macos/install.command" "${image_root}/Install libvirtualhid.command" -cp "${repository_root}/scripts/macos/dev.lizardbyte.app.libvirtualhid.plist" "${image_root}/" +cp "${repository_root}/scripts/macos/${launchd_plist}" "${image_root}/" cp "${repository_root}/LICENSE.md" "${image_root}/" cp -R "${repository_root}/LICENSES" "${image_root}/" diff --git a/src/platform/macos/broker/license_manager.hpp b/src/platform/macos/broker/license_manager.hpp index 7d2a17f4..8d9b81b2 100644 --- a/src/platform/macos/broker/license_manager.hpp +++ b/src/platform/macos/broker/license_manager.hpp @@ -43,7 +43,6 @@ namespace lvh::detail::macos_broker { Message deactivate(); Message status(); bool licensed_locked() const; - bool yearly_locked() const; void fill_status_locked(Message &response) const; void background_validation(std::stop_token stop); diff --git a/src/platform/macos/broker/license_manager.mm b/src/platform/macos/broker/license_manager.mm index 8a3284a9..4ca84f88 100644 --- a/src/platform/macos/broker/license_manager.mm +++ b/src/platform/macos/broker/license_manager.mm @@ -8,7 +8,7 @@ #include "license_manager.hpp" -#include "platform/windows/shared/lvh_windows_broker_config.hpp" +#include "platform/shared/lvh_broker_license_policy.hpp" #include #include @@ -25,10 +25,6 @@ constexpr auto state_directory = "/Library/Application Support/libvirtualhid"; constexpr auto state_path = "/Library/Application Support/libvirtualhid/license.json"; constexpr auto evaluation_path = "/Library/Application Support/libvirtualhid/evaluation.json"; - constexpr auto validation_interval = std::chrono::hours {24}; - constexpr auto subscription_max_age = std::chrono::hours {25}; - constexpr auto outage_retention = std::chrono::hours {1}; - constexpr auto evaluation_duration = std::chrono::minutes {5}; template void set_text(std::array &destination, std::string_view value) { @@ -163,25 +159,6 @@ ApiResult polar_request(NSString *endpoint, NSDictionary *body) { return result; } - bool allowed_benefit(std::string_view benefit_id, bool &yearly) { - for (const auto &benefit : windows::broker_config::allowed_benefits) { - if (benefit.id == benefit_id) { - yearly = benefit.subscription_backed; - return true; - } - } - return false; - } - - std::string plan_name(std::string_view benefit_id) { - for (const auto &benefit : windows::broker_config::allowed_benefits) { - if (benefit.id == benefit_id) { - return std::string {benefit.plan_name}; - } - } - return ""; - } - bool valid_c_string(const std::array &value) { return std::memchr(value.data(), '\0', value.size()) != nullptr; } @@ -201,8 +178,7 @@ bool valid_c_string(const std::array &value) { state.benefit_id = from_ns(json_string(saved, @"benefit_id")); state.customer_email = from_ns(json_string(saved, @"customer_email")); state.activation_limit = [saved[@"activation_limit"] unsignedIntValue]; - bool yearly = false; - if (!state.key.empty() && !state.activation_id.empty() && state.status == "granted" && state.organization_id == windows::broker_config::polar_organization_id && allowed_benefit(state.benefit_id, yearly)) { + if (!state.key.empty() && !state.activation_id.empty() && state.status == "granted" && state.organization_id == broker_license::polar_organization_id && broker_license::benefit(state.benefit_id)) { state_ = std::move(state); } } @@ -229,20 +205,16 @@ bool valid_c_string(const std::array &value) { validator_.request_stop(); } - bool LicenseManager::yearly_locked() const { - bool yearly = false; - return state_ && allowed_benefit(state_->benefit_id, yearly) && yearly; - } - bool LicenseManager::licensed_locked() const { - if (!state_ || state_->status != "granted" || state_->organization_id != windows::broker_config::polar_organization_id) { + if (!state_ || state_->status != "granted" || state_->organization_id != broker_license::polar_organization_id) { return false; } - bool yearly = false; - if (!allowed_benefit(state_->benefit_id, yearly)) { + const auto *benefit = broker_license::benefit(state_->benefit_id); + if (!benefit) { return false; } - return !yearly || (validated_at_ && std::chrono::steady_clock::now() - *validated_at_ < subscription_max_age); + return !benefit->subscription_backed || + (validated_at_ && std::chrono::steady_clock::now() - *validated_at_ < broker_license::subscription_max_age); } void LicenseManager::fill_status_locked(Message &response) const { @@ -255,7 +227,7 @@ bool valid_c_string(const std::array &value) { response.license_state = static_cast(licensed_locked() ? LicenseState::licensed : LicenseState::invalid); response.activation_limit = state_->activation_limit; response.activation_usage = 1; - set_text(response.plan_name, plan_name(state_->benefit_id)); + set_text(response.plan_name, broker_license::plan_name(state_->benefit_id)); set_text(response.customer_email, state_->customer_email); } @@ -280,7 +252,7 @@ bool valid_c_string(const std::array &value) { @autoreleasepool { NSString *name = request.instance_name[0] ? @(request.instance_name.data()) : [[NSHost currentHost] localizedName]; auto result = polar_request(@"/v1/customer-portal/license-keys/activate", @{@"key": @(request.license_key.data()), - @"organization_id": to_ns(windows::broker_config::polar_organization_id), + @"organization_id": to_ns(broker_license::polar_organization_id), @"label": name ? name : @"Mac"}); auto response = status(); if (!result.transport_ok || result.status != 200) { @@ -315,8 +287,7 @@ bool valid_c_string(const std::array &value) { if ([customer isKindOfClass:[NSDictionary class]]) { state.customer_email = from_ns(json_string(customer, @"email")); } - bool yearly = false; - if (state.activation_id.empty() || state.status != "granted" || state.organization_id != windows::broker_config::polar_organization_id || !allowed_benefit(state.benefit_id, yearly)) { + if (state.activation_id.empty() || state.status != "granted" || state.organization_id != broker_license::polar_organization_id || !broker_license::benefit(state.benefit_id)) { response.status = static_cast(ErrorCode::license_invalid); set_text(response.message, "License organization, benefit, or activation is not allowed"); return response; @@ -363,7 +334,7 @@ bool valid_c_string(const std::array &value) { @autoreleasepool { auto result = polar_request(@"/v1/customer-portal/license-keys/validate", @ { @"key": to_ns(state.key), - @"organization_id": to_ns(windows::broker_config::polar_organization_id), + @"organization_id": to_ns(broker_license::polar_organization_id), @"activation_id": to_ns(state.activation_id) }); if (!result.transport_ok || result.status != 200 || !result.body || !result.trusted_time) { @@ -392,8 +363,7 @@ bool valid_c_string(const std::array &value) { const auto new_status = from_ns(json_string(result.body, @"status")); const auto new_organization = from_ns(json_string(result.body, @"organization_id")); const auto new_benefit = from_ns(json_string(result.body, @"benefit_id")); - bool yearly = false; - if (from_ns(activation_id) != state.activation_id || new_status != "granted" || new_organization != windows::broker_config::polar_organization_id || !allowed_benefit(new_benefit, yearly)) { + if (from_ns(activation_id) != state.activation_id || new_status != "granted" || new_organization != broker_license::polar_organization_id || !broker_license::benefit(new_benefit)) { { std::lock_guard lock {mutex_}; state_.reset(); @@ -454,7 +424,7 @@ bool valid_c_string(const std::array &value) { @autoreleasepool { auto result = polar_request(@"/v1/customer-portal/license-keys/deactivate", @ { @"key": to_ns(state.key), - @"organization_id": to_ns(windows::broker_config::polar_organization_id), + @"organization_id": to_ns(broker_license::polar_organization_id), @"activation_id": to_ns(state.activation_id) }); if (!result.transport_ok || (result.status != 204 && result.status != 404)) { @@ -524,7 +494,7 @@ bool valid_c_string(const std::array &value) { } evaluation_started_at_ = now; } - if (now >= *evaluation_started_at_ && now < *evaluation_started_at_ + evaluation_duration) { + if (broker_license::github_actions_evaluation::active(*evaluation_started_at_, now)) { evaluation = true; return true; } @@ -538,12 +508,12 @@ bool valid_c_string(const std::array &value) { std::lock_guard lock {mutex_}; if (evaluation) { const auto now = std::chrono::system_clock::now(); - return evaluation_started_at_ && now >= *evaluation_started_at_ && now < *evaluation_started_at_ + evaluation_duration; + return evaluation_started_at_ && broker_license::github_actions_evaluation::active(*evaluation_started_at_, now); } if (!licensed_locked()) { return false; } - return !unavailable_since_ || std::chrono::steady_clock::now() - *unavailable_since_ < outage_retention; + return !unavailable_since_ || !broker_license::outage_retention_elapsed(std::chrono::steady_clock::now() - *unavailable_since_); } void LicenseManager::add_device(bool evaluation) { @@ -573,7 +543,7 @@ bool valid_c_string(const std::array &value) { bool due = false; { std::lock_guard lock {mutex_}; - due = state_ && (!validated_at_ || std::chrono::steady_clock::now() - *validated_at_ >= validation_interval); + due = state_ && (!validated_at_ || std::chrono::steady_clock::now() - *validated_at_ >= broker_license::validation_interval); } if (due) { static_cast(validate()); diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index b1f2821e..7f47502e 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -6,7 +6,7 @@ #include "platform/macos/macos_broker_client.hpp" #include "platform/macos/broker/io.hpp" -#include "platform/windows/shared/lvh_windows_broker_config.hpp" +#include "platform/shared/lvh_broker_license_policy.hpp" #include #include @@ -226,8 +226,8 @@ namespace lvh::detail { LicenseResult license_call(macos_broker::Message request) { LicenseResult result; - result.license.purchase_url = windows::broker_config::buy_url; - result.license.manage_account_url = windows::broker_config::manage_account_url; + result.license.purchase_url = broker_license::buy_url; + result.license.manage_account_url = broker_license::manage_account_url; std::string error; const int fd = macos_broker::connect_to_broker(error); if (fd < 0) { diff --git a/src/platform/shared/lvh_broker_license_policy.hpp b/src/platform/shared/lvh_broker_license_policy.hpp new file mode 100644 index 00000000..57735976 --- /dev/null +++ b/src/platform/shared/lvh_broker_license_policy.hpp @@ -0,0 +1,82 @@ +// SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC +// SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 + +/** + * @file src/platform/shared/lvh_broker_license_policy.hpp + * @brief Platform-neutral Polar benefits and broker license time limits. + */ +#pragma once + +#include +#include +#include +#include +#include + +namespace lvh::broker_license { + + struct PolarBenefit { + std::string_view id; + std::string_view plan_name; + bool subscription_backed; + }; + + inline constexpr std::string_view polar_organization_id = "3db9f05a-44d7-42f1-ba7c-a0f198235fb7"; + inline constexpr auto allowed_benefits = std::array { + PolarBenefit {.id = "eb316dac-bf6a-4359-95a2-86c299d48ecc", .plan_name = "Yearly", .subscription_backed = true}, + PolarBenefit {.id = "157374cb-f526-4154-81ba-9f2c92a053ca", .plan_name = "Lifetime", .subscription_backed = false}, + }; + + inline constexpr std::string_view buy_url = "https://buy.polar.sh/polar_cl_zj6Io5NVukXfZSl97ULtFvImfI5L1jbL2cSnc0Y72Pt"; + inline constexpr std::string_view manage_account_url = "https://polar.sh/lizardbyte-llc/portal"; + + inline constexpr auto validation_interval = std::chrono::hours {24}; + inline constexpr auto validation_retry_interval = std::chrono::seconds {60}; + inline constexpr auto outage_retention = std::chrono::hours {1}; + inline constexpr auto subscription_max_age = validation_interval + outage_retention; + inline constexpr std::size_t unvalidated_active_device_limit = 1U; + + constexpr const PolarBenefit *benefit(std::string_view benefit_id) noexcept { + for (const auto &candidate : allowed_benefits) { + if (candidate.id == benefit_id) { + return &candidate; + } + } + return nullptr; + } + + constexpr std::string_view plan_name(std::string_view benefit_id) noexcept { + const auto *found = benefit(benefit_id); + return found ? found->plan_name : std::string_view {}; + } + + constexpr bool subscription_current(std::uint64_t validated_at, std::uint64_t effective_timestamp) noexcept { + constexpr auto maximum_age = std::chrono::duration_cast(subscription_max_age).count(); + return validated_at != 0U && effective_timestamp >= validated_at && + effective_timestamp - validated_at < static_cast(maximum_age); + } + + template + constexpr bool outage_retention_elapsed(Duration elapsed) noexcept { + return elapsed >= outage_retention; + } + + namespace github_actions_evaluation { + + using Clock = std::chrono::system_clock; + inline constexpr auto duration = std::chrono::minutes {5}; + + constexpr bool active(Clock::time_point started_at, Clock::time_point now) noexcept { + return now >= started_at && now < started_at + duration; + } + + constexpr std::chrono::seconds remaining(Clock::time_point started_at, Clock::time_point now) noexcept { + if (!active(started_at, now)) { + return std::chrono::seconds::zero(); + } + return std::chrono::ceil(started_at + duration - now); + } + + } // namespace github_actions_evaluation + +} // namespace lvh::broker_license diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp index abb31ab0..78d58097 100644 --- a/src/platform/windows/broker/libvirtualhid_broker.cpp +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -86,12 +86,10 @@ namespace lvh::detail::windows_broker_service { L"Accept: application/json\r\n" L"Content-Type: application/json\r\n" L"Polar-Version: 2026-04\r\n"; - constexpr auto license_validation_interval = std::chrono::days {1}; - constexpr auto license_validation_retry_interval = std::chrono::seconds {60}; - constexpr auto license_outage_device_retention = std::chrono::hours {1}; - constexpr auto subscription_validation_max_age = - license_validation_interval + license_outage_device_retention; - constexpr std::size_t unvalidated_active_device_limit = 1U; + constexpr auto license_validation_interval = lvh::broker_license::validation_interval; + constexpr auto license_validation_retry_interval = lvh::broker_license::validation_retry_interval; + constexpr auto license_outage_device_retention = lvh::broker_license::outage_retention; + constexpr std::size_t unvalidated_active_device_limit = lvh::broker_license::unvalidated_active_device_limit; constexpr auto boot_session_registry_path = L"SYSTEM\\CurrentControlSet\\Services\\libvirtualhid_broker\\Runtime"; constexpr auto boot_session_registry_value = L"BootMarker"; @@ -243,14 +241,7 @@ namespace lvh::detail::windows_broker_service { std::uint64_t validated_at, std::uint64_t effective_timestamp ) { - const auto maximum_age = static_cast( - std::chrono::duration_cast( - subscription_validation_max_age - ) - .count() - ); - return validated_at != 0U && effective_timestamp >= validated_at && - effective_timestamp - validated_at < maximum_age; + return lvh::broker_license::subscription_current(validated_at, effective_timestamp); } bool unvalidated_device_creation_allowed( @@ -262,7 +253,7 @@ namespace lvh::detail::windows_broker_service { bool license_outage_retention_elapsed( LicenseValidationClock::duration elapsed ) { - return elapsed >= license_outage_device_retention; + return lvh::broker_license::outage_retention_elapsed(elapsed); } bool license_outage_device_should_be_revoked( @@ -1066,20 +1057,11 @@ namespace lvh::detail::windows_broker_service { const lvh::windows::broker_config::PolarBenefit *polar_benefit( std::string_view benefit_id ) { - const auto benefit = std::ranges::find_if( - lvh::windows::broker_config::allowed_benefits, - [benefit_id](const auto &candidate) { - return candidate.id == benefit_id; - } - ); - return benefit == lvh::windows::broker_config::allowed_benefits.end() ? - nullptr : - std::to_address(benefit); + return lvh::broker_license::benefit(benefit_id); } std::string_view plan_name_for_benefit(std::string_view benefit_id) { - const auto *benefit = polar_benefit(benefit_id); - return benefit == nullptr ? std::string_view {} : benefit->plan_name; + return lvh::broker_license::plan_name(benefit_id); } bool session_token_matches( diff --git a/src/platform/windows/shared/lvh_windows_broker_config.hpp b/src/platform/windows/shared/lvh_windows_broker_config.hpp index ebd3ed6e..49b04b63 100644 --- a/src/platform/windows/shared/lvh_windows_broker_config.hpp +++ b/src/platform/windows/shared/lvh_windows_broker_config.hpp @@ -1,47 +1,10 @@ // SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 -/** - * @file src/platform/windows/shared/lvh_windows_broker_config.hpp - * @brief Compiled Windows broker licensing constants. - * - * Update this file when Polar organization, benefit, or purchase URL changes. - */ #pragma once -#include -#include +#include "../../shared/lvh_broker_license_policy.hpp" -namespace lvh::windows::broker_config { - - struct PolarBenefit { - std::string_view id; - std::string_view plan_name; - bool subscription_backed; - }; - - // Polar's public license API identifies the organization and license-key benefit, - // not the product. Restrict production licenses to this organization's yearly and - // lifetime license-key benefits. - inline constexpr auto polar_organization_id = - std::string_view {"3db9f05a-44d7-42f1-ba7c-a0f198235fb7"}; - inline constexpr auto allowed_benefits = std::array { - PolarBenefit { - .id = "eb316dac-bf6a-4359-95a2-86c299d48ecc", - .plan_name = "Yearly", - .subscription_backed = true, - }, - PolarBenefit { - .id = "157374cb-f526-4154-81ba-9f2c92a053ca", - .plan_name = "Lifetime", - .subscription_backed = false, - }, - }; - - // Use persistent Polar Checkout Links and the organization's hosted customer portal. - inline constexpr auto buy_url = - std::string_view {"https://buy.polar.sh/polar_cl_zj6Io5NVukXfZSl97ULtFvImfI5L1jbL2cSnc0Y72Pt"}; - inline constexpr auto manage_account_url = - std::string_view {"https://polar.sh/lizardbyte-llc/portal"}; - -} // namespace lvh::windows::broker_config +namespace lvh::windows { + namespace broker_config = ::lvh::broker_license; +} diff --git a/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp b/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp index 76c5c5e6..ca40b9ce 100644 --- a/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp +++ b/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp @@ -1,53 +1,10 @@ // SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 -/** - * @file src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp - * @brief Time-window helpers for the GitHub Actions gamepad evaluation exception. - */ #pragma once -// standard includes -#include +#include "../../shared/lvh_broker_license_policy.hpp" -namespace lvh::windows::github_actions_evaluation { - - using Clock = std::chrono::system_clock; - - /** - * @brief Maximum unlicensed gamepad evaluation window on GitHub-hosted CI. - */ - inline constexpr auto duration = std::chrono::minutes {5}; - - /** - * @brief Check whether an evaluation window is active. - * - * A clock earlier than the persisted start is treated as expired so rolling - * the system clock backward cannot extend the window. - * - * @param started_at Persisted start of the evaluation window. - * @param now Current wall-clock time. - * @return `true` from the start instant until, but not including, its deadline. - */ - constexpr bool active(Clock::time_point started_at, Clock::time_point now) noexcept { - return now >= started_at && now < started_at + duration; - } - - /** - * @brief Calculate display seconds remaining in an evaluation window. - * - * @param started_at Persisted start of the evaluation window. - * @param now Current wall-clock time. - * @return Remaining seconds rounded up, or zero when the window is inactive. - */ - constexpr std::chrono::seconds remaining( - Clock::time_point started_at, - Clock::time_point now - ) noexcept { - if (!active(started_at, now)) { - return std::chrono::seconds::zero(); - } - return std::chrono::ceil(started_at + duration - now); - } - -} // namespace lvh::windows::github_actions_evaluation +namespace lvh::windows { + namespace github_actions_evaluation = ::lvh::broker_license::github_actions_evaluation; +} diff --git a/tests/unit/test_license.cpp b/tests/unit/test_license.cpp index 6be152fd..f2fd8b16 100644 --- a/tests/unit/test_license.cpp +++ b/tests/unit/test_license.cpp @@ -7,7 +7,7 @@ #include // local includes -#include "lvh_windows_github_actions_evaluation.hpp" +#include "platform/shared/lvh_broker_license_policy.hpp" // lib includes #include @@ -31,11 +31,36 @@ TEST(LicenseStatusTest, LicensedReflectsCurrentState) { EXPECT_FALSE(status.licensed()); } +TEST(BrokerLicensePolicyTest, AcceptsOnlyConfiguredBenefits) { + const auto *yearly = lvh::broker_license::benefit(lvh::broker_license::allowed_benefits[0].id); + const auto *lifetime = lvh::broker_license::benefit(lvh::broker_license::allowed_benefits[1].id); + ASSERT_NE(yearly, nullptr); + ASSERT_NE(lifetime, nullptr); + EXPECT_TRUE(yearly->subscription_backed); + EXPECT_FALSE(lifetime->subscription_backed); + EXPECT_EQ(lvh::broker_license::plan_name(yearly->id), "Yearly"); + EXPECT_EQ(lvh::broker_license::plan_name(lifetime->id), "Lifetime"); + EXPECT_EQ(lvh::broker_license::benefit("unrecognized-benefit"), nullptr); +} + +TEST(BrokerLicensePolicyTest, EnforcesSubscriptionAndOutageBoundaries) { + using namespace std::chrono_literals; + constexpr auto start = 1000U; + constexpr auto max_age = std::chrono::duration_cast(lvh::broker_license::subscription_max_age).count(); + EXPECT_TRUE(lvh::broker_license::subscription_current(start, start)); + EXPECT_TRUE(lvh::broker_license::subscription_current(start, start + max_age - 1)); + EXPECT_FALSE(lvh::broker_license::subscription_current(start, start + max_age)); + EXPECT_FALSE(lvh::broker_license::subscription_current(start, start - 1)); + EXPECT_FALSE(lvh::broker_license::subscription_current(0, start)); + EXPECT_FALSE(lvh::broker_license::outage_retention_elapsed(1h - 1ms)); + EXPECT_TRUE(lvh::broker_license::outage_retention_elapsed(1h)); +} + TEST(GitHubActionsEvaluationTest, IsActiveOnlyInsideFiveMinuteWindow) { using namespace std::chrono_literals; - using lvh::windows::github_actions_evaluation::active; + using lvh::broker_license::github_actions_evaluation::active; - const auto started_at = lvh::windows::github_actions_evaluation::Clock::time_point {1000s}; + const auto started_at = lvh::broker_license::github_actions_evaluation::Clock::time_point {1000s}; EXPECT_TRUE(active(started_at, started_at)); EXPECT_TRUE(active(started_at, started_at + 5min - 1s)); EXPECT_FALSE(active(started_at, started_at + 5min)); @@ -44,9 +69,9 @@ TEST(GitHubActionsEvaluationTest, IsActiveOnlyInsideFiveMinuteWindow) { TEST(GitHubActionsEvaluationTest, RemainingTimeClampsAtWindowBoundaries) { using namespace std::chrono_literals; - using lvh::windows::github_actions_evaluation::remaining; + using lvh::broker_license::github_actions_evaluation::remaining; - const auto started_at = lvh::windows::github_actions_evaluation::Clock::time_point {1000s}; + const auto started_at = lvh::broker_license::github_actions_evaluation::Clock::time_point {1000s}; EXPECT_EQ(remaining(started_at, started_at), 5min); EXPECT_EQ(remaining(started_at, started_at + 4min), 1min); EXPECT_EQ(remaining(started_at, started_at + 5min - 500ms), 1s); From 1984478ee8129ec46108b2321fa6a55ac59ed603 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:10:19 -0400 Subject: [PATCH 10/32] ci: keep Apple secret expressions within YAML lint limit --- .github/workflows/ci.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f72546aa..0134aec5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,16 +72,16 @@ jobs: publish_release: ${{ needs.setup_release.outputs.publish_release }} release_version: ${{ needs.setup_release.outputs.release_version }} secrets: - APPLE_ID: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_ID || '' }} - APPLE_TEAM_ID: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_TEAM_ID || '' }} - APPLE_NOTARYTOOL_PASSWORD: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_NOTARYTOOL_PASSWORD || '' }} - APPLE_CODESIGN_IDENTITY: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_CODESIGN_IDENTITY || '' }} + APPLE_ID: ${{ github.event_name == 'push' && secrets.APPLE_ID || '' }} + APPLE_TEAM_ID: ${{ github.event_name == 'push' && secrets.APPLE_TEAM_ID || '' }} + APPLE_NOTARYTOOL_PASSWORD: ${{ github.event_name == 'push' && secrets.APPLE_NOTARYTOOL_PASSWORD || '' }} + APPLE_CODESIGN_IDENTITY: ${{ github.event_name == 'push' && secrets.APPLE_CODESIGN_IDENTITY || '' }} APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64: >- - ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 || '' }} + ${{ github.event_name == 'push' && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 || '' }} APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD: >- - ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD || '' }} + ${{ github.event_name == 'push' && secrets.APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD || '' }} APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64: >- - ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 || '' }} + ${{ github.event_name == 'push' && secrets.APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 || '' }} build: name: Library From bf1419c755b242d028354d9cf9f27163b99ffe35 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:13:15 -0400 Subject: [PATCH 11/32] ci: satisfy ShellCheck in macOS broker smoke test --- .github/workflows/ci-macos-broker.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci-macos-broker.yml b/.github/workflows/ci-macos-broker.yml index b444bab7..963ee1e4 100644 --- a/.github/workflows/ci-macos-broker.yml +++ b/.github/workflows/ci-macos-broker.yml @@ -77,11 +77,11 @@ jobs: run: | broker=cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker license_cli=cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license - sudo -n env GITHUB_ACTIONS=true "${broker}" > "${RUNNER_TEMP}/virtualhid-broker.log" 2>&1 & + sudo -n env GITHUB_ACTIONS=true "${broker}" & broker_pid=$! trap 'sudo -n kill "${broker_pid}" 2>/dev/null || true' EXIT ready=false - for attempt in {1..20}; do + for (( attempt = 0; attempt < 20; ++attempt )); do if status_output="$("${license_cli}" status 2>&1)"; then ready=true break @@ -89,7 +89,7 @@ jobs: sleep 1 done if [[ "${ready}" != true ]]; then - cat "${RUNNER_TEMP}/virtualhid-broker.log" + echo 'Broker did not become ready' >&2 exit 1 fi grep -F 'Plan: GitHub Actions Evaluation' <<< "${status_output}" From e079d4bcdf011b7adc95f456cc04ad76bf9b6b28 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:18:30 -0400 Subject: [PATCH 12/32] fix: remove obsolete Windows license constant --- src/platform/windows/broker/libvirtualhid_broker.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/windows/broker/libvirtualhid_broker.cpp b/src/platform/windows/broker/libvirtualhid_broker.cpp index 78d58097..00b64e86 100644 --- a/src/platform/windows/broker/libvirtualhid_broker.cpp +++ b/src/platform/windows/broker/libvirtualhid_broker.cpp @@ -88,7 +88,6 @@ namespace lvh::detail::windows_broker_service { L"Polar-Version: 2026-04\r\n"; constexpr auto license_validation_interval = lvh::broker_license::validation_interval; constexpr auto license_validation_retry_interval = lvh::broker_license::validation_retry_interval; - constexpr auto license_outage_device_retention = lvh::broker_license::outage_retention; constexpr std::size_t unvalidated_active_device_limit = lvh::broker_license::unvalidated_active_device_limit; constexpr auto boot_session_registry_path = L"SYSTEM\\CurrentControlSet\\Services\\libvirtualhid_broker\\Runtime"; From c1973f705c3b3d5b6a75e0fa4a455de31a8c0893 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:30:48 -0400 Subject: [PATCH 13/32] test: use shared license limits in Windows broker fixtures --- tests/fixtures/windows_broker_service_test_hooks.cpp | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/windows_broker_service_test_hooks.cpp b/tests/fixtures/windows_broker_service_test_hooks.cpp index 9d9947f9..1a503387 100644 --- a/tests/fixtures/windows_broker_service_test_hooks.cpp +++ b/tests/fixtures/windows_broker_service_test_hooks.cpp @@ -6,6 +6,8 @@ // local includes #include "fixtures/windows_broker_service_test_hooks.hpp" +#include "platform/shared/lvh_broker_license_policy.hpp" + #ifndef NOMINMAX #define NOMINMAX #endif @@ -500,7 +502,7 @@ namespace lvh::detail::test { using namespace lvh::detail::windows_broker_service; const auto subscription_validation_seconds = static_cast( std::chrono::duration_cast( - subscription_validation_max_age + lvh::broker_license::subscription_max_age ) .count() ); @@ -557,10 +559,10 @@ namespace lvh::detail::test { .second_unvalidated_device_is_rejected = !unvalidated_device_creation_allowed(1U), .existing_gamepads_are_retained_before_one_hour = !license_outage_retention_elapsed( - license_outage_device_retention - std::chrono::milliseconds {1} + lvh::broker_license::outage_retention - std::chrono::milliseconds {1} ), .outage_limit_applies_at_one_hour = license_outage_retention_elapsed( - license_outage_device_retention + lvh::broker_license::outage_retention ), .first_gamepad_is_retained_after_one_hour = !license_outage_device_should_be_revoked(false, true, 0U), From 97164f7cbf7d2d4fa9fea5cd541f11014eea3840 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:05:24 -0400 Subject: [PATCH 14/32] ci: scope coverage to macOS code exercised by hosted runners --- codecov.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..0b391a44 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,8 @@ +# The hosted coverage matrix does not collect coverage from the root-owned +# broker or its IPC client, and cannot exercise signed virtual HID creation. +# The macOS broker CI job tests IPC and licensing behavior separately. +ignore: + - "src/platform/macos/broker/io.hpp" + - "src/platform/macos/broker/libvirtualhid_macos_broker.cpp" + - "src/platform/macos/broker/license_cli.cpp" + - "src/platform/macos/macos_broker_client.cpp" From 9897aa7f1dd83db97f906e96f71fa65122dc82be Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:10:42 -0400 Subject: [PATCH 15/32] ci: add YAML document start to Codecov config --- codecov.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/codecov.yml b/codecov.yml index 0b391a44..499c76e2 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,3 +1,4 @@ +--- # The hosted coverage matrix does not collect coverage from the root-owned # broker or its IPC client, and cannot exercise signed virtual HID creation. # The macOS broker CI job tests IPC and licensing behavior separately. From 1ec75007d8c001bef88ae22222a1cfcfd90a79ab Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 10:15:15 -0400 Subject: [PATCH 16/32] ci: retain full Codecov coverage reporting --- codecov.yml | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 codecov.yml diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 499c76e2..00000000 --- a/codecov.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -# The hosted coverage matrix does not collect coverage from the root-owned -# broker or its IPC client, and cannot exercise signed virtual HID creation. -# The macOS broker CI job tests IPC and licensing behavior separately. -ignore: - - "src/platform/macos/broker/io.hpp" - - "src/platform/macos/broker/libvirtualhid_macos_broker.cpp" - - "src/platform/macos/broker/license_cli.cpp" - - "src/platform/macos/macos_broker_client.cpp" From 968856478aa5c013d22a709fc9e891d6d57b8c22 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:32:41 -0400 Subject: [PATCH 17/32] Add local macOS PR build and install helper --- .env.example | 26 ++++ .gitignore | 3 + docs/macos-gamepad.md | 51 ++++---- scripts/macos/build-and-install.sh | 196 +++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 22 deletions(-) create mode 100644 .env.example create mode 100755 scripts/macos/build-and-install.sh diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..4a3120f6 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Local macOS PR build settings. Copy this file to .env; .env is ignored by Git. +# Paste values after = without quotes, even for paths containing spaces. +# Use full paths beginning with /Users/... for local files. + +# Apple ID used for notarization, and its app-specific password. +APPLE_ID= +APPLE_NOTARYTOOL_PASSWORD= + +# The original Developer ID Application .p12 file and its export password. +# Leave the file/password blank only if the certificate and private key are +# already available in this Mac's Keychain. +APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_FILE= +APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD= + +# Downloaded provisioning profile for dev.lizardbyte.app.libvirtualhid. +# A profile for another bundle ID will not work. +APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE= + +# Optional: the script detects these from the profile and certificate. +APPLE_TEAM_ID= +APPLE_CODESIGN_IDENTITY= + +# Optional alternatives if you have the GitHub workflow's base64 values +# instead of the original local files. Leave blank when using paths above. +APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64= +APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64= diff --git a/.gitignore b/.gitignore index 8dbbc1ab..6d9a6860 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Python .venv/ +# Local macOS signing credentials +/.env + # CMake build/ cmake-build-*/ diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 246e3601..f8af8c2b 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -87,28 +87,35 @@ with the corresponding certificate, profile, and notarization secrets. ### Test a PR on a Mac mini -Install Xcode on the Mac mini and select it with -`sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer`. -Confirm `xcodebuild -version` works, install CMake, and check out the PR branch. -Import the Developer ID Application `.p12` file through Keychain Access into -the login keychain, entering its export password. Confirm that -`security find-identity -v -p codesigning` lists the certificate **with its -private key**. Keep the approved libvirtualhid `.provisionprofile` on the Mac -mini. -Set `APPLE_CODESIGN_IDENTITY` to that certificate's identity, -`APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE` to the profile's full path, and -`APPLE_ID`, `APPLE_TEAM_ID`, and `APPLE_NOTARYTOOL_PASSWORD` to the Apple -notarization values in the local shell. Do not commit these values. - -Run the universal CMake commands above, then run -`bash scripts/macos/package-dmg.sh cmake-build-macos-universal`. This produces a -signed, notarized, stapled DMG from the PR branch. Install it using the steps -below, activate a license, and test each gamepad profile in a macOS consumer. -The certificate and profile are necessary even when System Integrity Protection -is disabled. A locally built unsigned broker can test IPC and licensing, but -cannot establish that virtual gamepad creation works. - -Mount the DMG and double-click **Install libvirtualhid.command**. It asks for +Check out the PR branch on the Mac mini, install Xcode, and copy +`.env.example` to `.env` in the repository root. Fill in the Apple ID, +notarization app-specific password, Developer ID Application `.p12` file path +and export password, and the provisioning profile path. Paths must be absolute. +The script can also decode the base64 certificate and profile values used by +CI, if you have those originals. The optional team ID and signing identity are +detected from the profile and certificate. `.env` is ignored by Git and must +stay local. GitHub's secrets API cannot return stored secret values. + +```sh +cp -n .env.example .env +open -e .env +bash scripts/macos/build-and-install.sh +``` + +The script selects Xcode, installs CMake through Homebrew if needed, imports +the certificate into a temporary Keychain, builds and tests universal binaries, +signs and notarizes the DMG, and installs it. It removes the temporary Keychain +afterward. The approved profile must be for +`dev.lizardbyte.app.libvirtualhid`; a profile for another bundle ID fails +before the build. The certificate and profile are necessary even when System +Integrity Protection is disabled. A locally built unsigned broker can test IPC +and licensing, but cannot establish that virtual gamepad creation works. + +After installation, activate a license if needed, then test each gamepad +profile in a macOS consumer. + +For manual installation, mount the DMG and double-click +**Install libvirtualhid.command**. It asks for administrator authorization, installs the signed broker app under `/Library/Application Support/libvirtualhid`, installs the static library and headers under `/usr/local`, and starts the `dev.lizardbyte.app.libvirtualhid` diff --git a/scripts/macos/build-and-install.sh b/scripts/macos/build-and-install.sh new file mode 100755 index 00000000..3179eec7 --- /dev/null +++ b/scripts/macos/build-and-install.sh @@ -0,0 +1,196 @@ +#!/bin/bash +set -euo pipefail +umask 077 + +script_directory="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repository_root="$(cd "${script_directory}/../.." && pwd)" +settings_file="${repository_root}/.env" +build_directory="${repository_root}/cmake-build-macos-universal" +bundle_id="dev.lizardbyte.app.libvirtualhid" +temporary_directory="" +temporary_keychain="" +mounted_image="" +existing_keychains=() + +fail() { + echo "Error: $*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "${mounted_image}" ]]; then + /usr/bin/hdiutil detach -quiet "${mounted_image}" || true + fi + if [[ -n "${temporary_keychain}" ]]; then + if (( ${#existing_keychains[@]} > 0 )); then + /usr/bin/security list-keychains -d user -s "${existing_keychains[@]}" || true + fi + /usr/bin/security delete-keychain "${temporary_keychain}" || true + fi + if [[ -n "${temporary_directory}" ]]; then + /bin/rm -rf "${temporary_directory}" + fi +} +trap cleanup EXIT + +if [[ ! -f "${settings_file}" ]]; then + /bin/cp "${repository_root}/.env.example" "${settings_file}" + /bin/chmod 600 "${settings_file}" + fail "Fill ${settings_file}, then rerun this script" +fi + +# Parse literal KEY=value lines. Shell metacharacters in passwords are not run. +while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + [[ "${line}" == *=* ]] || fail "Invalid line in ${settings_file}: expected KEY=value" + name="${line%%=*}" + value="${line#*=}" + case "${name}" in + APPLE_ID|APPLE_NOTARYTOOL_PASSWORD|APPLE_TEAM_ID|APPLE_CODESIGN_IDENTITY|\ + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_FILE|\ + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD|\ + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64|\ + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE|\ + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64) + printf -v "${name}" '%s' "${value}" + export "${name}" + ;; + *) fail "Unknown setting ${name} in ${settings_file}" ;; + esac +done < "${settings_file}" +unset line name value + +[[ -n "${APPLE_ID:-}" ]] || fail "Fill APPLE_ID in ${settings_file}" +[[ -n "${APPLE_NOTARYTOOL_PASSWORD:-}" ]] || fail "Fill APPLE_NOTARYTOOL_PASSWORD in ${settings_file}" + +temporary_directory="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/libvirtualhid-local.XXXXXX")" +/bin/chmod 700 "${temporary_directory}" + +profile_path="${APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE:-}" +if [[ -z "${profile_path}" && -n "${APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64:-}" ]]; then + profile_path="${temporary_directory}/broker.provisionprofile" + printf '%s' "${APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64}" | /usr/bin/base64 -D > "${profile_path}" \ + || fail "Could not decode the provisioning profile" +fi +[[ -f "${profile_path}" ]] || fail "Set APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE to the downloaded libvirtualhid profile path" +export APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE="${profile_path}" + +/usr/bin/security cms -D -i "${profile_path}" > "${temporary_directory}/profile.plist" \ + || fail "The provisioning profile could not be decoded" +profile_team_id="$(/usr/bin/python3 - "${temporary_directory}/profile.plist" "${bundle_id}" <<'PY' +import plistlib +import sys + +with open(sys.argv[1], 'rb') as profile_file: + profile = plistlib.load(profile_file) +teams = profile.get('TeamIdentifier', []) +if len(teams) != 1: + raise SystemExit('Profile must identify exactly one Apple team') +team = teams[0] +entitlements = profile.get('Entitlements', {}) +if entitlements.get('com.apple.application-identifier') != f'{team}.{sys.argv[2]}': + raise SystemExit('Profile is not for dev.lizardbyte.app.libvirtualhid') +if entitlements.get('com.apple.developer.hid.virtual.device') is not True: + raise SystemExit('Profile lacks the HID Virtual Device entitlement') +print(team) +PY +)" || fail "Use a provisioning profile approved for ${bundle_id}" +if [[ -n "${APPLE_TEAM_ID:-}" && "${APPLE_TEAM_ID}" != "${profile_team_id}" ]]; then + fail "APPLE_TEAM_ID does not match the provisioning profile" +fi +export APPLE_TEAM_ID="${profile_team_id}" + +p12_path="${APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_FILE:-}" +if [[ -z "${p12_path}" && -n "${APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64:-}" ]]; then + p12_path="${temporary_directory}/developer-id.p12" + printf '%s' "${APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64}" | /usr/bin/base64 -D > "${p12_path}" \ + || fail "Could not decode the Developer ID certificate" +fi +if [[ -n "${p12_path}" ]]; then + [[ -f "${p12_path}" ]] || fail "Developer ID .p12 file does not exist: ${p12_path}" + [[ -n "${APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD:-}" ]] \ + || fail "Fill APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD in ${settings_file}" + while IFS= read -r keychain; do + keychain="${keychain#"${keychain%%[![:space:]]*}"}" + keychain="${keychain#\"}" + keychain="${keychain%\"}" + [[ -z "${keychain}" ]] || existing_keychains+=("${keychain}") + done < <(/usr/bin/security list-keychains -d user) + temporary_keychain="${temporary_directory}/signing.keychain-db" + keychain_password="$(/usr/bin/openssl rand -hex 24)" + /usr/bin/security create-keychain -p "${keychain_password}" "${temporary_keychain}" + /usr/bin/security unlock-keychain -p "${keychain_password}" "${temporary_keychain}" + /usr/bin/security set-keychain-settings -lut 21600 "${temporary_keychain}" + /usr/bin/security import "${p12_path}" -k "${temporary_keychain}" \ + -P "${APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD}" \ + -T /usr/bin/codesign || fail "Could not import the Developer ID .p12 file" + /usr/bin/security set-key-partition-list -S apple-tool:,apple: -s \ + -k "${keychain_password}" "${temporary_keychain}" > /dev/null \ + || fail "Could not grant codesign access to the temporary Keychain" + /usr/bin/security list-keychains -d user -s "${temporary_keychain}" "${existing_keychains[@]}" +fi + +if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then + identity_matches="$(/usr/bin/security find-identity -v -p codesigning | \ + /usr/bin/awk -v team="(${APPLE_TEAM_ID})" \ + 'index($0, "Developer ID Application:") && index($0, team) {print $2}')" + identity_count="$(printf '%s\n' "${identity_matches}" | /usr/bin/awk 'NF {count++} END {print count+0}')" + [[ "${identity_count}" == 1 ]] || fail \ + "Expected one Developer ID Application identity for team ${APPLE_TEAM_ID}, found ${identity_count}. Set APPLE_CODESIGN_IDENTITY if you have more than one, or supply the .p12 file." + export APPLE_CODESIGN_IDENTITY="${identity_matches}" +fi + +if ! /usr/bin/xcodebuild -version > /dev/null 2>&1; then + [[ -d /Applications/Xcode.app/Contents/Developer ]] \ + || fail "Install Xcode from the App Store before running this script" + echo 'Selecting the installed Xcode; macOS may ask for your administrator password...' + /usr/bin/sudo /usr/bin/xcode-select --switch /Applications/Xcode.app/Contents/Developer + /usr/bin/xcodebuild -version > /dev/null 2>&1 \ + || fail "Open Xcode once to finish its setup, then rerun this script" +fi +if ! command -v cmake > /dev/null 2>&1; then + command -v brew > /dev/null 2>&1 || fail "Install CMake or Homebrew before running this script" + echo 'Installing CMake with Homebrew...' + brew install cmake +fi + +echo 'Updating submodules...' +git -C "${repository_root}" submodule update --init --recursive +export MACOSX_DEPLOYMENT_TARGET=14.2 +echo 'Configuring and building the universal macOS binaries...' +cmake -S "${repository_root}" -B "${build_directory}" \ + -DCMAKE_OSX_ARCHITECTURES='arm64;x86_64' \ + -DCMAKE_BUILD_TYPE=Release -DBUILD_DOCS=OFF -DBUILD_TESTS=ON \ + -DLIBVIRTUALHID_BUILD_TOOLS=OFF -DLIBVIRTUALHID_WARNINGS_AS_ERRORS=ON +cmake --build "${build_directory}" --parallel "$(/usr/sbin/sysctl -n hw.ncpu)" + +for binary in \ + "${build_directory}/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker" \ + "${build_directory}/src/platform/macos/broker/libvirtualhid-license" \ + "${build_directory}/src/libvirtualhid.a"; do + /usr/bin/xcrun lipo "${binary}" -verify_arch arm64 + /usr/bin/xcrun lipo "${binary}" -verify_arch x86_64 +done +"${build_directory}/tests/test_libvirtualhid" \ + '--gtest_filter=BrokerLicensePolicyTest.*:GitHubActionsEvaluationTest.*:MacosBrokerProtocolTest.*' + +echo 'Signing and notarizing the DMG; Apple may take several minutes...' +/bin/bash "${repository_root}/scripts/macos/package-dmg.sh" "${build_directory}" \ + "${build_directory}/artifacts" +disk_image="${build_directory}/artifacts/libvirtualhid-macOS-universal.dmg" +[[ -s "${disk_image}" ]] || fail "The DMG was not created" + +mounted_image="${temporary_directory}/mounted-dmg" +/bin/mkdir -p "${mounted_image}" +/usr/bin/hdiutil attach -quiet -nobrowse -mountpoint "${mounted_image}" "${disk_image}" +echo 'Installing the broker; macOS may ask for your administrator password...' +/bin/bash "${mounted_image}/Install libvirtualhid.command" +/usr/bin/hdiutil detach -quiet "${mounted_image}" +mounted_image="" + +echo "Installed signed PR build from $(git -C "${repository_root}" rev-parse --short HEAD)." +echo "DMG: ${disk_image}" +echo 'License status:' +/usr/local/bin/libvirtualhid-license status || true +echo 'If a license is needed, run: /usr/local/bin/libvirtualhid-license activate' From 65858ecb0feebceb3372f8cc321bc7e98407ab54 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:32:51 -0400 Subject: [PATCH 18/32] Support local signed macOS PR package builds --- docs/macos-gamepad.md | 19 +++++++++++-------- scripts/macos/build-and-install.sh | 30 ++++++++++++++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index f8af8c2b..c21d71da 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -102,14 +102,17 @@ open -e .env bash scripts/macos/build-and-install.sh ``` -The script selects Xcode, installs CMake through Homebrew if needed, imports -the certificate into a temporary Keychain, builds and tests universal binaries, -signs and notarizes the DMG, and installs it. It removes the temporary Keychain -afterward. The approved profile must be for -`dev.lizardbyte.app.libvirtualhid`; a profile for another bundle ID fails -before the build. The certificate and profile are necessary even when System -Integrity Protection is disabled. A locally built unsigned broker can test IPC -and licensing, but cannot establish that virtual gamepad creation works. +The script uses the installed Xcode, finds CMake in the project `.venv` or +installs it through Homebrew if needed, imports the certificate into a temporary +Keychain, builds and tests universal binaries, signs and notarizes the DMG, and +installs it. It removes the temporary Keychain afterward. The approved profile +must be for `dev.lizardbyte.app.libvirtualhid`; a profile for another bundle +ID fails before the build. The certificate and profile are necessary even when +System Integrity Protection is disabled. A locally built unsigned broker can +test IPC and licensing, but cannot establish that virtual gamepad creation works. + +Run `bash scripts/macos/build-and-install.sh --package-only` to create the +signed DMG without installing it. After installation, activate a license if needed, then test each gamepad profile in a macOS consumer. diff --git a/scripts/macos/build-and-install.sh b/scripts/macos/build-and-install.sh index 3179eec7..4b85d877 100755 --- a/scripts/macos/build-and-install.sh +++ b/scripts/macos/build-and-install.sh @@ -17,6 +17,14 @@ fail() { exit 1 } +package_only=false +case "${1:-}" in + '') ;; + --package-only) package_only=true ;; + *) fail 'Usage: build-and-install.sh [--package-only]' ;; +esac +[[ $# -le 1 ]] || fail 'Usage: build-and-install.sh [--package-only]' + cleanup() { if [[ -n "${mounted_image}" ]]; then /usr/bin/hdiutil detach -quiet "${mounted_image}" || true @@ -134,7 +142,8 @@ fi if [[ -z "${APPLE_CODESIGN_IDENTITY:-}" ]]; then identity_matches="$(/usr/bin/security find-identity -v -p codesigning | \ /usr/bin/awk -v team="(${APPLE_TEAM_ID})" \ - 'index($0, "Developer ID Application:") && index($0, team) {print $2}')" + 'index($0, "Developer ID Application:") && index($0, team) {print $2}' | \ + /usr/bin/sort -u)" identity_count="$(printf '%s\n' "${identity_matches}" | /usr/bin/awk 'NF {count++} END {print count+0}')" [[ "${identity_count}" == 1 ]] || fail \ "Expected one Developer ID Application identity for team ${APPLE_TEAM_ID}, found ${identity_count}. Set APPLE_CODESIGN_IDENTITY if you have more than one, or supply the .p12 file." @@ -144,15 +153,18 @@ fi if ! /usr/bin/xcodebuild -version > /dev/null 2>&1; then [[ -d /Applications/Xcode.app/Contents/Developer ]] \ || fail "Install Xcode from the App Store before running this script" - echo 'Selecting the installed Xcode; macOS may ask for your administrator password...' - /usr/bin/sudo /usr/bin/xcode-select --switch /Applications/Xcode.app/Contents/Developer + export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer /usr/bin/xcodebuild -version > /dev/null 2>&1 \ || fail "Open Xcode once to finish its setup, then rerun this script" fi if ! command -v cmake > /dev/null 2>&1; then - command -v brew > /dev/null 2>&1 || fail "Install CMake or Homebrew before running this script" - echo 'Installing CMake with Homebrew...' - brew install cmake + if [[ -x "${repository_root}/.venv/bin/cmake" ]]; then + export PATH="${repository_root}/.venv/bin:${PATH}" + else + command -v brew > /dev/null 2>&1 || fail "Install CMake or Homebrew before running this script" + echo 'Installing CMake with Homebrew...' + brew install cmake + fi fi echo 'Updating submodules...' @@ -181,6 +193,12 @@ echo 'Signing and notarizing the DMG; Apple may take several minutes...' disk_image="${build_directory}/artifacts/libvirtualhid-macOS-universal.dmg" [[ -s "${disk_image}" ]] || fail "The DMG was not created" +if [[ "${package_only}" == true ]]; then + echo "Signed and notarized PR build from $(git -C "${repository_root}" rev-parse --short HEAD)." + echo "DMG: ${disk_image}" + exit 0 +fi + mounted_image="${temporary_directory}/mounted-dmg" /bin/mkdir -p "${mounted_image}" /usr/bin/hdiutil attach -quiet -nobrowse -mountpoint "${mounted_image}" "${disk_image}" From 288484203848310568d3944a201bb370a5241d4e Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:35:11 -0400 Subject: [PATCH 19/32] Import Developer ID intermediate for local signing --- scripts/macos/build-and-install.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/macos/build-and-install.sh b/scripts/macos/build-and-install.sh index 4b85d877..1923d547 100755 --- a/scripts/macos/build-and-install.sh +++ b/scripts/macos/build-and-install.sh @@ -133,6 +133,16 @@ if [[ -n "${p12_path}" ]]; then /usr/bin/security import "${p12_path}" -k "${temporary_keychain}" \ -P "${APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD}" \ -T /usr/bin/codesign || fail "Could not import the Developer ID .p12 file" + intermediate_certificate="${temporary_directory}/DeveloperIDG2CA.cer" + /usr/bin/curl --fail --location --silent --show-error \ + --output "${intermediate_certificate}" \ + https://www.apple.com/certificateauthority/DeveloperIDG2CA.cer \ + || fail "Could not download Apple's Developer ID G2 intermediate certificate" + intermediate_sha256="$(/usr/bin/shasum -a 256 "${intermediate_certificate}" | /usr/bin/awk '{print $1}')" + [[ "${intermediate_sha256}" == f16cd3c54c7f83cea4bf1a3e6a0819c8aaa8e4a1528fd144715f350643d2df3a ]] \ + || fail "Apple's Developer ID G2 intermediate certificate did not match the expected digest" + /usr/bin/security add-certificates -k "${temporary_keychain}" "${intermediate_certificate}" \ + || fail "Could not import Apple's Developer ID G2 intermediate certificate" /usr/bin/security set-key-partition-list -S apple-tool:,apple: -s \ -k "${keychain_password}" "${temporary_keychain}" > /dev/null \ || fail "Could not grant codesign access to the temporary Keychain" From 8547c222d91ed5f85f12b3a5f28dd1f2d14204d6 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:10:39 -0400 Subject: [PATCH 20/32] Document macOS virtual HID privacy approval --- docs/macos-gamepad.md | 15 +++++++++++++-- scripts/macos/install.command | 3 +++ .../macos/broker/libvirtualhid_macos_broker.cpp | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index c21d71da..f935b96a 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -126,6 +126,17 @@ LaunchDaemon. Run a host process in the normal user session. The broker socket is `/var/run/libvirtualhid/broker.sock`; only the root-owned installed broker can answer the library's requests. +macOS also requires permission for the broker to create virtual HID devices. +Open **System Settings**, then **Privacy & Security**, then **Device Control and +Data Access** (**Accessibility** on older macOS versions). Click **Add**, +authorize the settings change, and select `VirtualHIDBroker.app` inside the +mounted DMG at `usr/local/libexec/libvirtualhid/VirtualHIDBroker.app`. +In the app picker, press **Command-Shift-G** and enter +`/Volumes/libvirtualhid/usr/local/libexec/libvirtualhid` to reach it. The +installed broker runs as a root LaunchDaemon, so macOS cannot show its prompt +during gamepad creation. This permission gives the broker broad device control +access; review the signed app before granting it. + To activate a purchased license, open Terminal and run: ```sh @@ -177,8 +188,8 @@ when trusted elapsed time cannot be reconstructed. If creation returns `backend_unavailable`, inspect the launchd job with `sudo launchctl print system/dev.lizardbyte.app.libvirtualhid`. If it returns -`backend_failure` with an entitlement message, inspect the embedded profile -and signature with +`backend_failure` during virtual HID creation, check the broker's macOS +permission above, then inspect the embedded profile and signature with `codesign -d --entitlements :- '/Library/Application Support/libvirtualhid/VirtualHIDBroker.app'` and inspect the embedded profile with `security cms -D -i '/Library/Application Support/libvirtualhid/VirtualHIDBroker.app/Contents/embedded.provisionprofile'`. diff --git a/scripts/macos/install.command b/scripts/macos/install.command index 8ecb7040..8e574566 100755 --- a/scripts/macos/install.command +++ b/scripts/macos/install.command @@ -54,3 +54,6 @@ fi /usr/bin/sudo /bin/launchctl bootstrap system "${service_plist}" /usr/bin/sudo /bin/launchctl kickstart -k "system/${bundle_id}" echo "libvirtualhid broker installed. Run: /usr/local/bin/libvirtualhid-license activate" +echo 'To create gamepads, grant VirtualHIDBroker.app from this DMG access in' +echo 'System Settings > Privacy & Security > Device Control and Data Access' +echo '(Accessibility on older macOS).' diff --git a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp index 84faa7ec..06d418b4 100644 --- a/src/platform/macos/broker/libvirtualhid_macos_broker.cpp +++ b/src/platform/macos/broker/libvirtualhid_macos_broker.cpp @@ -339,7 +339,7 @@ namespace lvh::detail::macos_broker { dispatch_semaphore_t cancelled = dispatch_semaphore_create(0); const auto device = create_device(request, session, cancelled); if (!device) { - static_cast(send_message(fd, response_with_error(lvh::ErrorCode::backend_failure, "Virtual HID creation failed; check the broker's Apple virtual HID entitlement and provisioning profile"))); + static_cast(send_message(fd, response_with_error(lvh::ErrorCode::backend_failure, "Virtual HID creation failed; check broker signing and macOS Accessibility permission"))); ::close(fd); return; } From 70208f7c8b92af74812a5a2b242f65e01f3bc12a Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:14:32 -0400 Subject: [PATCH 21/32] Fix macOS local build script ShellCheck warning --- scripts/macos/build-and-install.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/macos/build-and-install.sh b/scripts/macos/build-and-install.sh index 1923d547..195b866c 100755 --- a/scripts/macos/build-and-install.sh +++ b/scripts/macos/build-and-install.sh @@ -62,12 +62,17 @@ while IFS= read -r line || [[ -n "${line}" ]]; do APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE|\ APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64) printf -v "${name}" '%s' "${value}" - export "${name}" ;; *) fail "Unknown setting ${name} in ${settings_file}" ;; esac done < "${settings_file}" unset line name value +export APPLE_ID APPLE_NOTARYTOOL_PASSWORD APPLE_TEAM_ID APPLE_CODESIGN_IDENTITY \ + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_FILE \ + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD \ + APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64 \ + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE \ + APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64 [[ -n "${APPLE_ID:-}" ]] || fail "Fill APPLE_ID in ${settings_file}" [[ -n "${APPLE_NOTARYTOOL_PASSWORD:-}" ]] || fail "Fill APPLE_NOTARYTOOL_PASSWORD in ${settings_file}" From 8ce0eb5e8b81fb2324615dd26c258b5fd8ede95c Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:42:35 -0400 Subject: [PATCH 22/32] Bundle macOS control app and install both apps in Applications --- .github/workflows/ci-macos-broker.yml | 3 +- README.md | 3 +- assets/macos/libvirtualhid.icns | Bin 0 -> 21178 bytes docs/macos-gamepad.md | 44 +++++++++--------- scripts/macos/build-and-install.sh | 3 +- .../dev.lizardbyte.app.libvirtualhid.plist | 2 +- scripts/macos/install.command | 30 ++++++++---- scripts/macos/package-dmg.sh | 8 +++- src/platform/macos/broker/CMakeLists.txt | 5 +- src/platform/macos/broker/Info.plist | 2 + src/platform/macos/broker/io.hpp | 5 ++ src/platform/macos/broker/license_manager.hpp | 5 ++ .../shared/lvh_windows_broker_config.hpp | 5 ++ .../lvh_windows_github_actions_evaluation.hpp | 5 ++ tests/package-consumer/main.cpp | 5 ++ tools/CMakeLists.txt | 26 +++++++++-- tools/macos/Info.plist | 16 +++++++ 17 files changed, 127 insertions(+), 40 deletions(-) create mode 100644 assets/macos/libvirtualhid.icns create mode 100644 tools/macos/Info.plist diff --git a/.github/workflows/ci-macos-broker.yml b/.github/workflows/ci-macos-broker.yml index 963ee1e4..5cc4b07b 100644 --- a/.github/workflows/ci-macos-broker.yml +++ b/.github/workflows/ci-macos-broker.yml @@ -54,7 +54,7 @@ jobs: -DCMAKE_BUILD_TYPE=Release \ -DBUILD_DOCS=OFF \ -DBUILD_TESTS=ON \ - -DLIBVIRTUALHID_BUILD_TOOLS=OFF \ + -DLIBVIRTUALHID_BUILD_TOOLS=ON \ -DLIBVIRTUALHID_WARNINGS_AS_ERRORS=ON - name: Build and inspect architectures @@ -62,6 +62,7 @@ jobs: cmake --build cmake-build-macos-universal --parallel "$(sysctl -n hw.ncpu)" for binary in \ cmake-build-macos-universal/src/platform/macos/broker/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker \ + cmake-build-macos-universal/tools/VirtualHIDControl.app/Contents/MacOS/VirtualHIDControl \ cmake-build-macos-universal/src/platform/macos/broker/libvirtualhid-license \ cmake-build-macos-universal/src/libvirtualhid.a; do xcrun lipo "${binary}" -verify_arch arm64 diff --git a/README.md b/README.md index 0fd5193d..0705fe1d 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,13 @@ companion stay behind backend implementations. fallbacks remain available when the licensed driver path is unavailable. - macOS gamepads through a licensed, entitlement-bearing user-space HID broker. The generic, Xbox, PlayStation, and Switch Pro HID profiles use the same - public API; keyboard and mouse input continue through CoreGraphics. + public API; keyboard and mouse input use CoreGraphics. - Output callbacks for profile-specific feedback such as ordinary and trigger rumble, RGB and player LEDs, adaptive triggers, and raw HID output reports when available. - An optional `virtualhid_control` native UI tool for creating, removing, controlling, and inspecting test gamepads and mice through the public C++ API. + The macOS installer includes it as **Virtual HID Control** in Applications. - CMake consumption through installed packages, vendored source, `add_subdirectory`, or `FetchContent`. diff --git a/assets/macos/libvirtualhid.icns b/assets/macos/libvirtualhid.icns new file mode 100644 index 0000000000000000000000000000000000000000..13c43dde1b14dec37e9b454c5670df5af74c3841 GIT binary patch literal 21178 zcmeFZ^LJ%W@F;p>+dSdKn0TU-OpJ+bV`5{%i9MOvwr$(CHL;D8+NQn6P4n;~* zR0#lp_(UN9@UWji$FbP#Kgvl-;ya*nlKA+Oa5vSEGW+oZK>vxu1E3)Y0Wklud^SM{ zqW=#UhoA*O{XhMX06>T(0Q!G;s=7*Bwoa5tF&Ix9RrG%_SC#Q!D# z&q~7t5t30Ee*{|ai#JzwEROB`w_TnM#=%y|bUD<+iXLsGXpq_0r*i6}YSU|QKl`C| zm+`e7W5oF&(95dGSTl`jXf@KYk(d^2cDg696i8d)Fhb61bns;Sy+^Whpkj{1j09~y z269otVTkuHdl^B(Xv@OiuV#5R58CPHB57wLSAz9;4I?z^d}ZY#Kg_VvJPYGziD)o* z%2B$h1Vw4oUjfFM-;9FgXF8&7v8o&zOWL`{WV_N2U%DN!qAu0M|vwF-eb12AIQb8WRiTF};U2ihEVLWSwz88hn+h$wDhz2jCsM~zGX~AR5YVSVp z5s`Vt@wiuLg^)p9(wwWN^CEjfChBY|%7@|jA~$8T+{Mv2g9NtER3yyl9s7|GhUdBzxZxCNXdr(dQNlSyhiBm6ZxI5+lM zUM6;wUetr*O%(>4t3!NCTh0%)3(pckXX^;~sw{@-_rVqu-faD`s>AUB;5ur#N5#@z zCZ5qhbafG^tp6lTUm`>4-T>;`x7ujOjbL5Z{A7K*wdMaM64em8nD%iXb7Ja#|6 zNo~a^sH>;n(tBnY3CHz)72MUKuMw(;INn00+*EkhlD*l6D*|cE%?Qfi*J;0RRB20p z9H3Pl5co>dBnobEVvC)I_7y#XF8^I81R&uY%4kb9C+R1IXcGnM{w{f>PFJLZDATNC zJ1u<_KXc?+3%?JvVMSddJ8aUp%XbOJj9e@-_OBpG2pY1~W*P(ySeIagj}L^pL;qgO z*#m~yQ<;xBSw9RTZJ1jZ-R&cC?4*zd-9!kzxwFlq17m?6-Bw}rNU1M;(6Shx z3T9aQflgF;)AGX1VjK_jZ*va>eUJTPmn^&gp?a@CJF{&W8b!nHy~)@PKKB>%75|ux zdtx_VQ4L6eSnqNXI+diowB%TlT;LV#A|wf&RcAmekeTnnT2K}v6@5K4dU(%6@V@XFbt=QsNCDxQ={dd0CZ2=gxl*i8wt&{sVw>(?3wit zg?;BI4ZC_$4J?+>y;>3GyBsw=>}(Dh9d?E5O9WqZPKbC4KQH~g8Iq<!m6oYhDggA4v1Al%WU-HT-8i9PJ)Zh?h;x)Mt7}V9&i^d ztO5x;pk*Vsn(IXPG^k%=HRVw#EfAeXc-bC4jM|4S*LPa|P)m@1^tQwxuWaD2xu$0k z?^#4`D#EFx#b$WYpgYA)TWoWER$D4XZXHmR8}R6ieEmXf*rjYatUuj$gh5&_AMDYJ zd+tbt|Bg^i>_1cp)}WgHitiB3D8Wa(Fh8QbZcdLBApRtUYV&Uwbz@B*rQm~84KD;Z zZndCE*HtXS?+{k?rq@SCeu6V{4CfNV7!zM{uC24YhBR(Ou}Yyy-`}Clj2{y0gIYFm zEdl?Rp)jEApNe$Yw~H@l6lo+i=__fx^?GXkN=>8hw`jq{DW@E~32SoP{J;5$NKP4L z%X0Y3!?r1dxqEDhGdt`thg7LXFGrLD_&aNjMsEi!?~CpK@g&Keezkfjuo`+parw{* z`sLL>k~iDm7{HRQUDk`+W@dP4NkrGqyxuaf1?oW%73}Yxi9eaU;}1@p4bxXwa5rUr z!cs$HPSwI|FD#2L=K?(15_&Na?p`LE!ihq)(ze)8B~Zrx{-y>U^UKUJ4Sc+bTJ_46 zosVR#YD><7T>osKPqSW@71dEDP~AO`=GJBIe@PtkC>kD5s{Pi!Xv%YIEND;qBo0%Tu(118;B3JYZ~%S+Eq zURBzwf@VW7UU;0IfHYYTgNgIq8m~$|4HebbIbgV7xxz0ydJUkvpvd3L1sVymVJ7!C z&++m%S{eNDvkW3L(JmWf0%rWd@LXma$sX{eCk0Xl7OYAay#oB_rW*c>S}XbMdKniq z*!{m;bU#e@V)o>o!9fI6)AAt(A}rDo617k9erI+$MT&uEdkkY zXpnE(qI^_G&b1Ck;-Zsye2n&e0wlQYSyEAdLX)y=h-XNfXuuZ10FRfvA(tL!PN70dFh~?>Xc@|g&Q_f4~Bg?j|t-b?W9{pv6U{n3S>+1OjgpfXVy6jI0A#A#X8t=7} z0E1OuB;NG^D#>0{MvD8HtV{9f`#3(vRB^?Xlgf8{6Q{EE;|c3BmUv_2!%_6FP za({|3x$-?qjGU`LQ^j=S4X%zMWdDw6a+}Afz!ic<9Qbbqrl;m;$xNH^^K&LBQ7I_6 zPt1L?2Z~pWkKFLxWFUl{yn`U*!vk_Y$JH0%x_60@+5yu9h-SCj?Xj8bEU_UxuhWbL z8@#62`pJoB$M$V*AIG8RP4+Wu%k-10Z-yJiYW`#(q*0GAtUO2_tH$!)eA~{9W=KZr zfP^@7U=t>b9y=jDa+6lp9yqYA@G<#4Kdrsz*C8Vl|JU8Cl4hHpwcObfvF|}hng?iCvu;a8O5~ub%DB}z}{9h zgb`Q_naN;5^`+Q!|82;M+5I4FJf1>OouCWDVs_Aff;A=sQHDAmq=q~LuV-wq-{el< zS{2Pb3XF(vk;C1_!z!X{9+7noOV*@#B{iH{23#xYO|9-t9)RvO|ieBB)n63$M9Z+G&UL5_UubFh2PIEmo6!-^_`DR3`;%WDe8 z_0}HydBXood<$~D$#R&5HNjUDg0$>SkEds_!jVzsK>p~l)RaTNUS zNbx>oi$5TjLv9jnYC^UM*uY@_2sqeB@o4-goBhL_t<~g~&JK{5{c54%ZAXeoAw)tA z?6&_zz%RbU2JFZ8mdxA;MZNDwK{M29b@r)aXf#dwPfCRl)=%H~(T^Ilw~q|EGm-kH zWu#=wrjrwV0tJea%Dbr8foNv?Ox=_kJ%gRkMMeqw5U006iUuF%@mnF`Eyc#rkd0ua z@*6B94W6^WhQ4p-xbhY%LyW3rt&to6ILHGx|JTV)TK$ny5aSu>;-vX%YT@r96&rj$ zgg@yA$r>DkLV(H)#|;CGq?wDQYU8CFJ!9NkBs<{Ij$oc-D#tDyM#`}C72xT8+y3_> z;)hiw3ZzT8P+(TYi)oPO2K=~%-36B&qs{y%ZIU9a0Vb|u^~#|XqpXTMLnY)-)K^sV zg~*oU{-#;aGu0r^AAZ7Qun@FOO~Z#oV9qi>g71IW52aRhu;V)PSlCAa`m{PR3u-mk1GOHWJpZ^I^1XvVpu=9z`x#$=gqxN zOaa*>dYDG#dlE_WTT+vrX{OW2ejC8DH{Lwbi4{3g#*ZjB8~6YrSh8(s%4H&hbL{fw zQ5H)NaukT~_*TJlRu?0HL>MFjLx9B&T9K!$LNj&h%b5G2d7M1+JF_x?!wVl89-Y26eL`>d~wEc!6w{Lb#8p)c24f z2|~JGkj&e|iTq0XlNEftPpY(`CePqSvZes)msWsBYf^qlZ8~!XuU!H_cwgz3Ub^uX z29b}eDZn;3$WutjYow@x6QGJ_f|@rK2&732YrcnZw&8OPz-`*-Nm@EAnrmewbU==P z2K8`l<3h)BKx398)*nQA`7W1#V{7#BIb8r`&n$au2Y>p_1J1OWH&~3!_T2I()wGo5 zW?+tm9qrTZCZw*zKqk*AQkwhfJd+UnSD2pP(Nr(+l8ghsQBn`__RFo88Ny!e4%N2kWpzJ?S@?x(RjI4JK-NICCSMspl3ySnRl~L{C^U6Ak%t#4VjKf$P*V7z?>(Ctr3lIK-BIellU{%-aNYT3k%W8TNze) z3O>nYxT=r5p3VLv5sa2aUFy!^#Z=6{TUch&h0!r(xc)`qsdytNbnkvqo|Tw}aL!JL z_%&Tgbvr1B4ppzI!k(mUR(f8w*4Y`L22)2_U+yi(6Y@$o23gR7N6Q%N28c2f(<30b zQc{oXzKB-HT{t9s8K2o4YEegH-@Ak^mEs3>eCw$d%viPfjQ%{}3x6a1)%hKmRo};v z>Bl-1W8j|jpc9V#ThmU6Czf!IM2 z8|$bG0_-z~r@nqdiYxH(UL!upXpjiucp8PyZtU7l8y%DAt&Z)5^7J*8y>=)JIyH(^ix)}Az5siE1w>EyO67hb1mhs{C>=b8bNTd1WN+R4&dYh@Mi{Vf z5GjIzOf{Nos1)U`n4aysdLORnz{Q&_nm-*ke7OFV0jF9+8XT8)+`Z=M;9?8#=`vN| zt2gwaIKb;3b^~mt&il*|-Tw|RsnPCsGQCw&Kk z6N0k&pVGgfCcB*6Enh_SnKMFXIawI?#tl(kEzj2c^XB+Ds7-q7B0Jv#QBCrIw7zA? z=R8lxAIOpbH24;mM~A#HjgkG!m*d6sQwXyZLicDnqIV-MlU03Smo%J1+D0qP@86yX*_(=t|DmwT6=I)69O)c~85-UOF(p>R+i$LcL#dpF6<3*am;N;r@PXQ{2d%fZO zIrwJk*c;{DgF`v(Epvw*8`@J~>7VdmKycu-zogzF>^9ht-C~IWjIiXPWmqSHDPikxL)ro5wNy9;=>+n%0IsM1bXTtmq=;maff;J?6`@9VTJLX1lM` z!e{JRd$g}fY0o3K(ahTh)6LMOJ`;_+&?va2`xG#2==J9&%Z1W&;%7?p^Y6r-+4Wd= zPk-*|C%GcU=d&SsT@^RJv7dvsiv_r?B^8n-?>W%Icr!+-7TuC2lV#`>X{({w5y*)xiHeD}JF zlKPyL^NNHT;d3hDEFecZLM!OnN@*Esd6T7f-LbORI)rmvH0yocqS#_A zs6VIskv(}`iw@BDqIo=T;31@8^xamp;D-k5Ux?z_iT!{72v2Hg8Of%yU3a|s5cK|Z zt?PMql^21TZoN?kSpDQ%3@i2k@=<5Sx|&x)xM3T6O{!u{@_IpCJ`*fwms9# z34dnut(AGX6bs5vjG+A8>ih4=4)N*enAIk|{V!wqQ?8I*P*u4zMjnOskzd@*kn1$Z z;rAZW`a63y@CJ~ao&vlRBY!kY2J8Bm?{37ts*+P={fc2474d_2g0wq1SfcJQ9gp0I z?rZXJY3-HLGo_tr4rv*ci4&(d|2*BJfm-WLMEgRZqglWrp-7&}@HQt_PWx9}Gmf5+ zn-m=cA~+1m(A3-jTlH@jF7_=m?!m1PocgWLG&!yWnz}MtTI2(Duqd= z6X7=^HB8t4@fnbnz!@i$7@!|3^wL)y*J5kwJ5BR4Byi4gm)kc-FVma|Hx9ZKmRP9@ zRB)f;bnbOBfym z&RKK#j!oDrV1+uL`aH`D|MbohOY%+~zNI)Lri9mGb++$sV&>;Y_jN=j9F8vs30_xs z3%j7|s3`d0(F*9e(8o?hO^4e@m^>~mee-7?OC9p^);m<4VYT&^Pf7I6O{6<8(L(V% zsJ`rz7C~LIRJNd|A_XVZIr16Dyx@9$QE;RVz?^z&kT7`mn|%E4d+53B8OWzqTA8m} zKz;qagBAVvzs29mT@AoH%z1Xje;RWomkfQ>%l_MfojH7RLX+8Z1Gb(heglJa{_#Kt z`0~ErBf5GYTwMHdl{l9*g042Em}w{hcw8%V3=Paf)m^dBO36q&cjQ}9=$SE#j(+j# zUQn(%K3$!c;Ct9rEkCp{9_t($<49|1#CDGO#Wla|Nfr8IDInT67=~Xr&35Fb=_Os| z)wBusg{|eKh3};~O1gGji4s*S7lH_f4}1?ok)}g=^1u)^xRK6oL*liWoro^=D5uxs zGeKg+)hr1|{qEf>_}<gzRdCx_>RfU+X9j@e zVz|POV!u!7y{TneOFb_b!~D(}e^VEDUSA`1_^|iw{aj!Bjg9tMv6iCskuvZK38h!h zhoXWdx~Wsp&0`0f&tgM;o#wg)%m6#Si5A4T{2)-n^uT3eBYtp_d|F@5P8icc^NR5S z`0*MsI8l0c&6a_wqDR^SHYROBcJ%Yz7Y*9q&oq*9O49~%A^Ca_mgN=MXF6@cJ%>Ff zeWU+~ia(q%`gUL{L^~A+6DSveR*g=k`yz8tK#g}Ao}%+_)N!N3ayZQojS@m95<&s8 z*I??+dniazL{ZcK9@}k0oLFyv@%(u{WoQ20w{=pADB5;`;CLqVc_Qo#mA|WtfKMRJ zQwW1=HHZR751RNUo@_y6=)Cx4{ar9(5ZLF~+q9PlOcDmJqT}Z>HVH|MA`#8LY*si1A2boYJ`YG$q}vSSb1_Qz?D{qtkQ@m{Jb zkJ7S@O}UQ-KOeAFeonrXa$S&&x1yH_mM;qMVjP%eJ01gpXWrFlObUPyJC; zL|LTZ0GAA#xefZaO6eP_`HsEbWcz%mB6op%)Sg;V=w zNed*F+i+6-zIKsVs1il`A`$97<9SVW^{CUU?totK+a2CgMHJBq+FA4^YV{wUQ1*Xq zP_<$%dM;Y=xla$_CU^;lR|Q3WA52nSZe%8P>QISTy-Z5ck_pjQaXF*O)N4zxPZ zZ(G%E!b9))VeFgnag01v9K#gjZwt`sWje$nD%f$B!tET`iIlVP@US)&SS+zo;|~R5 zVn2|n^U5x7W;@WEo3~Z6RmGP=$dCV~nJV3=XqQovTx~X2Y{YV~*G-;QK`0 zFvylP8Gre+==9pgM9V`+yiV2Zz>xShf+QvLse-~shOTk)xl(;%Y$vP3mcF0-N^9RH zo&Na7R9<(AsSIhocFC9*9ROzHi2(fcEs_wqUNCCT*atv?>E|eU6|dmevAdEknuswD zUYp+68RVq0!80*P^2qlYJX;;_q=SB15N|b46EH(7a}8L;6+|tD5ruPXCamoR%P)7b z3TGIP`$n_BR`J_iI2Gz@Y1?vuF!z4d+sg`1Bh(t#^>@>$WtoUCo8!fjJN6UQypuxy z629m)UvDEj>9z%@YSH}X*=q|#_mQvvuFA6#?G{u>%Yt#$yRxD3@J2P5Jd2T8+oI@? z&9{(&rHG?Vf}iA5=OJ&rq=JN>YvD^Pdfh6cfVr0ZOW!v;n=AOsq9?>bL6!;$c+*=# zLWWJtZ%z6)mp*Pn4O!||900G`;GEOcBI(Ik}em7FhKY7db-av|cc_qf>antNd!pvNYApyuJMDsUgA&NlE2vX3| z?s$|<%AdD@&6)6>NLvjYbUJkfw|Dv|9$JV(20kvj37imHMZR5cK$Hay@3JxT2SJ2k z*G#_F8>nRf`Q-Q)=3%T-b87EgsockSJsa;&yhJgZ0E>q&?08BnkIL!tN` zy6EM6gKokzX#O$)OMVB+uMOC5_sdy}XMx?Pj{LJeE_mHu`WJ#CdObqKcU`x6lAkla z9Yn1!E(sYZ++bC^W)`*6I|uIFTlM?DzLcLihX z5BmYtSF)6sYTg6gZih&XOy#!)>!@8&^+_aU`I}WeTp~D1eXQ9{)hsTXIrA!07L60t z?W_gvU7}!^t=q4gDtiC8=l**CV=ADH;~Q-wU=vaS$}axWig_HNm)N(arYiSi@2ev) zrV(Ssxl^i6oX1jXMv&kg2)vZ)*{vSaL;3^*(+o;^Jk6O&XJx?EW}=uOZ`gYb(8z zI+NlyO{;enhPY3tpdO&RQtE9B^1PEEC8JeQr=Z6yR9HNKD^gQu?aq~5A^mGC)i%KP zzN5WY`hL;v8Vmc@H=<XCLJ=w%L~UQ7GAawt?q0qN5f7cL61fCuq`?-o>bdy96HPS4oxw&(z~NJ-R4sr!>CbC?9{LJPx2CxH}o%ZPFn9;2i zzbZ=U7LEg!{mBG9Lw!dFj@Lt9z7%(9bnRCU`Y+QE6R1qcUKJ7&dluWw3HR@sJlx@=at8A_)nA%a_NOP^c~nswmnzv zttzdnFMery#}(V{tjK!aMXKNp|3?IDwpj%iq zbgB`*cmhB8ragN97?#PTAA|mAoz>X-&B>|?toob@bU;i`(W*<1I)t*snnRor=B0sR|mRbsq)Sk{bC7eweS zFQonR^z~@1-cvuD&udBV4xpC3i1f#;ZDt z3g?Fk!8n> zJYlY^3DYaoPPRbAov&Q$zO?qV7i5-J2g!C8T_H0fwu9fHr4c(k3o%BYxwd6DQUG$I zSj1enxRG0;h^)br;_o|n9YNsDcd3c6(L@0^pBi;{US zURhKz&I7hD;QR?ZY`IUXW39f3YJb@9O-`f5q zkXD(j8w>3|w{^S7q$FUIRr(lNA1sH1p3 ziwhS#6`fk;0S8!}p>Wdv*l}Y%mcY$~?^*^K*T`9L@;Bb)-`xx)22sV>GAAc+eB>|7 z#o=!4BvQ#kQbMz``bFgwJaP(nIZDQ~~e&^sa*oBMq4vQTeI$nzM zF)J7}%3xcZywQT|{Mct&Pa|8wNaD@I-2HQd9HJkHC*^K4FY5N@lQ^R@jLY--(zc&Wcc?LXwBM;^((CV|r? z7{jwh<&>UrgM0c}DjrW%p#^P`lwt_@Vne))Jk`VJj?6GfC*;N$X!+yeo9uxv4c8&< zqTzHr)=qSsLz*kLujTbMJ~#JLD=~|+&E05>qEAJfrsk-5B;ODiD8X{b- zvnMwM-#<|%eFK&c*LpFBq7K!pHTf-)?wo8elrYg2Nj3C`k6xbjQJydI76k^jgK%iw zwf4}nK5|5&g454mJLwZc@+9vhu*qtqnPj;fisPt>NzQPJWiEbNTaZG}2j6496dBAb z`)C=|Ekatv_%d1ki+fq}@2SB2{`{xR;~!G2C+hL~yRn-F%ruA>w_S98+kdyTK)a$+ zRC;Fna!3Z$Wz&>~qOU&?*8ORDD-nZ&ttBq|GLf8iy`=JkMeOhwY!|5aXeH}c9Mzy+Y>hWs{175Y|LG!)tF}1-c&b#PqVA87XZA}J(e#Jy zQ^jEghb+MCs|ndV$*CeS_8e)<>(BcifQ$qULfmdQqU>E4y(`))WSq=${ZnlKU@4_= zIQ;95RbCfSr=TV*JGuVZJP6Bul!|oo;&E1e`%s*At;&rw!s+!J2$<^5&l~g^f4!sX zTng#a**3L#9zM*u!8OK<>jg?1$A`>u=uvj~(EJSzyg)Oas8;fM44LSIYMsJYJ#GOL zNlak8?NMdvyIEtOgb;P!_AuCfJ&GZw+;lV0VSeX`Z=z6re5pNq>e}AXEs;run1#lp zI@V*-tv~^sfON0_2tCh8zCk5?S$oqsa6?Hwb5G3~<7%1hv2&R`*H?`S|FnzQ*={!4 zOzvK~_4_kvVOrT)wgl4X;?MA)Tx*MjY~WqgwoiQH{**YaE5;5(9EF%ePa3Y1|M$Ww zVEzPm`ymDn7&Ft=FeIOT%*tq<-|ir@oeGv4q4Uanv$Rx5t%alPIdoXVSAAcOm0u_) z46NMXsI@VH6w51T4jJu}%2=x^Ew$QeO!Yxdp(xeMDz+v2vME9Qsv*e5NPYR!6adM% ztob!@IK+SX<`An)(1P3pV>U~91h=Yiba%Ia+c1H$`WZfb&Cc@hfkwfHiHDc$r`YNF zcaHdj+$uCGH`^{X>x@|EEBL3xEy(G>1xahkJ!*Z~^k2nXvJ0aC0~`)dKqwal{ng0S zzhe+Y+1ReGRjpF8Ly53Z=Gx`Qgzny(zO#n0#sw zA;Yh}?A}d!)WB(q(eo#yYe@Y(S7d*^rd3Q|V^L}yanZuwrpL%c<=A6%Zd11i#xjk) z0+K}(fhyg@Pg#De^CXXGX;b?(YNncz2z#n=<3E8oy}^&n^rLC$o5VN2z0kz{+te_Q;_n2%=s4HiZ>Y$>pcFMjQeCv(kt>B2#5bFg4l zO^E>tP%W>7{DckTe{!w4jNnx`Yf{Ey62X42&SqYwlhV)qGW#5!C93S3U-Gv8p2NPF z_0{gqM&mwdi^RU@Jy(Y#aY@he>B4VUKv%HiOJuFGVDc9mz!CQLpMlh0*(ZPJ=7FdV zIW`$-i=L^uc)I%af9rlQId=x)Zxagd4 z)c_A4=er>+#8ck}LuRZSqt%x2xCv89-8t%h-9!X0Gd{HDkD+l;c)57ETrketlNwLv zwnoN-G~~_%umu$e&!rcZ zs+@~0>1k@tvxwxZD;`-?B=AealjDK6*-b)FNt%Zvs>oyOA^f5Q#Qlj*8A!9K?*59Y zwc)i+xg$a91YhiRR52I6JD*>Tg?osZz8M?vK(L)t#e)W~;&a%Dt4O-oyB6GdMwG#vTHrwi(icqmEy9 zUaSH&;|>{6O?m*uRmg>5UZ?xD=ie+DIv&I|{a14q#r(}G=hca2NI1hcW8d4WCG&P( zPZTf-;=i-tn-3Dp11=+`I9o{z#HgN=f>!q|Q01QyXg2dCyOYb&!Z1lcguE2l>k%Tr zWPZVC1_UpXjCc+2k*Y6##L1lkaNiF1cdw=_Uf^eBe{||KxM+N zS&Izu>10{Sp#8EQQhZ00_Urf5ph&%-yDGkZN4rt}RKSpdF8Uc@fnwh=XwI?+;_ye@ z0hJ|GHzVIgZ2#YH&(TK7K~WHrji#t`UY8Z!E#O2~+pge%wT~b!QOlScO~@tJ&~?X? zDLgNo#Mf*!9oIi{q9=bC3cuLQU+pjrnFM`)mLG;?CV!hIr zLLj=C{31jtx;!?0i~%RLO&ot(kJjIB*or_GzK6w`%rD6&7GJ^9%=fgwiRa(7@O*YM zh*RfPGRd0F^`Z#s5Kv2i-D`_k*zA`z2A2{CK9{i}kr~0+SeBGHWnV<<&G+@^hJ17U zT9ex}WJtVnW@`fGrpbO8{PXLLVCAcS!%`N1jBCuCuwA1`g2dSI?ZnMTV2X3lfx^qe zUNBA2B)e9TwED&==SclvwBtsysX~R6duBQ_wl}J@kV*KbV$CD2yNb2y<)uj8lp}MM z?6qgz*)qtU(eokZ3Ap}xV2F2VclqrMz$TOEi_1j91eRjo_8Nn}jC4}mCR}#1LZ{&U z6z`w9^d0gtAN%k=5s|B~#@ia>PIcP;)wysyP5qz1manAXfL3_O>7xA4O3q$YKDQ?P~! zgFUvhFuOd4E_4Oe80gS7l1sKc;b;<_MkL6QGXw&>P(^^OYmg!F^xlh1coMg;W(%n`dfiil{HKd0v$lGljY?k9=0QNe9>HJ2f zPYw&>>)u4Y>)(x$b~@c(f5#40J*W7&=9-`ypAG?dao!Q8N9Ce{VFW9-=MOS&TXM{$p#{C?oEQ_Nc|%=>CZ4~ zDzy7BtT+TCQ$!L~4a>dgJ0MB0wsi+1-^1%fN2@~0q(wDD{W8!*d0v;g2cYDcNmYo| zObI{@@G1+gBa3PbP}!eOOAG=Va;|SN^Iile-h$BfZd0c(v)bWBf0`rS=)S`jRo${UYziSDyrC@PvgbKOZ=M5M zvbBvA5kM9V&5+|~dGgh>f=_Gbb6)HOX`@*hmebE=UX6jv@Pg-83f!rpazX2RD;#0N z7uNG&s~J54l}ty3J*c#6hHwvH+nY<4P><4kxjWi_eq z=bZ{4$OsnF9M9y|Caui|wL-+XwcZHvd^|Lp$XPwaJEn|iy`vrayiga)T)Jy~e;C0W zrAf~`1FF3T4qER_$uBfaMTRif;TK4QZ)*q%8=d4Is&>A=>di!`Mg3~%ET}vYDKaGf z>=c0n#l#lo@iNK_Q)mTF?lXEW5^`Kr+FMt8k+?;TpzX_?XG&$Nb|D@xxLkB1By`_h zXISqM9r*mOoP+LyewB#HJLHM(9oI`A1d)?r=#n#G?xqKFtE17qG+(~;QYQChiWAcM z)$#eP8_5wDXwnj+wJSJB3w6 z&r1)SWc`5I;)gwD*x~MuF2tsVeq4a-b8kcz9!u(UQM1h^BR(CAjUmj_3J}B5y$FzGMvB@xnsU6+w0H)%rFk za39ZG$|Fi9virhefZC=Ig`dT-`dW9n-sKx>zopJ@b;h9+Dru|LOP;{eph)10!QdnT9kLw}MVRWPoVw(jC&Tsnq@cd*jIFx>A+ zN_>w&s^0q91Iv;2IezR6=(X>Fym`rM>BwVeesS{4g&zLbx5`^YpJu~ko3o*FiYt1G-*DPEM`PhS?m^BAQagKD;4LpsX zkitLa^x9u$fqO6sMzq5v6jf$CdC4P6d%AfuTOx@L1K#qWj%NKC5WCcK*eObYnY9F5 zhoE?qdw+11VRthcxRBBws((=`+t-QK zghk*6L}}G9nOG0e?X)>n;;}0SrqdDCGJ^#WdzNP=J6-by`AX1ya1*kqe;4UeG^VRs z-$GROZBIDlQBU7GysHa;m1U}3HYtZOE!1|KVeswC`xV2v>d#xn-h3t zh>g;&6Fc&B&Dl&y4L;f18REm}%=k+`$iKyjj9=L6F_BjPbQ>nN%<2Brl2bOA8HDpr zz#4ci=}7{NaW%ZI#BqHZ=M)E9g}>t2jGFCFrs^*X_V&tue{vE)CM*A&6iOSFMoi5J z-+t`gCqZfU{sV@@_Pr)%r};MhN)W%_&k6(s&GEt@vEPY1ace*8(1KVNmUV}`Jw>9w z5%%vzt_E`|#04SISKU$k2mVi5%aBf84ZwlEn(#9N#vj|F>Ffj6^d^+EIZK5`22fb5 zt;I~?+yg9}@Pu!`yAtVwy=bYN2m*Pt8^UjkyDWsyTe+>Z*>ef;AVlPz{T!+u+!kHpU|Jm?9vXOz4Ad)$L3-l zIZ(Ej;gIG?WH93AzjJ4Fw5g%=x+4PXuTEbnl<4H5F{da7qIvs7s;^iQU5)TE|NS?H z%4u^CKa*h|)%~5~72)b6n(nd*)!d0Y$g#+5fu>XP4`FlOhrn*KBf~l;SHy`jwp{&0 zh8BUJ)&^w#do48VXQhEW7$;zK2)PIK06MrIaaN)77-tizC7tv(>c%7*hk0@y$iB^; zth)J2@guiS?iq)lDCCDPOSj1XYUjKkp>O~;ehQ(C%(F?haM_%d5wbVY8ChAGU1e_} zUn7!nBBLXhJ?@-wglr;Yp3Jk(I%nP6`$xRLy??{=+vj zleK8pZq^9Ew4R(5X;k5UABTan^D^BiZ7N3DC%XdfaYp$UZQA&4n9I78Lnp^M+FG4* zX0mhG9V!x1tWD^8l^Kx+HveuFCbHP|Y(od>I(fP27nB+-u^BpkNRie1;l95xcbw0@ zP&R#aJ%h(c-sOqN&xQ;~VX+#()(3HYZx#P0nmGLOm}}G}I+}{%i!Xl*nn(OhSgRO; zjx=Diw=hUcbHh^|BSy~Rgp5E@wz85d>O(1W{J?pmJm7nG40W#)m^U@Te`9=jA!s+# zO)lt=*3oe+_fNC}zxPwD!9aD8NVlv2Rl(1LU(YW~)O1MGlkCc%8&M{UH<5rRhHh{4 zQ+B`m;w!@j24r-T({z)c3>JNy7M5)TuG8*cdP_=DM#tN~`_gaE85J2?NZ^MusoYSd z8w>oK4d{N=(p)zFBsX6B`cwD*Es3`vTlLJDbd_GVyW>dle$qGJr&`y4sy4sdZVBTp zu!`w#x3jlxEE9|Krsmc!Pb?rjCs*0Oo{q-9%)b&D{{<0c`MYR+nDv@>I%mqB6W{_E zVs2HFJZ|6Ncss@!Z?6&RI zD?o>aChnRDs>AqT`?aC9Xy7 z-@UA{f3wr#@3=(Wy+n3Xo0M|rtLD-W&0PT3|L9+cbU!DfWJ2pttm5=AlNu+GMUsW_ zOKYmL_m2ke>B~-<)N}P>xTVLFzi)vr(coqYMJKb-5??R(+AdZTVXid_0nB)F&yK!x zO>?Xy%3cO@pxzXag(?$n2Ui#Jz-VRH<*ry@20Q_~G*aL_AH}m(BJN)`eCbmzks~*CXGN=@PBNvZLHh^pdZDJ*v==u~stdWS)(%Nxb72 zM7~LdpE#Vq?fe?w!cMm=R**dAWz~j?boZ?9oMTGSP4XA7Y)K_3VKDcMYJi`x--|CL zh+kid$^eY{j=>xi4suBweTE-uQ*cd#OT#-`?Cpta*uq+R&2bNm5BJKzqolt6QVz{8 znp|`nVbxRCYU|&w6F0=iXwogu|ManNpUY;euwLN;5DtKvxn2({6nTu|4V7CS&H3`! z-0S6`_D5`Dtn;ni0}+BmmWI{rr=ORPzGm{dQ7+?0ZlgQr1}A$yzW4|g5Y(M)Qi|4n zRbdcinJe>Rp)%`}g8z}w$VNxro`|a5{5wWRWWtIhcpW)%mE;DuQ$Y=4IOFqa+#xCW z$Uq4OxRD5|d%Jbsh)~faMcY@}IP~fQgRF32NUmXT{g?De1dlTG3MFMBsQ+(30Af!5 zerq{-!540ZC62*45%d2oiCaDT0x!7h-RH2X7=0pbzR@EtQ>0lMjAd8fuxVd>Yac(t zj^JX-0pG~Ky*^KYLUiq!zXl|1JBifA93rRqsOx=mxa*_5f5&xqElcawJ20iirDznW z^UTcpn;&jk4JD(C>=gnq2Nf^tBi+DmW;e{ZQfJ>Ry7(`77)$0n)-B6P0JOeo+R0|RFy7hXi-+=wDrEU>QqIo|Bk z{=B^39{!;$CW052F{!+pQzAct`(ydNE?* z2dD{;R6O;U@iFZL=}I=MK`ic9hZ?>v$L;XCz`tdFu}2#W=JyEC;->uylDyX1gAp)O zqM3n1Yyt*o+r!gXOv$@ana$5+CqO?#FVvijj7cj9Q3XKUtciT;nE*LaZu}sg30;0E#-+Pl zVjxn?d z^9>!w@>GuFEI^n%My78i-G^7*W}cFZG(XlP0Yu&5Oq&x3tiTj{L7rJ(IEKi@7q3N1 z7TxJ3aj#9j7QE{;c5Qx`MbQEG%9AsKvEJ0Ex=g!~=E=Xx%%3`F6{b@%`Lfy@hEvNg zU-o+CYMrJe=u?jbfZTbYwamuHd5S+opDA((_4wix5CkaEwz@@;Ljz;jlNWAYaJ`lu<41Z!rCuXU9{VW@M#DVVP4O z*mCG3iYb;Ct{ezHk5s)L z-y`g_xLltGQ_pNCKiSjvUCfe}JHP$RA)z1Bbd*p3?BbkvqW9K^eEFLi@sN6cl4w+< zTgd)5P@A#n?R7`Uud;r^f~J_boz3^AVzy3HSKv;-f7WQ5_uUQ!;WWk}rcjjPfxOfZ zM*U6c=#aliVF3F(fbu-GRQl%7y&YR|;B4LQ#AedsX<68uy1H(olw3o}IJ!tOv2^b42AclR0@vo)LxdxC}l*J)I6~=vu}JJ3x0h6eSF78XCq>1i=sSQuZpbY zTq~?eWJjVe?-jPuSP-`1T`E2xgdws4-TeJomDl6@vcS@-|EpE`P&*Iu?5Q$!hlDey zPp?krdLG-X6!3Oh?yM74-(ANaGUL2FX)K<%-St#)JG0)|S2X(-n{&*;^r^xV#`sg# zUrPq&0+zkGw1U}~%6fT*vKh?G z0s;a~E--USD}3Aq0e2jchd;Pqw?jpE_tKkO)(`wqZ)7fd_v*z|<*Q!0I{UTAhBXaW zNAxRmg%qUgeBQL6B7ftC1nYH{a_r&-&0|b{V!pY}Hz5j^g0|fiW}tSd0CxbaxKPDZ z*m5^5VS3ZEWr@L*k|!E35(d%yX(`Mpad?fxvk(scKKzzih{AfRMWou{%3=;ZW zjaQ`Fu!ZZbmmjiN={wdPm>(bUUm(J{r9PMao=PU;yBl%5m=sW%x>(ld@&0)u4c&qX z&_=&#MvfAJ;`qzYBg(rS89$o=8O7m$TGPnXtnj^@&o&d57Dv$yl4GUWCeAz|$v?kt zeXVG?SD3G8aJ2t@Y$1s7;i&gc?d)yhBdao2nGi@HqjjT9Mq zOQo$ti#C>)heEJj`CBPsp=1Ch?Je-~f+6Ftvs01JW(*Wo1)vt4b1)=a`VQLrOI*le z+(s7$g0qsH7U`gGqYSD&sDdlOqGV6plZbi8x477~Vh3X&MYrLvnDx_X1l-vx{ ze*}-Hyj`6*fG+Rd&S34(>sh8X2gc!_Wj`(s^W`Ycc-EWE>~-oA3T}9gy=1pH@)BO9 zZ}qAP?S4xvmVS2nbI0heQtlox zO=XBdF*G!S$%D6p2lwQ7I9ecN&C)>p{QaM3u?ecKS0Hrjc4N+P5pYhZ&;3v|n(BRw zots?&!abv({*)9vUNP?JZQ88@phE*Wm*}h>XM8nm4!6Xs zL3*iS*|4Xum}{$I+2Eh3A{~0pH9}X2tz#G{aIXs?8F|7lT(PAOJtc_;-w%*NFk45h zgz`Greg$wQfo0qTF6u+gfhdKrsi+axsP+xMv>sEhZX<=moYW@@vfF=P)KfNN5<86h-nPZ)P- zNY`0DAcyD04Qahr@{^lQ0b?&~|IYnZhtu(H8E1hgHK>(W>Z#KV2s_+yS91c+LIJE? z>aEIByPe%R-7bh|4S#33$k(mh4)Ua(k_EWKDkpiS&hp!qj`W!w6jl9!Wb_o>r%TZ$ ziuN<#0=5uOUcl%;UR*Q1RPS7OhV9cC>Ngm3 zLs>*`*AH&##C^}lJEaHzS-yJfT-N;fQHJjl_u1ZUWgY8u*@pjD{r%sV4mGqMFO$f4 Y{T?~@v8(?1U&o`qjLabeldev.lizardbyte.app.libvirtualhid ProgramArguments - /Library/Application Support/libvirtualhid/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker + /Applications/VirtualHIDBroker.app/Contents/MacOS/VirtualHIDBroker RunAtLoad KeepAlive diff --git a/scripts/macos/install.command b/scripts/macos/install.command index 8e574566..81054dee 100755 --- a/scripts/macos/install.command +++ b/scripts/macos/install.command @@ -5,16 +5,24 @@ image_root="$(cd "$(dirname "$0")" && pwd)" bundle_id="dev.lizardbyte.app.libvirtualhid" support_directory="/Library/Application Support/libvirtualhid" launchd_plist="${bundle_id}.plist" -broker_app="${image_root}/usr/local/libexec/libvirtualhid/VirtualHIDBroker.app" -installed_app="${support_directory}/VirtualHIDBroker.app" +broker_app="${image_root}/VirtualHIDBroker.app" +control_app="${image_root}/VirtualHIDControl.app" +installed_broker_app="/Applications/VirtualHIDBroker.app" +installed_control_app="/Applications/VirtualHIDControl.app" +legacy_broker_app="${support_directory}/VirtualHIDBroker.app" service_plist="/Library/LaunchDaemons/${launchd_plist}" if [[ ! -d "${broker_app}" ]]; then echo "The virtual HID broker is missing from this disk image." >&2 exit 1 fi +if [[ ! -d "${control_app}" ]]; then + echo "The virtual HID control app is missing from this disk image." >&2 + exit 1 +fi /usr/bin/codesign --verify --deep --strict "${broker_app}" +/usr/bin/codesign --verify --deep --strict "${control_app}" /usr/bin/sudo -v if [[ -L "${support_directory}" ]]; then echo "The libvirtualhid state directory must not be a symbolic link." >&2 @@ -24,11 +32,14 @@ fi /usr/bin/sudo /usr/sbin/chown root:wheel "${support_directory}" /usr/bin/sudo /bin/chmod 700 "${support_directory}" /usr/bin/sudo /bin/launchctl bootout "system/${bundle_id}" 2>/dev/null || true -/usr/bin/sudo /bin/rm -rf "${installed_app}" -/usr/bin/sudo /usr/bin/ditto "${broker_app}" "${installed_app}" -/usr/bin/sudo /usr/sbin/chown -R root:wheel "${installed_app}" -/usr/bin/sudo /bin/chmod -R go-w "${installed_app}" -/usr/bin/sudo /usr/bin/codesign --verify --deep --strict "${installed_app}" +/usr/bin/sudo /bin/rm -rf "${legacy_broker_app}" "${installed_broker_app}" "${installed_control_app}" +/usr/bin/sudo /usr/bin/ditto "${broker_app}" "${installed_broker_app}" +/usr/bin/sudo /usr/bin/ditto "${control_app}" "${installed_control_app}" +/usr/bin/sudo /usr/sbin/chown -R root:wheel "${installed_broker_app}" "${installed_control_app}" +/usr/bin/sudo /bin/chmod -R a+rX "${installed_broker_app}" "${installed_control_app}" +/usr/bin/sudo /bin/chmod -R go-w "${installed_broker_app}" "${installed_control_app}" +/usr/bin/sudo /usr/bin/codesign --verify --deep --strict "${installed_broker_app}" +/usr/bin/sudo /usr/bin/codesign --verify --deep --strict "${installed_control_app}" /usr/bin/sudo /usr/bin/install -m 0644 \ "${image_root}/${launchd_plist}" "${service_plist}" /usr/bin/sudo /usr/sbin/chown root:wheel "${service_plist}" @@ -53,7 +64,8 @@ fi /usr/bin/sudo /bin/launchctl bootstrap system "${service_plist}" /usr/bin/sudo /bin/launchctl kickstart -k "system/${bundle_id}" -echo "libvirtualhid broker installed. Run: /usr/local/bin/libvirtualhid-license activate" -echo 'To create gamepads, grant VirtualHIDBroker.app from this DMG access in' +echo 'Virtual HID Broker and Virtual HID Control installed in /Applications.' +echo 'Activate a license if needed: /usr/local/bin/libvirtualhid-license activate' +echo 'To create gamepads, grant Virtual HID Broker from /Applications access in' echo 'System Settings > Privacy & Security > Device Control and Data Access' echo '(Accessibility on older macOS).' diff --git a/scripts/macos/package-dmg.sh b/scripts/macos/package-dmg.sh index 389a7833..3e306aef 100755 --- a/scripts/macos/package-dmg.sh +++ b/scripts/macos/package-dmg.sh @@ -13,7 +13,8 @@ mkdir -p "${output_directory}" output_directory="$(cd "${output_directory}" && pwd)" stage_directory="${build_directory}/macos-dmg-stage" image_root="${stage_directory}/image" -broker_app="${image_root}/usr/local/libexec/libvirtualhid/VirtualHIDBroker.app" +broker_app="${image_root}/VirtualHIDBroker.app" +control_app="${image_root}/VirtualHIDControl.app" profile_path="${APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE:-}" signing_identity="${APPLE_CODESIGN_IDENTITY:-}" @@ -26,6 +27,8 @@ fi rm -rf "${stage_directory}" mkdir -p "${image_root}" DESTDIR="${image_root}" cmake --install "${build_directory}" --prefix /usr/local +mv "${image_root}/usr/local/libexec/libvirtualhid/VirtualHIDBroker.app" "${broker_app}" +mv "${image_root}/usr/local/Applications/VirtualHIDControl.app" "${control_app}" profile_details="${stage_directory}/profile.plist" /usr/bin/security cms -D -i "${profile_path}" > "${profile_details}" @@ -51,6 +54,9 @@ cp "${profile_path}" "${broker_app}/Contents/embedded.provisionprofile" --entitlements "${repository_root}/src/platform/macos/broker/entitlements.plist" \ "${broker_app}" /usr/bin/codesign --verify --deep --strict --verbose=2 "${broker_app}" +/usr/bin/codesign --force --timestamp --options runtime \ + --sign "${signing_identity}" "${control_app}" +/usr/bin/codesign --verify --deep --strict --verbose=2 "${control_app}" /usr/bin/codesign --force --timestamp --options runtime \ --sign "${signing_identity}" "${image_root}/usr/local/bin/libvirtualhid-license" /usr/bin/codesign --verify --strict --verbose=2 \ diff --git a/src/platform/macos/broker/CMakeLists.txt b/src/platform/macos/broker/CMakeLists.txt index 7174e725..2d4c6241 100644 --- a/src/platform/macos/broker/CMakeLists.txt +++ b/src/platform/macos/broker/CMakeLists.txt @@ -12,7 +12,10 @@ configure_file("${CMAKE_CURRENT_SOURCE_DIR}/Info.plist" add_executable(libvirtualhid_macos_broker MACOSX_BUNDLE "${CMAKE_CURRENT_SOURCE_DIR}/libvirtualhid_macos_broker.cpp" - "${CMAKE_CURRENT_SOURCE_DIR}/license_manager.mm") + "${CMAKE_CURRENT_SOURCE_DIR}/license_manager.mm" + "${PROJECT_SOURCE_DIR}/assets/macos/libvirtualhid.icns") +set_source_files_properties("${PROJECT_SOURCE_DIR}/assets/macos/libvirtualhid.icns" + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) target_compile_features(libvirtualhid_macos_broker PRIVATE cxx_std_23) set_target_properties(libvirtualhid_macos_broker PROPERTIES OUTPUT_NAME VirtualHIDBroker diff --git a/src/platform/macos/broker/Info.plist b/src/platform/macos/broker/Info.plist index cc6802ea..bff5df2a 100644 --- a/src/platform/macos/broker/Info.plist +++ b/src/platform/macos/broker/Info.plist @@ -7,6 +7,8 @@ CFBundleIdentifierdev.lizardbyte.app.libvirtualhid CFBundleInfoDictionaryVersion6.0 CFBundleNameVirtualHIDBroker + CFBundleDisplayNameVirtual HID Broker + CFBundleIconFilelibvirtualhid.icns CFBundlePackageTypeAPPL CFBundleShortVersionString@LIBVIRTUALHID_MACOS_BUNDLE_VERSION@ CFBundleVersion@LIBVIRTUALHID_MACOS_BUNDLE_VERSION@ diff --git a/src/platform/macos/broker/io.hpp b/src/platform/macos/broker/io.hpp index 44afb189..c9a40603 100644 --- a/src/platform/macos/broker/io.hpp +++ b/src/platform/macos/broker/io.hpp @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 +/** + * @file src/platform/macos/broker/io.hpp + * @brief Socket transport and peer verification for the macOS broker. + */ + #pragma once #include "protocol.hpp" diff --git a/src/platform/macos/broker/license_manager.hpp b/src/platform/macos/broker/license_manager.hpp index 8d9b81b2..bccb3d8a 100644 --- a/src/platform/macos/broker/license_manager.hpp +++ b/src/platform/macos/broker/license_manager.hpp @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 +/** + * @file src/platform/macos/broker/license_manager.hpp + * @brief Machine-scoped licensing state for the macOS virtual HID broker. + */ + #pragma once #include "protocol.hpp" diff --git a/src/platform/windows/shared/lvh_windows_broker_config.hpp b/src/platform/windows/shared/lvh_windows_broker_config.hpp index 49b04b63..96836879 100644 --- a/src/platform/windows/shared/lvh_windows_broker_config.hpp +++ b/src/platform/windows/shared/lvh_windows_broker_config.hpp @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 +/** + * @file src/platform/windows/shared/lvh_windows_broker_config.hpp + * @brief Windows alias for the shared broker licensing policy. + */ + #pragma once #include "../../shared/lvh_broker_license_policy.hpp" diff --git a/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp b/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp index ca40b9ce..62d2764f 100644 --- a/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp +++ b/src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp @@ -1,6 +1,11 @@ // SPDX-FileCopyrightText: 2026 LIZARDBYTE LLC // SPDX-License-Identifier: LicenseRef-LizardByte-SAL-1.0 +/** + * @file src/platform/windows/shared/lvh_windows_github_actions_evaluation.hpp + * @brief Windows alias for the shared GitHub Actions evaluation policy. + */ + #pragma once #include "../../shared/lvh_broker_license_policy.hpp" diff --git a/tests/package-consumer/main.cpp b/tests/package-consumer/main.cpp index 71d1f5e6..71125cb9 100644 --- a/tests/package-consumer/main.cpp +++ b/tests/package-consumer/main.cpp @@ -1,3 +1,8 @@ +/** + * @file tests/package-consumer/main.cpp + * @brief Smoke test that the installed package exposes built-in profiles. + */ + #include int main() { diff --git a/tools/CMakeLists.txt b/tools/CMakeLists.txt index 4ffdff2c..ff3c32ca 100644 --- a/tools/CMakeLists.txt +++ b/tools/CMakeLists.txt @@ -47,8 +47,21 @@ if(MSVC AND LIBVIRTUALHID_TOOLS_STATIC_RUNTIME AND LIBVIRTUALHID_BUILD_WINDOWS_D MSVC_RUNTIME_LIBRARY "MultiThreaded$<$:Debug>") endif() -add_executable(virtualhid_control - "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control.cpp") +if(APPLE) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/macos/Info.plist" + "${CMAKE_CURRENT_BINARY_DIR}/Info.plist" @ONLY) + add_executable(virtualhid_control MACOSX_BUNDLE + "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control.cpp" + "${PROJECT_SOURCE_DIR}/assets/macos/libvirtualhid.icns") + set_source_files_properties("${PROJECT_SOURCE_DIR}/assets/macos/libvirtualhid.icns" + PROPERTIES MACOSX_PACKAGE_LOCATION Resources) + set_target_properties(virtualhid_control PROPERTIES + OUTPUT_NAME VirtualHIDControl + MACOSX_BUNDLE_INFO_PLIST "${CMAKE_CURRENT_BINARY_DIR}/Info.plist") +else() + add_executable(virtualhid_control + "${CMAKE_CURRENT_SOURCE_DIR}/virtualhid_control.cpp") +endif() target_link_libraries(virtualhid_control PRIVATE @@ -97,5 +110,10 @@ if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" AND LIBVIRTUALHID_TOOLS_FULLY_STATIC) -static) endif() -install(TARGETS virtualhid_control - RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +if(APPLE) + install(TARGETS virtualhid_control + BUNDLE DESTINATION Applications) +else() + install(TARGETS virtualhid_control + RUNTIME DESTINATION "${CMAKE_INSTALL_BINDIR}") +endif() diff --git a/tools/macos/Info.plist b/tools/macos/Info.plist new file mode 100644 index 00000000..c5f7506c --- /dev/null +++ b/tools/macos/Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleDevelopmentRegionen + CFBundleExecutableVirtualHIDControl + CFBundleIdentifierdev.lizardbyte.app.libvirtualhid.control + CFBundleInfoDictionaryVersion6.0 + CFBundleNameVirtualHIDControl + CFBundleDisplayNameVirtual HID Control + CFBundleIconFilelibvirtualhid.icns + CFBundlePackageTypeAPPL + CFBundleShortVersionString@PROJECT_VERSION@ + CFBundleVersion@PROJECT_VERSION@ + + From e108e7b23172b541e77ea06a8580d38b6794b8b8 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:38:21 -0400 Subject: [PATCH 23/32] Fix macOS Xbox gamepad mapping and Steam discovery --- docs/macos-gamepad.md | 17 ++- src/platform/macos/macos_broker_client.cpp | 21 ++- src/platform/macos/macos_xbox_transport.hpp | 136 ++++++++++++++++++++ tests/unit/test_macos_broker_protocol.cpp | 55 ++++++++ 4 files changed, 216 insertions(+), 13 deletions(-) create mode 100644 src/platform/macos/macos_xbox_transport.hpp diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index e453a29e..f04c2576 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -13,12 +13,17 @@ the broker checks the machine license before gamepad creation. The built-in generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation -variants, are accepted as HID descriptors. Their VID/PID, transport, input -reports, output reports, and PlayStation feature reports are carried through the -broker. Xbox 360 is an ordinary HID device on macOS; the Windows XUSB/XInput -personality is Windows-specific. Individual games may use Apple's Game -Controller framework or their own HID mappings, so a signed installed build -still needs consumer testing for each profile. +variants, are accepted. The macOS broker receives the selected transport's HID +descriptor and reports. The Xbox profiles use an Xbox 360-compatible USB HID +identity (`045e:028e`, version `0114`) on macOS because Steam's macOS mapping +for that identity expects fifteen mapped buttons, including D-pad buttons. A +sixteenth HID button carries Xbox Series Share when the client supplies it. +Xbox One and Xbox Series therefore appear as Xbox 360-compatible controllers to +macOS HID consumers; their public API button, stick, and trigger states are +translated for that transport. The Windows XUSB/XInput personality is +Windows-specific. Individual games may use Apple's Game Controller framework +or their own HID mappings, so a signed installed build still needs consumer +testing for each profile. When metadata omits a stable ID, the client derives a locally administered `02:00:xx:xx:xx:xx` identifier from the device ID. diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 7f47502e..162bbe34 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -6,6 +6,7 @@ #include "platform/macos/macos_broker_client.hpp" #include "platform/macos/broker/io.hpp" +#include "platform/macos/macos_xbox_transport.hpp" #include "platform/shared/lvh_broker_license_policy.hpp" #include @@ -49,9 +50,10 @@ namespace lvh::detail { class MacosGamepad final: public BackendGamepad { public: - MacosGamepad(int fd, DeviceProfile profile): + MacosGamepad(int fd, DeviceProfile profile, bool xbox_transport): fd_ {fd}, profile_ {std::move(profile)}, + xbox_transport_ {xbox_transport}, reader_ {[this, fd] { read_loop(fd); }}, @@ -63,14 +65,18 @@ namespace lvh::detail { static_cast(close()); } - OperationStatus submit(const GamepadState & /*state*/, const std::vector &report) override { - if (report.empty() || report.size() > macos_broker::max_report_size) { + OperationStatus submit(const GamepadState &state, const std::vector &report) override { + const auto xbox_report = xbox_transport_ ? + macos::xbox_transport_input_report(state) : + std::vector {}; + const auto &transport_report = xbox_transport_ ? xbox_report : report; + if (transport_report.empty() || transport_report.size() > macos_broker::max_report_size) { return OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad report exceeds broker limit"); } macos_broker::Message request; request.type = macos_broker::MessageType::submit; - request.size = static_cast(report.size()); - std::ranges::copy(report, request.data.begin()); + request.size = static_cast(transport_report.size()); + std::ranges::copy(transport_report, request.data.begin()); return call(request); } @@ -213,6 +219,7 @@ namespace lvh::detail { int fd_; DeviceProfile profile_; + bool xbox_transport_; std::shared_ptr callback_state_ = std::make_shared(); std::jthread reader_; std::jthread callback_thread_; @@ -260,7 +267,7 @@ namespace lvh::detail { } // namespace BackendGamepadCreationResult create_macos_brokered_gamepad(DeviceId id, const CreateGamepadOptions &options) { - const auto &profile = options.profile; + const auto profile = macos::xbox_transport_profile(options.profile); if (profile.device_type != DeviceType::gamepad || profile.report_descriptor.empty() || profile.report_descriptor.size() > macos_broker::max_descriptor_size || profile.input_report_size == 0 || profile.input_report_size > macos_broker::max_report_size || profile.output_report_size > macos_broker::max_report_size) { return {OperationStatus::failure(ErrorCode::unsupported_profile, "macOS broker requires a valid gamepad HID descriptor and report sizes"), nullptr}; } @@ -302,7 +309,7 @@ namespace lvh::detail { } timeval no_receive_timeout {.tv_sec = 0, .tv_usec = 0}; static_cast(::setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &no_receive_timeout, sizeof(no_receive_timeout))); - return {OperationStatus::success(), std::make_unique(fd, profile)}; + return {OperationStatus::success(), std::make_unique(fd, profile, macos::uses_xbox_transport(options.profile))}; } } // namespace lvh::detail diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp new file mode 100644 index 00000000..3799db03 --- /dev/null +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -0,0 +1,136 @@ +/** + * @file src/platform/macos/macos_xbox_transport.hpp + * @brief Steam-compatible macOS HID transport for Xbox gamepad profiles. + */ +#pragma once + +#include +#include +#include +#include + +namespace lvh::detail::macos { + + inline bool uses_xbox_transport(const DeviceProfile &profile) { + if (profile.vendor_id != 0x045E) { + return false; + } + switch (profile.gamepad_kind) { + case GamepadProfileKind::xbox_360: + return profile.product_id == 0x028E; + case GamepadProfileKind::xbox_one: + return profile.product_id == 0x02EA; + case GamepadProfileKind::xbox_series: + return profile.product_id == 0x0B12; + default: + return false; + } + } + + inline DeviceProfile xbox_transport_profile(const DeviceProfile &requested) { + if (!uses_xbox_transport(requested)) { + return requested; + } + + // macOS Steam maps 045e:028e version 0114 as fifteen numbered buttons, + // six axes, and a USB device. The public Xbox One/Series GIP descriptors + // are not usable by its macOS HID path, so all Xbox profiles use this + // compatible transport while keeping their public profile unchanged. + auto transport = profiles::xbox_360(); + transport.name = requested.name; + transport.report_descriptor = { + 0x05, + 0x01, // Generic Desktop + 0x09, + 0x05, // Game Pad + 0xA1, + 0x01, // Application collection + 0x85, + 0x01, // Report ID 1 + 0x05, + 0x09, // Button page + 0x19, + 0x01, // Button 1 + 0x29, + 0x10, // Button 16 (Series Share is button 16) + 0x15, + 0x00, + 0x25, + 0x01, + 0x75, + 0x01, + 0x95, + 0x10, + 0x81, + 0x02, // Fifteen mapped buttons and optional Share + 0x05, + 0x01, // Generic Desktop + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x06, + 0x09, + 0x30, // X + 0x09, + 0x31, // Y + 0x09, + 0x32, // Z (left trigger) + 0x09, + 0x33, // Rx + 0x09, + 0x34, // Ry + 0x09, + 0x35, // Rz (right trigger) + 0x81, + 0x02, + 0x06, + 0x00, + 0xFF, // Vendor-defined rumble output + 0x09, + 0x01, + 0x15, + 0x00, + 0x26, + 0xFF, + 0x00, + 0x75, + 0x08, + 0x95, + 0x04, + 0x91, + 0x02, + 0xC0, + }; + return transport; + } + + inline std::vector xbox_transport_input_report(const GamepadState &state) { + auto report = reports::pack_input_report(profiles::xbox_360(), state); + if (report.size() != 9U) { + return {}; + } + + using enum GamepadButton; + // SDL's macOS mapping for this identity assigns stick clicks to 6/7, + // Start/Back/Guide to 8/9/10, and the D-pad to buttons 11 through 14. + const auto button = [&state](GamepadButton value, unsigned int bit) { + return state.buttons.test(value) ? (1U << bit) : 0U; + }; + const auto bits = button(a, 0) | button(b, 1) | button(x, 2) | button(y, 3) | + button(left_shoulder, 4) | button(right_shoulder, 5) | + button(left_stick, 6) | button(right_stick, 7) | + button(start, 8) | button(back, 9) | button(guide, 10) | + button(dpad_up, 11) | button(dpad_down, 12) | + button(dpad_left, 13) | button(dpad_right, 14) | + button(misc1, 15); + report[1] = static_cast(bits & 0xFFU); + report[2] = static_cast((bits >> 8U) & 0xFFU); + return report; + } + +} // namespace lvh::detail::macos diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 18544834..4def7ee7 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -4,10 +4,12 @@ */ #include "platform/macos/broker/io.hpp" +#include "platform/macos/macos_xbox_transport.hpp" #include #include #include +#include #include #include #include @@ -52,3 +54,56 @@ TEST(MacosBrokerProtocolTest, TransfersVersionedMessagesWithoutTruncation) { ::close(sockets[0]); ::close(sockets[1]); } + +TEST(MacosBrokerProtocolTest, XboxTransportUsesSteamMacOSIdentityAndButtonLayout) { + using enum lvh::GamepadButton; + + for (const auto &requested : {lvh::profiles::xbox_360(), lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { + SCOPED_TRACE(requested.name); + const auto transport = lvh::detail::macos::xbox_transport_profile(requested); + EXPECT_EQ(transport.vendor_id, 0x045E); + EXPECT_EQ(transport.product_id, 0x028E); + EXPECT_EQ(transport.version, 0x0114); + EXPECT_EQ(transport.input_report_size, 9U); + EXPECT_EQ(transport.report_id, 1); + EXPECT_NE(transport.report_descriptor, requested.report_descriptor); + + const std::array buttons {a, b, x, y, left_shoulder, right_shoulder, left_stick, right_stick, start, back, guide, dpad_up, dpad_down, dpad_left, dpad_right, misc1}; + for (std::size_t bit = 0; bit < buttons.size(); ++bit) { + lvh::GamepadState state; + state.buttons.set(buttons[bit]); + const auto report = lvh::detail::macos::xbox_transport_input_report(state); + ASSERT_EQ(report.size(), transport.input_report_size); + const auto flags = static_cast(report[1] | (report[2] << 8U)); + EXPECT_EQ(flags, static_cast(1U << bit)); + } + } +} + +TEST(MacosBrokerProtocolTest, XboxTransportKeepsAxesAndSharedReportsUntouched) { + lvh::GamepadState state; + state.left_stick = {1.0F, -1.0F}; + state.right_stick = {-1.0F, 1.0F}; + state.left_trigger = 0.5F; + state.right_trigger = 1.0F; + state.buttons.set(lvh::GamepadButton::back); + const auto shared = lvh::reports::pack_input_report(lvh::profiles::xbox_360(), state); + const auto transport = lvh::detail::macos::xbox_transport_input_report(state); + ASSERT_EQ(shared.size(), 9U); + ASSERT_EQ(transport.size(), 9U); + EXPECT_EQ(shared[1], 0x40U); + EXPECT_EQ(transport[2], 0x02U); + for (std::size_t index = 3; index < shared.size(); ++index) { + EXPECT_EQ(transport[index], shared[index]); + } +} + +TEST(MacosBrokerProtocolTest, XboxTransportLeavesCustomHidProfilesUntouched) { + auto custom = lvh::profiles::xbox_360(); + custom.vendor_id = 0x1209; + const auto transport = lvh::detail::macos::xbox_transport_profile(custom); + EXPECT_FALSE(lvh::detail::macos::uses_xbox_transport(custom)); + EXPECT_EQ(transport.vendor_id, custom.vendor_id); + EXPECT_EQ(transport.product_id, custom.product_id); + EXPECT_EQ(transport.report_descriptor, custom.report_descriptor); +} From 348698f61b152ea78d8f634215fd8a2d8027ce02 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:59:37 -0400 Subject: [PATCH 24/32] Expose distinct Xbox One and Series HID identities on macOS --- docs/macos-gamepad.md | 18 +- src/platform/macos/macos_broker_client.cpp | 9 +- .../macos/macos_xbox_bluetooth_descriptor.hpp | 332 ++++++++++++++++++ src/platform/macos/macos_xbox_transport.hpp | 53 ++- tests/unit/test_macos_broker_protocol.cpp | 61 +++- 5 files changed, 443 insertions(+), 30 deletions(-) create mode 100644 src/platform/macos/macos_xbox_bluetooth_descriptor.hpp diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index f04c2576..4cfe0026 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -14,16 +14,14 @@ the broker checks the machine license before gamepad creation. The built-in generic, Xbox 360, Xbox One, Xbox Series, DualShock 4, DualSense, and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation variants, are accepted. The macOS broker receives the selected transport's HID -descriptor and reports. The Xbox profiles use an Xbox 360-compatible USB HID -identity (`045e:028e`, version `0114`) on macOS because Steam's macOS mapping -for that identity expects fifteen mapped buttons, including D-pad buttons. A -sixteenth HID button carries Xbox Series Share when the client supplies it. -Xbox One and Xbox Series therefore appear as Xbox 360-compatible controllers to -macOS HID consumers; their public API button, stick, and trigger states are -translated for that transport. The Windows XUSB/XInput personality is -Windows-specific. Individual games may use Apple's Game Controller framework -or their own HID mappings, so a signed installed build still needs consumer -testing for each profile. +descriptor and reports. Xbox 360 uses a USB HID identity (`045e:028e`, version +`0114`) with numbered D-pad buttons that match Steam's macOS mapping. Xbox One +and Xbox Series use their respective Xbox Bluetooth HID identities +(`045e:0b20` and `045e:0b13`); the macOS backend translates their public GIP +input reports into the corresponding Bluetooth report layout. The Windows +XUSB/XInput personality is Windows-specific. Individual games may use Apple's +Game Controller framework or their own HID mappings, so a signed installed +build still needs consumer testing for each profile. When metadata omits a stable ID, the client derives a locally administered `02:00:xx:xx:xx:xx` identifier from the device ID. diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 162bbe34..08a1463c 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -66,9 +66,12 @@ namespace lvh::detail { } OperationStatus submit(const GamepadState &state, const std::vector &report) override { - const auto xbox_report = xbox_transport_ ? - macos::xbox_transport_input_report(state) : - std::vector {}; + std::vector xbox_report; + if (xbox_transport_) { + xbox_report = profile_.gamepad_kind == GamepadProfileKind::xbox_360 ? + macos::xbox_transport_input_report(state) : + macos::xbox_bluetooth_input_report(state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series); + } const auto &transport_report = xbox_transport_ ? xbox_report : report; if (transport_report.empty() || transport_report.size() > macos_broker::max_report_size) { return OperationStatus::failure(ErrorCode::invalid_argument, "macOS gamepad report exceeds broker limit"); diff --git a/src/platform/macos/macos_xbox_bluetooth_descriptor.hpp b/src/platform/macos/macos_xbox_bluetooth_descriptor.hpp new file mode 100644 index 00000000..d4d4ceef --- /dev/null +++ b/src/platform/macos/macos_xbox_bluetooth_descriptor.hpp @@ -0,0 +1,332 @@ +/** + * @file src/platform/macos/macos_xbox_bluetooth_descriptor.hpp + * @brief Xbox Bluetooth HID descriptor used by the macOS broker transport. + */ +#pragma once + +#include +#include +#include + +namespace lvh::detail::macos { + inline constexpr std::uint8_t xbox_bluetooth_input_report_id = 0x01; + inline constexpr std::uint8_t xbox_bluetooth_rumble_report_id = 0x03; + inline constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04; + + inline std::vector make_xbox_bluetooth_report_descriptor(bool include_battery) { + // Match the Xbox Bluetooth input, rumble, and battery report layout. + // Steam's HIDAPI driver reads these byte offsets for wireless Xbox pads. + std::vector descriptor { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x05, // Usage (Game Pad) + 0xA1, + 0x01, // Collection (Application) + 0x85, + xbox_bluetooth_input_report_id, // Report ID (1) + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x09, + 0x30, // Usage (X) + 0x09, + 0x31, // Usage (Y) + 0x15, + 0x00, // Logical Minimum (0) + 0x27, + 0xFF, + 0xFF, + 0x00, + 0x00, // Logical Maximum (65534) + 0x95, + 0x02, // Report Count (2) + 0x75, + 0x10, // Report Size (16) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0xC0, // End Collection + 0x09, + 0x01, // Usage (Pointer) + 0xA1, + 0x00, // Collection (Physical) + 0x09, + 0x33, // Usage (Rx) + 0x09, + 0x34, // Usage (Ry) + 0x15, + 0x00, // Logical Minimum (0) + 0x27, + 0xFF, + 0xFF, + 0x00, + 0x00, // Logical Maximum (65534) + 0x95, + 0x02, // Report Count (2) + 0x75, + 0x10, // Report Size (16) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0xC0, // End Collection + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x32, // Usage (Z) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x03, // Logical Maximum (1023) + 0x95, + 0x01, // Report Count (1) + 0x75, + 0x0A, // Report Size (10) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x06, // Report Size (6) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x35, // Usage (Rz) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x03, // Logical Maximum (1023) + 0x95, + 0x01, // Report Count (1) + 0x75, + 0x0A, // Report Size (10) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x06, // Report Size (6) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x39, // Usage (Hat Switch) + 0x15, + 0x01, // Logical Minimum (1) + 0x25, + 0x08, // Logical Maximum (8) + 0x35, + 0x00, // Physical Minimum (0) + 0x46, + 0x3B, + 0x01, // Physical Maximum (315) + 0x66, + 0x14, + 0x00, // Unit (Degrees) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x42, // Input (Data, Variable, Absolute, Null State) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x35, + 0x00, // Physical Minimum (0) + 0x45, + 0x00, // Physical Maximum (0) + 0x65, + 0x00, // Unit (None) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x09, // Usage Page (Button) + 0x19, + 0x01, // Usage Minimum (Button 1) + 0x29, + 0x0F, // Usage Maximum (Button 15) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x0F, // Report Count (15) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x01, // Report Size (1) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x0C, // Usage Page (Consumer) + 0x0A, + 0xB2, + 0x00, // Usage (Record) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x95, + 0x01, // Report Count (1) + 0x75, + 0x01, // Report Size (1) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x07, // Report Size (7) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x03, // Input (Constant, Variable, Absolute) + 0x05, + 0x0F, // Usage Page (Physical Interface Device) + 0x09, + 0x21, // Usage (Set Effect Report) + 0x85, + xbox_bluetooth_rumble_report_id, // Report ID (3) + 0xA1, + 0x02, // Collection (Logical) + 0x09, + 0x97, // Usage (DC Enable Actuators) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x01, // Logical Maximum (1) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x00, // Logical Maximum (0) + 0x75, + 0x04, // Report Size (4) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x03, // Output (Constant, Variable, Absolute) + 0x09, + 0x70, // Usage (Magnitude) + 0x15, + 0x00, // Logical Minimum (0) + 0x25, + 0x64, // Logical Maximum (100) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x04, // Report Count (4) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x09, + 0x50, // Usage (Duration) + 0x66, + 0x01, + 0x10, // Unit (Seconds) + 0x55, + 0x0E, // Unit Exponent (-2) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x09, + 0xA7, // Usage (Start Delay) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x65, + 0x00, // Unit (None) + 0x55, + 0x00, // Unit Exponent (0) + 0x09, + 0x7C, // Usage (Loop Count) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0xC0, // End Collection + }; + + if (include_battery) { + // Keep the native wireless/source flag in a byte and the logical + // range within the four Xbox Bluetooth battery levels. + constexpr std::array battery_descriptor { + 0x05, + 0x06, // Usage Page (Generic Device Controls) + 0x09, + 0x20, // Usage (Battery Strength) + 0x85, + xbox_bluetooth_battery_report_id, // Report ID (4) + 0x15, + 0x04, // Logical Minimum (wireless, empty) + 0x25, + 0x07, // Logical Maximum (wireless, full) + 0x75, + 0x08, // Report Size (8) + 0x95, + 0x01, // Report Count (1) + 0x81, + 0x02, // Input (Data, Variable, Absolute) + }; + descriptor.insert(descriptor.end(), battery_descriptor.begin(), battery_descriptor.end()); + } + + descriptor.push_back(0xC0); // End Collection + return descriptor; + } + +} // namespace lvh::detail::macos diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp index 3799db03..86b1cc00 100644 --- a/src/platform/macos/macos_xbox_transport.hpp +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -4,9 +4,13 @@ */ #pragma once +#include "platform/macos/macos_xbox_bluetooth_descriptor.hpp" + +#include #include #include #include +#include #include namespace lvh::detail::macos { @@ -32,10 +36,20 @@ namespace lvh::detail::macos { return requested; } + if (requested.gamepad_kind != GamepadProfileKind::xbox_360) { + auto transport = requested; + transport.bus_type = BusType::bluetooth; + transport.product_id = requested.gamepad_kind == GamepadProfileKind::xbox_series ? 0x0B13 : 0x0B20; + transport.version = 0x0513; + transport.report_id = xbox_bluetooth_input_report_id; + transport.input_report_size = 17; + transport.output_report_size = 9; + transport.report_descriptor = make_xbox_bluetooth_report_descriptor(true); + return transport; + } + // macOS Steam maps 045e:028e version 0114 as fifteen numbered buttons, - // six axes, and a USB device. The public Xbox One/Series GIP descriptors - // are not usable by its macOS HID path, so all Xbox profiles use this - // compatible transport while keeping their public profile unchanged. + // six axes, and a USB device. auto transport = profiles::xbox_360(); transport.name = requested.name; transport.report_descriptor = { @@ -52,7 +66,7 @@ namespace lvh::detail::macos { 0x19, 0x01, // Button 1 0x29, - 0x10, // Button 16 (Series Share is button 16) + 0x10, // Button 16 (optional miscellaneous input) 0x15, 0x00, 0x25, @@ -133,4 +147,35 @@ namespace lvh::detail::macos { return report; } + inline std::vector xbox_bluetooth_input_report( + const GamepadState &state, + std::span packed_report, + bool include_share_button + ) { + if (packed_report.size() < 17U) { + return {}; + } + + using enum GamepadButton; + std::vector report(17U, 0); + report[0] = xbox_bluetooth_input_report_id; + std::copy_n(packed_report.begin(), 8U, report.begin() + 1U); + std::copy_n(packed_report.begin() + 8U, 4U, report.begin() + 9U); + report[13] = packed_report[14]; + report[14] = static_cast( + (state.buttons.test(a) ? 0x01U : 0U) | (state.buttons.test(b) ? 0x02U : 0U) | + (state.buttons.test(x) ? 0x08U : 0U) | (state.buttons.test(y) ? 0x10U : 0U) | + (state.buttons.test(left_shoulder) ? 0x40U : 0U) | (state.buttons.test(right_shoulder) ? 0x80U : 0U) + ); + report[15] = static_cast( + (state.buttons.test(back) ? 0x04U : 0U) | (state.buttons.test(start) ? 0x08U : 0U) | + (state.buttons.test(guide) ? 0x10U : 0U) | (state.buttons.test(left_stick) ? 0x20U : 0U) | + (state.buttons.test(right_stick) ? 0x40U : 0U) + ); + if (include_share_button && state.buttons.test(misc1)) { + report[16] = 0x01; + } + return report; + } + } // namespace lvh::detail::macos diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 4def7ee7..2a066a58 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -28,6 +28,10 @@ TEST(MacosBrokerProtocolTest, BuiltInProfilesFitBrokerTransport) { EXPECT_GT(profile.input_report_size, 0U); EXPECT_LE(profile.input_report_size, lvh::detail::macos_broker::max_report_size); EXPECT_LE(profile.output_report_size, lvh::detail::macos_broker::max_report_size); + const auto transport = lvh::detail::macos::xbox_transport_profile(profile); + EXPECT_LE(transport.report_descriptor.size(), lvh::detail::macos_broker::max_descriptor_size); + EXPECT_LE(transport.input_report_size, lvh::detail::macos_broker::max_report_size); + EXPECT_LE(transport.output_report_size, lvh::detail::macos_broker::max_report_size); } } @@ -58,25 +62,56 @@ TEST(MacosBrokerProtocolTest, TransfersVersionedMessagesWithoutTruncation) { TEST(MacosBrokerProtocolTest, XboxTransportUsesSteamMacOSIdentityAndButtonLayout) { using enum lvh::GamepadButton; - for (const auto &requested : {lvh::profiles::xbox_360(), lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { + const auto requested = lvh::profiles::xbox_360(); + const auto transport = lvh::detail::macos::xbox_transport_profile(requested); + EXPECT_EQ(transport.vendor_id, 0x045E); + EXPECT_EQ(transport.product_id, 0x028E); + EXPECT_EQ(transport.version, 0x0114); + EXPECT_EQ(transport.input_report_size, 9U); + EXPECT_EQ(transport.report_id, 1); + EXPECT_NE(transport.report_descriptor, requested.report_descriptor); + + const std::array buttons {a, b, x, y, left_shoulder, right_shoulder, left_stick, right_stick, start, back, guide, dpad_up, dpad_down, dpad_left, dpad_right, misc1}; + for (std::size_t bit = 0; bit < buttons.size(); ++bit) { + lvh::GamepadState state; + state.buttons.set(buttons[bit]); + const auto report = lvh::detail::macos::xbox_transport_input_report(state); + ASSERT_EQ(report.size(), transport.input_report_size); + const auto flags = static_cast(report[1] | (report[2] << 8U)); + EXPECT_EQ(flags, static_cast(1U << bit)); + } +} + +TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctBluetoothIdentities) { + for (const auto &requested : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { SCOPED_TRACE(requested.name); const auto transport = lvh::detail::macos::xbox_transport_profile(requested); EXPECT_EQ(transport.vendor_id, 0x045E); - EXPECT_EQ(transport.product_id, 0x028E); - EXPECT_EQ(transport.version, 0x0114); - EXPECT_EQ(transport.input_report_size, 9U); + EXPECT_EQ(transport.product_id, requested.gamepad_kind == lvh::GamepadProfileKind::xbox_one ? 0x0B20 : 0x0B13); + EXPECT_EQ(transport.version, 0x0513); + EXPECT_EQ(transport.bus_type, lvh::BusType::bluetooth); EXPECT_EQ(transport.report_id, 1); + EXPECT_EQ(transport.input_report_size, 17U); + EXPECT_EQ(transport.output_report_size, 9U); EXPECT_NE(transport.report_descriptor, requested.report_descriptor); - const std::array buttons {a, b, x, y, left_shoulder, right_shoulder, left_stick, right_stick, start, back, guide, dpad_up, dpad_down, dpad_left, dpad_right, misc1}; - for (std::size_t bit = 0; bit < buttons.size(); ++bit) { - lvh::GamepadState state; - state.buttons.set(buttons[bit]); - const auto report = lvh::detail::macos::xbox_transport_input_report(state); - ASSERT_EQ(report.size(), transport.input_report_size); - const auto flags = static_cast(report[1] | (report[2] << 8U)); - EXPECT_EQ(flags, static_cast(1U << bit)); - } + lvh::GamepadState state; + state.buttons.set(lvh::GamepadButton::a); + state.buttons.set(lvh::GamepadButton::start); + state.buttons.set(lvh::GamepadButton::dpad_left); + state.buttons.set(lvh::GamepadButton::misc1); + state.left_stick = {1.0F, -1.0F}; + const auto original = lvh::reports::pack_input_report(requested, state); + const auto report = lvh::detail::macos::xbox_bluetooth_input_report( + state, + original, + requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series + ); + ASSERT_EQ(report.size(), transport.input_report_size); + EXPECT_EQ(report[0], 1U); + EXPECT_EQ(report[14] & 0x01U, 0x01U); + EXPECT_EQ(report[15] & 0x08U, 0x08U); + EXPECT_EQ(report[16], requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ? 1U : 0U); } } From 83de3fc708ecf9a1e43ee63dfbbcd9ebabf41bc8 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:10:31 -0400 Subject: [PATCH 25/32] Share Xbox Bluetooth HID transport across Linux and macOS --- src/platform/linux/uhid_backend.cpp | 369 +----------------- src/platform/macos/macos_broker_client.cpp | 2 +- src/platform/macos/macos_xbox_transport.hpp | 44 +-- .../xbox_bluetooth_transport.hpp} | 42 +- tests/unit/test_macos_broker_protocol.cpp | 6 +- 5 files changed, 52 insertions(+), 411 deletions(-) rename src/platform/{macos/macos_xbox_bluetooth_descriptor.hpp => shared/xbox_bluetooth_transport.hpp} (84%) diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index b2e0a2cf..9f0cd19d 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -63,6 +63,7 @@ // local includes #include "core/backend.hpp" +#include "platform/shared/xbox_bluetooth_transport.hpp" #if defined(__linux__) #include "shared/playstation_feature_reports.hpp" #include "shared/switch_pro_protocol.hpp" @@ -122,328 +123,6 @@ namespace lvh::detail { constexpr std::size_t xbox_bluetooth_rumble_report_size = 9; constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04; constexpr std::size_t xbox_bluetooth_battery_report_size = 2; - - std::vector make_xbox_bluetooth_report_descriptor(bool include_battery) { - // Preserve the native Xbox BLE report layout while advertising the - // conventional Linux evdev usages for the right stick and triggers. - // HIDAPI consumes the same byte offsets directly, while the kernel maps - // these usages to ABS_RX/ABS_RY and ABS_Z/ABS_RZ for Steam. Battery-capable - // clients add the native two-byte Xbox Bluetooth battery notification as - // report 4 through the standard Battery Strength usage. - std::vector descriptor { - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x05, // Usage (Game Pad) - 0xA1, - 0x01, // Collection (Application) - 0x85, - xbox_bluetooth_input_report_id, // Report ID (1) - 0x09, - 0x01, // Usage (Pointer) - 0xA1, - 0x00, // Collection (Physical) - 0x09, - 0x30, // Usage (X) - 0x09, - 0x31, // Usage (Y) - 0x15, - 0x00, // Logical Minimum (0) - 0x27, - 0xFF, - 0xFF, - 0x00, - 0x00, // Logical Maximum (65534) - 0x95, - 0x02, // Report Count (2) - 0x75, - 0x10, // Report Size (16) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - 0xC0, // End Collection - 0x09, - 0x01, // Usage (Pointer) - 0xA1, - 0x00, // Collection (Physical) - 0x09, - 0x33, // Usage (Rx) - 0x09, - 0x34, // Usage (Ry) - 0x15, - 0x00, // Logical Minimum (0) - 0x27, - 0xFF, - 0xFF, - 0x00, - 0x00, // Logical Maximum (65534) - 0x95, - 0x02, // Report Count (2) - 0x75, - 0x10, // Report Size (16) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - 0xC0, // End Collection - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x32, // Usage (Z) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x03, // Logical Maximum (1023) - 0x95, - 0x01, // Report Count (1) - 0x75, - 0x0A, // Report Size (10) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x00, // Logical Maximum (0) - 0x75, - 0x06, // Report Size (6) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x03, // Input (Constant, Variable, Absolute) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x35, // Usage (Rz) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x03, // Logical Maximum (1023) - 0x95, - 0x01, // Report Count (1) - 0x75, - 0x0A, // Report Size (10) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x00, // Logical Maximum (0) - 0x75, - 0x06, // Report Size (6) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x03, // Input (Constant, Variable, Absolute) - 0x05, - 0x01, // Usage Page (Generic Desktop) - 0x09, - 0x39, // Usage (Hat Switch) - 0x15, - 0x01, // Logical Minimum (1) - 0x25, - 0x08, // Logical Maximum (8) - 0x35, - 0x00, // Physical Minimum (0) - 0x46, - 0x3B, - 0x01, // Physical Maximum (315) - 0x66, - 0x14, - 0x00, // Unit (Degrees) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x42, // Input (Data, Variable, Absolute, Null State) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x00, // Logical Maximum (0) - 0x35, - 0x00, // Physical Minimum (0) - 0x45, - 0x00, // Physical Maximum (0) - 0x65, - 0x00, // Unit (None) - 0x81, - 0x03, // Input (Constant, Variable, Absolute) - 0x05, - 0x09, // Usage Page (Button) - 0x19, - 0x01, // Usage Minimum (Button 1) - 0x29, - 0x0F, // Usage Maximum (Button 15) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x75, - 0x01, // Report Size (1) - 0x95, - 0x0F, // Report Count (15) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x00, // Logical Maximum (0) - 0x75, - 0x01, // Report Size (1) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x03, // Input (Constant, Variable, Absolute) - 0x05, - 0x0C, // Usage Page (Consumer) - 0x0A, - 0xB2, - 0x00, // Usage (Record) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x95, - 0x01, // Report Count (1) - 0x75, - 0x01, // Report Size (1) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x00, // Logical Maximum (0) - 0x75, - 0x07, // Report Size (7) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x03, // Input (Constant, Variable, Absolute) - 0x05, - 0x0F, // Usage Page (Physical Interface Device) - 0x09, - 0x21, // Usage (Set Effect Report) - 0x85, - xbox_bluetooth_rumble_report_id, // Report ID (3) - 0xA1, - 0x02, // Collection (Logical) - 0x09, - 0x97, // Usage (DC Enable Actuators) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x01, // Logical Maximum (1) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x91, - 0x02, // Output (Data, Variable, Absolute) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x00, // Logical Maximum (0) - 0x75, - 0x04, // Report Size (4) - 0x95, - 0x01, // Report Count (1) - 0x91, - 0x03, // Output (Constant, Variable, Absolute) - 0x09, - 0x70, // Usage (Magnitude) - 0x15, - 0x00, // Logical Minimum (0) - 0x25, - 0x64, // Logical Maximum (100) - 0x75, - 0x08, // Report Size (8) - 0x95, - 0x04, // Report Count (4) - 0x91, - 0x02, // Output (Data, Variable, Absolute) - 0x09, - 0x50, // Usage (Duration) - 0x66, - 0x01, - 0x10, // Unit (Seconds) - 0x55, - 0x0E, // Unit Exponent (-2) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - 0x01, // Report Count (1) - 0x91, - 0x02, // Output (Data, Variable, Absolute) - 0x09, - 0xA7, // Usage (Start Delay) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - 0x01, // Report Count (1) - 0x91, - 0x02, // Output (Data, Variable, Absolute) - 0x65, - 0x00, // Unit (None) - 0x55, - 0x00, // Unit Exponent (0) - 0x09, - 0x7C, // Usage (Loop Count) - 0x15, - 0x00, // Logical Minimum (0) - 0x26, - 0xFF, - 0x00, // Logical Maximum (255) - 0x75, - 0x08, // Report Size (8) - 0x95, - 0x01, // Report Count (1) - 0x91, - 0x02, // Output (Data, Variable, Absolute) - 0xC0, // End Collection - }; - - if (include_battery) { - // SDL's Linux Xbox descriptor reader requires byte-sized fields. Keep - // the native wireless/source flag in the byte while narrowing its - // logical range to the four values this backend emits, so Linux power - // consumers can scale the categorical levels without breaking HIDAPI. - constexpr std::array battery_descriptor { - 0x05, - 0x06, // Usage Page (Generic Device Controls) - 0x09, - 0x20, // Usage (Battery Strength) - 0x85, - xbox_bluetooth_battery_report_id, // Report ID (4) - 0x15, - 0x04, // Logical Minimum (wireless, empty) - 0x25, - 0x07, // Logical Maximum (wireless, full) - 0x75, - 0x08, // Report Size (8) - 0x95, - 0x01, // Report Count (1) - 0x81, - 0x02, // Input (Data, Variable, Absolute) - }; - descriptor.insert(descriptor.end(), battery_descriptor.begin(), battery_descriptor.end()); - } - - descriptor.push_back(0xC0); // End Collection - return descriptor; - } #endif int system_access(const char *path, int mode) { @@ -637,53 +316,11 @@ namespace lvh::detail { transport_profile.report_id = xbox_bluetooth_input_report_id; transport_profile.input_report_size = xbox_bluetooth_input_report_size; transport_profile.output_report_size = xbox_bluetooth_rumble_report_size; - transport_profile.report_descriptor = make_xbox_bluetooth_report_descriptor(include_battery); + transport_profile.report_descriptor = xbox_bluetooth::make_xbox_bluetooth_report_descriptor(include_battery); } return transport_profile; } - std::uint16_t read_u16_le(std::span report, std::size_t offset) { - return static_cast(report[offset]) | - static_cast(static_cast(report[offset + 1U]) << 8U); - } - - void write_u16_le(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); - } - - std::vector make_xbox_bluetooth_input_report( - const GamepadState &state, - std::span packed_report, - bool include_share_button - ) { - if (packed_report.size() < 17U) { - return {}; - } - - using enum GamepadButton; - std::vector report(xbox_bluetooth_input_report_size, 0); - report[0] = xbox_bluetooth_input_report_id; - std::copy_n(packed_report.begin(), 8U, report.begin() + 1U); - write_u16_le(report, 9U, read_u16_le(packed_report, 8U)); - write_u16_le(report, 11U, read_u16_le(packed_report, 10U)); - report[13] = packed_report[14]; - report[14] = static_cast( - (state.buttons.test(a) ? 0x01U : 0U) | (state.buttons.test(b) ? 0x02U : 0U) | - (state.buttons.test(x) ? 0x08U : 0U) | (state.buttons.test(y) ? 0x10U : 0U) | - (state.buttons.test(left_shoulder) ? 0x40U : 0U) | (state.buttons.test(right_shoulder) ? 0x80U : 0U) - ); - report[15] = static_cast( - (state.buttons.test(back) ? 0x04U : 0U) | (state.buttons.test(start) ? 0x08U : 0U) | - (state.buttons.test(guide) ? 0x10U : 0U) | (state.buttons.test(left_stick) ? 0x20U : 0U) | - (state.buttons.test(right_stick) ? 0x40U : 0U) - ); - if (include_share_button && state.buttons.test(misc1)) { - report[16] = 0x01; - } - return report; - } - std::vector make_xbox_bluetooth_battery_report(const GamepadBattery &battery) { constexpr auto wireless_battery_source = std::byte {0x04}; constexpr std::uint8_t ten_percent_battery_level = 0; @@ -3477,7 +3114,7 @@ namespace lvh::detail { ) override { std::lock_guard lock {state_mutex_}; const auto transport_report = is_xbox_uhid_profile(profile_.gamepad_kind) ? - make_xbox_bluetooth_input_report( + xbox_bluetooth::make_xbox_bluetooth_input_report( state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 08a1463c..263e5e84 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -70,7 +70,7 @@ namespace lvh::detail { if (xbox_transport_) { xbox_report = profile_.gamepad_kind == GamepadProfileKind::xbox_360 ? macos::xbox_transport_input_report(state) : - macos::xbox_bluetooth_input_report(state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series); + xbox_bluetooth::make_xbox_bluetooth_input_report(state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series); } const auto &transport_report = xbox_transport_ ? xbox_report : report; if (transport_report.empty() || transport_report.size() > macos_broker::max_report_size) { diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp index 86b1cc00..76e1c9c0 100644 --- a/src/platform/macos/macos_xbox_transport.hpp +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -4,7 +4,7 @@ */ #pragma once -#include "platform/macos/macos_xbox_bluetooth_descriptor.hpp" +#include "platform/shared/xbox_bluetooth_transport.hpp" #include #include @@ -19,12 +19,13 @@ namespace lvh::detail::macos { if (profile.vendor_id != 0x045E) { return false; } + using enum GamepadProfileKind; switch (profile.gamepad_kind) { - case GamepadProfileKind::xbox_360: + case xbox_360: return profile.product_id == 0x028E; - case GamepadProfileKind::xbox_one: + case xbox_one: return profile.product_id == 0x02EA; - case GamepadProfileKind::xbox_series: + case xbox_series: return profile.product_id == 0x0B12; default: return false; @@ -41,10 +42,10 @@ namespace lvh::detail::macos { transport.bus_type = BusType::bluetooth; transport.product_id = requested.gamepad_kind == GamepadProfileKind::xbox_series ? 0x0B13 : 0x0B20; transport.version = 0x0513; - transport.report_id = xbox_bluetooth_input_report_id; + transport.report_id = xbox_bluetooth::xbox_bluetooth_input_report_id; transport.input_report_size = 17; transport.output_report_size = 9; - transport.report_descriptor = make_xbox_bluetooth_report_descriptor(true); + transport.report_descriptor = xbox_bluetooth::make_xbox_bluetooth_report_descriptor(true); return transport; } @@ -147,35 +148,4 @@ namespace lvh::detail::macos { return report; } - inline std::vector xbox_bluetooth_input_report( - const GamepadState &state, - std::span packed_report, - bool include_share_button - ) { - if (packed_report.size() < 17U) { - return {}; - } - - using enum GamepadButton; - std::vector report(17U, 0); - report[0] = xbox_bluetooth_input_report_id; - std::copy_n(packed_report.begin(), 8U, report.begin() + 1U); - std::copy_n(packed_report.begin() + 8U, 4U, report.begin() + 9U); - report[13] = packed_report[14]; - report[14] = static_cast( - (state.buttons.test(a) ? 0x01U : 0U) | (state.buttons.test(b) ? 0x02U : 0U) | - (state.buttons.test(x) ? 0x08U : 0U) | (state.buttons.test(y) ? 0x10U : 0U) | - (state.buttons.test(left_shoulder) ? 0x40U : 0U) | (state.buttons.test(right_shoulder) ? 0x80U : 0U) - ); - report[15] = static_cast( - (state.buttons.test(back) ? 0x04U : 0U) | (state.buttons.test(start) ? 0x08U : 0U) | - (state.buttons.test(guide) ? 0x10U : 0U) | (state.buttons.test(left_stick) ? 0x20U : 0U) | - (state.buttons.test(right_stick) ? 0x40U : 0U) - ); - if (include_share_button && state.buttons.test(misc1)) { - report[16] = 0x01; - } - return report; - } - } // namespace lvh::detail::macos diff --git a/src/platform/macos/macos_xbox_bluetooth_descriptor.hpp b/src/platform/shared/xbox_bluetooth_transport.hpp similarity index 84% rename from src/platform/macos/macos_xbox_bluetooth_descriptor.hpp rename to src/platform/shared/xbox_bluetooth_transport.hpp index d4d4ceef..bd63c07d 100644 --- a/src/platform/macos/macos_xbox_bluetooth_descriptor.hpp +++ b/src/platform/shared/xbox_bluetooth_transport.hpp @@ -1,14 +1,17 @@ /** - * @file src/platform/macos/macos_xbox_bluetooth_descriptor.hpp - * @brief Xbox Bluetooth HID descriptor used by the macOS broker transport. + * @file src/platform/shared/xbox_bluetooth_transport.hpp + * @brief Xbox Bluetooth HID descriptor and input report shared by Linux and macOS. */ #pragma once +#include #include #include +#include +#include #include -namespace lvh::detail::macos { +namespace lvh::detail::xbox_bluetooth { inline constexpr std::uint8_t xbox_bluetooth_input_report_id = 0x01; inline constexpr std::uint8_t xbox_bluetooth_rumble_report_id = 0x03; inline constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04; @@ -329,4 +332,35 @@ namespace lvh::detail::macos { return descriptor; } -} // namespace lvh::detail::macos + inline std::vector make_xbox_bluetooth_input_report( + const GamepadState &state, + std::span packed_report, + bool include_share_button + ) { + if (packed_report.size() < 17U) { + return {}; + } + + using enum GamepadButton; + std::vector report(17U, 0); + report[0] = xbox_bluetooth_input_report_id; + std::copy_n(packed_report.begin(), 8U, report.begin() + 1U); + std::copy_n(packed_report.begin() + 8U, 4U, report.begin() + 9U); + report[13] = packed_report[14]; + report[14] = static_cast( + (state.buttons.test(a) ? 0x01U : 0U) | (state.buttons.test(b) ? 0x02U : 0U) | + (state.buttons.test(x) ? 0x08U : 0U) | (state.buttons.test(y) ? 0x10U : 0U) | + (state.buttons.test(left_shoulder) ? 0x40U : 0U) | (state.buttons.test(right_shoulder) ? 0x80U : 0U) + ); + report[15] = static_cast( + (state.buttons.test(back) ? 0x04U : 0U) | (state.buttons.test(start) ? 0x08U : 0U) | + (state.buttons.test(guide) ? 0x10U : 0U) | (state.buttons.test(left_stick) ? 0x20U : 0U) | + (state.buttons.test(right_stick) ? 0x40U : 0U) + ); + if (include_share_button && state.buttons.test(misc1)) { + report[16] = 0x01; + } + return report; + } + +} // namespace lvh::detail::xbox_bluetooth diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 2a066a58..2309028d 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -102,15 +102,15 @@ TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctBluetoothIdentities) { state.buttons.set(lvh::GamepadButton::misc1); state.left_stick = {1.0F, -1.0F}; const auto original = lvh::reports::pack_input_report(requested, state); - const auto report = lvh::detail::macos::xbox_bluetooth_input_report( + const auto report = lvh::detail::xbox_bluetooth::make_xbox_bluetooth_input_report( state, original, requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ); ASSERT_EQ(report.size(), transport.input_report_size); EXPECT_EQ(report[0], 1U); - EXPECT_EQ(report[14] & 0x01U, 0x01U); - EXPECT_EQ(report[15] & 0x08U, 0x08U); + EXPECT_EQ(report[14], 0x01U); + EXPECT_EQ(report[15], 0x08U); EXPECT_EQ(report[16], requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ? 1U : 0U); } } From 26935c74776a21ca3eeea161e7680408caf8cbd6 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:28:39 -0400 Subject: [PATCH 26/32] Fix macOS Xbox One and Series input for virtual transport Send GIP-framed input for the Virtual HID transport selected by Steam while preserving distinct Xbox identities. Cover button and axis bytes, and repair Linux build errors from the shared transport refactor. --- docs/macos-gamepad.md | 8 +- src/platform/linux/uhid_backend.cpp | 1 - src/platform/macos/macos_broker_client.cpp | 2 +- src/platform/macos/macos_xbox_transport.hpp | 85 +++++++++++++++++++-- tests/fixtures/linux_backend_test_hooks.cpp | 3 +- tests/unit/test_macos_broker_protocol.cpp | 68 ++++++++++++++--- 6 files changed, 144 insertions(+), 23 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 4cfe0026..5a6dfe02 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -16,10 +16,10 @@ and Switch Pro profiles, including the explicit USB and Bluetooth PlayStation variants, are accepted. The macOS broker receives the selected transport's HID descriptor and reports. Xbox 360 uses a USB HID identity (`045e:028e`, version `0114`) with numbered D-pad buttons that match Steam's macOS mapping. Xbox One -and Xbox Series use their respective Xbox Bluetooth HID identities -(`045e:0b20` and `045e:0b13`); the macOS backend translates their public GIP -input reports into the corresponding Bluetooth report layout. The Windows -XUSB/XInput personality is Windows-specific. Individual games may use Apple's +and Xbox Series use distinct HID identities (`045e:0b20` and `045e:0b13`). +macOS exposes broker-created devices as Virtual transport, so the backend +frames their input as wired Xbox GIP packets for Steam's Xbox HID decoder. The +Windows XUSB/XInput personality is Windows-specific. Individual games may use Apple's Game Controller framework or their own HID mappings, so a signed installed build still needs consumer testing for each profile. When metadata omits a stable ID, the client derives a locally administered diff --git a/src/platform/linux/uhid_backend.cpp b/src/platform/linux/uhid_backend.cpp index 9f0cd19d..699ed721 100644 --- a/src/platform/linux/uhid_backend.cpp +++ b/src/platform/linux/uhid_backend.cpp @@ -119,7 +119,6 @@ namespace lvh::detail { constexpr std::uint16_t xbox_bluetooth_version = 0x0513; constexpr std::uint8_t xbox_bluetooth_input_report_id = 0x01; constexpr std::size_t xbox_bluetooth_input_report_size = 17; - constexpr std::uint8_t xbox_bluetooth_rumble_report_id = 0x03; constexpr std::size_t xbox_bluetooth_rumble_report_size = 9; constexpr std::uint8_t xbox_bluetooth_battery_report_id = 0x04; constexpr std::size_t xbox_bluetooth_battery_report_size = 2; diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index 263e5e84..a36747f7 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -70,7 +70,7 @@ namespace lvh::detail { if (xbox_transport_) { xbox_report = profile_.gamepad_kind == GamepadProfileKind::xbox_360 ? macos::xbox_transport_input_report(state) : - xbox_bluetooth::make_xbox_bluetooth_input_report(state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series); + macos::xbox_gip_transport_input_report(state, report, profile_.gamepad_kind == GamepadProfileKind::xbox_series); } const auto &transport_report = xbox_transport_ ? xbox_report : report; if (transport_report.empty() || transport_report.size() > macos_broker::max_report_size) { diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp index 76e1c9c0..a1a1dc85 100644 --- a/src/platform/macos/macos_xbox_transport.hpp +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -4,8 +4,6 @@ */ #pragma once -#include "platform/shared/xbox_bluetooth_transport.hpp" - #include #include #include @@ -15,6 +13,83 @@ namespace lvh::detail::macos { + inline constexpr std::uint8_t xbox_gip_input_command = 0x20; + + inline std::vector xbox_gip_report_descriptor() { + // IOHIDUserDevice exposes its transport as Virtual even when Bluetooth is + // requested. Steam therefore selects its wired GIP decoder for these IDs. + return { + 0x05, + 0x01, // Usage Page (Generic Desktop) + 0x09, + 0x05, // Usage (Game Pad) + 0xA1, + 0x01, // Collection (Application) + 0x06, + 0x00, + 0xFF, // Usage Page (Vendor Defined) + 0x09, + 0x01, // Usage (Vendor Defined 1) + 0x15, + 0x00, // Logical Minimum (0) + 0x26, + 0xFF, + 0x00, // Logical Maximum (255) + 0x75, + 0x08, // Report Size (8) + 0x85, + xbox_gip_input_command, // Report ID (GIP input command) + 0x95, + 0x18, // 24 bytes after the report ID + 0x81, + 0x02, // Input (Data, Variable, Absolute) + 0x85, + 0x03, // Bluetooth-compatible rumble output + 0x95, + 0x08, + 0x91, + 0x02, // Output (Data, Variable, Absolute) + 0x85, + 0x09, // Wired GIP rumble output + 0x95, + 0x0C, + 0x91, + 0x02, + 0xC0, + }; + } + + inline std::vector xbox_gip_transport_input_report( + const GamepadState &state, + std::span packed, + bool series + ) { + if (packed.size() < 12U) { + return {}; + } + + using enum GamepadButton; + const auto pressed = [&state](GamepadButton button, unsigned int bit) { + return state.buttons.test(button) ? (1U << bit) : 0U; + }; + std::vector report(25U); + report[0] = xbox_gip_input_command; + report[3] = 0x10; // Sixteen-byte GIP state payload. + report[4] = static_cast(pressed(start, 2) | pressed(back, 3) | pressed(a, 4) | pressed(b, 5) | pressed(x, 6) | pressed(y, 7)); + report[5] = static_cast(pressed(dpad_up, 0) | pressed(dpad_down, 1) | pressed(dpad_left, 2) | pressed(dpad_right, 3) | pressed(left_shoulder, 4) | pressed(right_shoulder, 5) | pressed(left_stick, 6) | pressed(right_stick, 7)); + std::copy_n(packed.begin() + 8, 4, report.begin() + 6); // Triggers. + std::copy_n(packed.begin(), 8, report.begin() + 10); // Sticks. + for (const auto index : {11U, 13U, 15U, 17U}) { + report[index] ^= 0x80U; // Unsigned public axes to signed GIP axes. + } + report[18] = series && state.buttons.test(misc1) ? 0x01 : 0x00; + report[20] = 0x07; // GIP virtual-key command for Guide. + report[21] = 0x20; // Internal command. + report[23] = 0x01; + report[24] = state.buttons.test(guide) ? 0x01 : 0x00; + return report; + } + inline bool uses_xbox_transport(const DeviceProfile &profile) { if (profile.vendor_id != 0x045E) { return false; @@ -42,10 +117,10 @@ namespace lvh::detail::macos { transport.bus_type = BusType::bluetooth; transport.product_id = requested.gamepad_kind == GamepadProfileKind::xbox_series ? 0x0B13 : 0x0B20; transport.version = 0x0513; - transport.report_id = xbox_bluetooth::xbox_bluetooth_input_report_id; - transport.input_report_size = 17; + transport.report_id = xbox_gip_input_command; + transport.input_report_size = 25; transport.output_report_size = 9; - transport.report_descriptor = xbox_bluetooth::make_xbox_bluetooth_report_descriptor(true); + transport.report_descriptor = xbox_gip_report_descriptor(); return transport; } diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index 0111bb27..a4282654 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1770,8 +1770,7 @@ namespace lvh::detail::test { result.xbox.saw_input = input.size() == xbox_bluetooth_input_report_size && input[0] == xbox_bluetooth_input_report_id && std::equal(report.begin(), report.begin() + 8, input.begin() + 1) && - read_u16_le(input, 9U) == read_u16_le(report, 8U) && - read_u16_le(input, 11U) == read_u16_le(report, 10U) && input[13] == report[14] && + std::equal(report.begin() + 8, report.begin() + 12, input.begin() + 9) && input[13] == report[14] && input[14] == 0x41U && input[15] == 0x38U; result.xbox.saw_guide = input.size() == xbox_bluetooth_input_report_size && (input[15] & 0x10U) != 0U; result.xbox.saw_profile_consumer_button = diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 2309028d..f103e4e9 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -7,6 +7,7 @@ #include "platform/macos/macos_xbox_transport.hpp" #include +#include #include #include #include @@ -77,12 +78,13 @@ TEST(MacosBrokerProtocolTest, XboxTransportUsesSteamMacOSIdentityAndButtonLayout state.buttons.set(buttons[bit]); const auto report = lvh::detail::macos::xbox_transport_input_report(state); ASSERT_EQ(report.size(), transport.input_report_size); - const auto flags = static_cast(report[1] | (report[2] << 8U)); - EXPECT_EQ(flags, static_cast(1U << bit)); + const auto flags = std::bitset<16> {report[1] + 256U * report[2]}; + EXPECT_TRUE(flags.test(bit)); + EXPECT_EQ(flags.count(), 1U); } } -TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctBluetoothIdentities) { +TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctIdentitiesAndGipInput) { for (const auto &requested : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { SCOPED_TRACE(requested.name); const auto transport = lvh::detail::macos::xbox_transport_profile(requested); @@ -90,8 +92,8 @@ TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctBluetoothIdentities) { EXPECT_EQ(transport.product_id, requested.gamepad_kind == lvh::GamepadProfileKind::xbox_one ? 0x0B20 : 0x0B13); EXPECT_EQ(transport.version, 0x0513); EXPECT_EQ(transport.bus_type, lvh::BusType::bluetooth); - EXPECT_EQ(transport.report_id, 1); - EXPECT_EQ(transport.input_report_size, 17U); + EXPECT_EQ(transport.report_id, lvh::detail::macos::xbox_gip_input_command); + EXPECT_EQ(transport.input_report_size, 25U); EXPECT_EQ(transport.output_report_size, 9U); EXPECT_NE(transport.report_descriptor, requested.report_descriptor); @@ -102,16 +104,62 @@ TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctBluetoothIdentities) { state.buttons.set(lvh::GamepadButton::misc1); state.left_stick = {1.0F, -1.0F}; const auto original = lvh::reports::pack_input_report(requested, state); - const auto report = lvh::detail::xbox_bluetooth::make_xbox_bluetooth_input_report( + const auto report = lvh::detail::macos::xbox_gip_transport_input_report( state, original, requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ); ASSERT_EQ(report.size(), transport.input_report_size); - EXPECT_EQ(report[0], 1U); - EXPECT_EQ(report[14], 0x01U); - EXPECT_EQ(report[15], 0x08U); - EXPECT_EQ(report[16], requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ? 1U : 0U); + EXPECT_EQ(report[0], 0x20U); + EXPECT_EQ(report[3], 16U); + EXPECT_EQ(report[4], 0x14U); // Start and A. + EXPECT_EQ(report[5], 0x04U); // D-pad left. + EXPECT_EQ(report[10], 0xFFU); // Left X fully right. + EXPECT_EQ(report[11], 0x7FU); + EXPECT_EQ(report[12], 0xFFU); // Left Y fully up before consumer inversion. + EXPECT_EQ(report[13], 0x7FU); + EXPECT_EQ(report[18], requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ? 1U : 0U); + EXPECT_EQ(report[20], 0x07U); // Separate Guide packet. + EXPECT_EQ(report[21], 0x20U); + EXPECT_EQ(report[23], 1U); + EXPECT_EQ(report[24], 0U); + } +} + +TEST(MacosBrokerProtocolTest, XboxGipTransportMapsEveryButtonAndGuide) { + using enum lvh::GamepadButton; + + struct ButtonCase { + lvh::GamepadButton button; + std::size_t index; + std::uint8_t mask; + }; + + const std::array buttons { + ButtonCase {start, 4, 0x04}, + ButtonCase {back, 4, 0x08}, + ButtonCase {a, 4, 0x10}, + ButtonCase {b, 4, 0x20}, + ButtonCase {x, 4, 0x40}, + ButtonCase {y, 4, 0x80}, + ButtonCase {dpad_up, 5, 0x01}, + ButtonCase {dpad_down, 5, 0x02}, + ButtonCase {dpad_left, 5, 0x04}, + ButtonCase {dpad_right, 5, 0x08}, + ButtonCase {left_shoulder, 5, 0x10}, + ButtonCase {right_shoulder, 5, 0x20}, + ButtonCase {left_stick, 5, 0x40}, + ButtonCase {right_stick, 5, 0x80}, + ButtonCase {misc1, 18, 0x01}, + ButtonCase {guide, 24, 0x01}, + }; + for (const auto &item : buttons) { + lvh::GamepadState state; + state.buttons.set(item.button); + const auto packed = lvh::reports::pack_input_report(lvh::profiles::xbox_series(), state); + const auto report = lvh::detail::macos::xbox_gip_transport_input_report(state, packed, true); + ASSERT_EQ(report.size(), 25U); + EXPECT_EQ(report[item.index], item.mask); } } From 30c9c65b8e2ddee0b10c0a188dc639f3b9b04db2 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:35:44 -0400 Subject: [PATCH 27/32] Decode wired Xbox rumble output on macOS Map GIP rumble feedback to the shared four-motor decoder and preserve the original wire report. Use std::byte for the macOS axis sign conversion. --- docs/macos-gamepad.md | 1 + src/platform/macos/macos_broker_client.cpp | 2 +- src/platform/macos/macos_xbox_transport.hpp | 24 +++++++++++++- tests/unit/test_macos_broker_protocol.cpp | 36 +++++++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 5a6dfe02..1cd64527 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -19,6 +19,7 @@ descriptor and reports. Xbox 360 uses a USB HID identity (`045e:028e`, version and Xbox Series use distinct HID identities (`045e:0b20` and `045e:0b13`). macOS exposes broker-created devices as Virtual transport, so the backend frames their input as wired Xbox GIP packets for Steam's Xbox HID decoder. The +backend accepts both wired GIP and report-ID-3 rumble output for these profiles. Windows XUSB/XInput personality is Windows-specific. Individual games may use Apple's Game Controller framework or their own HID mappings, so a signed installed build still needs consumer testing for each profile. diff --git a/src/platform/macos/macos_broker_client.cpp b/src/platform/macos/macos_broker_client.cpp index a36747f7..36e2dd6d 100644 --- a/src/platform/macos/macos_broker_client.cpp +++ b/src/platform/macos/macos_broker_client.cpp @@ -146,7 +146,7 @@ namespace lvh::detail { callback = state->callback; } if (callback) { - for (const auto &output : reports::parse_output_reports(profile, report)) { + for (const auto &output : macos::xbox_transport_output_reports(profile, report)) { callback(output); } } diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp index a1a1dc85..6e3a4724 100644 --- a/src/platform/macos/macos_xbox_transport.hpp +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include @@ -80,7 +81,7 @@ namespace lvh::detail::macos { std::copy_n(packed.begin() + 8, 4, report.begin() + 6); // Triggers. std::copy_n(packed.begin(), 8, report.begin() + 10); // Sticks. for (const auto index : {11U, 13U, 15U, 17U}) { - report[index] ^= 0x80U; // Unsigned public axes to signed GIP axes. + report[index] = std::to_integer(std::byte {report[index]} ^ std::byte {0x80}); } report[18] = series && state.buttons.test(misc1) ? 0x01 : 0x00; report[20] = 0x07; // GIP virtual-key command for Guide. @@ -90,6 +91,27 @@ namespace lvh::detail::macos { return report; } + inline std::vector xbox_transport_output_reports( + const DeviceProfile &profile, + const std::vector &report + ) { + if ( + (profile.gamepad_kind == GamepadProfileKind::xbox_one || profile.gamepad_kind == GamepadProfileKind::xbox_series) && + report.size() >= 13U && report[0] == 0x09U && report[3] == 0x09U + ) { + // The wired GIP rumble payload has a leading reserved byte. Reuse the + // shared four-motor decoder with its equivalent report-ID-3 layout. + std::vector normalized {0x03}; + normalized.insert(normalized.end(), report.begin() + 5, report.begin() + 13); + auto outputs = reports::parse_output_reports(profile, normalized); + for (auto &output : outputs) { + output.raw_report = report; + } + return outputs; + } + return reports::parse_output_reports(profile, report); + } + inline bool uses_xbox_transport(const DeviceProfile &profile) { if (profile.vendor_id != 0x045E) { return false; diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index f103e4e9..991f9dc4 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -14,6 +14,7 @@ #include #include #include +#include TEST(MacosBrokerProtocolTest, BuiltInProfilesFitBrokerTransport) { auto profiles = lvh::profiles::built_in_gamepad_profiles(); @@ -163,6 +164,41 @@ TEST(MacosBrokerProtocolTest, XboxGipTransportMapsEveryButtonAndGuide) { } } +TEST(MacosBrokerProtocolTest, XboxGipRumbleUsesFourMotorOutputDecoder) { + const auto profile = lvh::detail::macos::xbox_transport_profile(lvh::profiles::xbox_series()); + const std::vector wired { + 0x09, + 0x00, + 0x00, + 0x09, + 0x00, + 0x0F, + 25, + 50, + 75, + 100, + 0xFF, + 0x00, + 0xEB, + }; + const auto outputs = lvh::detail::macos::xbox_transport_output_reports(profile, wired); + ASSERT_EQ(outputs.size(), 2U); + EXPECT_EQ(outputs[0].kind, lvh::GamepadOutputKind::rumble); + EXPECT_GT(outputs[0].low_frequency_rumble, 0U); + EXPECT_GT(outputs[0].high_frequency_rumble, outputs[0].low_frequency_rumble); + EXPECT_EQ(outputs[1].kind, lvh::GamepadOutputKind::trigger_rumble); + EXPECT_GT(outputs[1].left_trigger_rumble, 0U); + EXPECT_GT(outputs[1].right_trigger_rumble, outputs[1].left_trigger_rumble); + EXPECT_EQ(outputs[0].raw_report, wired); + EXPECT_EQ(outputs[1].raw_report, wired); + + const std::vector bluetooth {0x03, 0x0F, 25, 50, 75, 100, 0xFF, 0x00, 0xEB}; + const auto wireless_outputs = lvh::detail::macos::xbox_transport_output_reports(profile, bluetooth); + ASSERT_EQ(wireless_outputs.size(), 2U); + EXPECT_EQ(wireless_outputs[0].low_frequency_rumble, outputs[0].low_frequency_rumble); + EXPECT_EQ(wireless_outputs[1].left_trigger_rumble, outputs[1].left_trigger_rumble); +} + TEST(MacosBrokerProtocolTest, XboxTransportKeepsAxesAndSharedReportsUntouched) { lvh::GamepadState state; state.left_stick = {1.0F, -1.0F}; From 47a544aa46b3cd81f3581e6455eb0b00667d4dee Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:37:40 -0400 Subject: [PATCH 28/32] Match macOS Xbox stick Y direction to other profiles Reverse only the macOS Xbox transport vertical bytes and cover both sticks for Xbox 360, One, and Series. --- docs/macos-gamepad.md | 2 ++ src/platform/macos/macos_xbox_transport.hpp | 5 +++ tests/unit/test_macos_broker_protocol.cpp | 37 ++++++++++++++++++--- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 1cd64527..ca66cbfb 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -20,6 +20,8 @@ and Xbox Series use distinct HID identities (`045e:0b20` and `045e:0b13`). macOS exposes broker-created devices as Virtual transport, so the backend frames their input as wired Xbox GIP packets for Steam's Xbox HID decoder. The backend accepts both wired GIP and report-ID-3 rumble output for these profiles. +The macOS Xbox reports reverse the vertical stick bytes so positive public Y +moves the sticks up in Steam, as with the PlayStation and Switch profiles. Windows XUSB/XInput personality is Windows-specific. Individual games may use Apple's Game Controller framework or their own HID mappings, so a signed installed build still needs consumer testing for each profile. diff --git a/src/platform/macos/macos_xbox_transport.hpp b/src/platform/macos/macos_xbox_transport.hpp index 6e3a4724..bd78e2eb 100644 --- a/src/platform/macos/macos_xbox_transport.hpp +++ b/src/platform/macos/macos_xbox_transport.hpp @@ -80,6 +80,9 @@ namespace lvh::detail::macos { report[5] = static_cast(pressed(dpad_up, 0) | pressed(dpad_down, 1) | pressed(dpad_left, 2) | pressed(dpad_right, 3) | pressed(left_shoulder, 4) | pressed(right_shoulder, 5) | pressed(left_stick, 6) | pressed(right_stick, 7)); std::copy_n(packed.begin() + 8, 4, report.begin() + 6); // Triggers. std::copy_n(packed.begin(), 8, report.begin() + 10); // Sticks. + for (const auto index : {12U, 13U, 16U, 17U}) { + report[index] = static_cast(0xFFU - report[index]); + } for (const auto index : {11U, 13U, 15U, 17U}) { report[index] = std::to_integer(std::byte {report[index]} ^ std::byte {0x80}); } @@ -242,6 +245,8 @@ namespace lvh::detail::macos { button(misc1, 15); report[1] = static_cast(bits & 0xFFU); report[2] = static_cast((bits >> 8U) & 0xFFU); + report[4] = static_cast(0xFFU - report[4]); + report[7] = static_cast(0xFFU - report[7]); return report; } diff --git a/tests/unit/test_macos_broker_protocol.cpp b/tests/unit/test_macos_broker_protocol.cpp index 991f9dc4..744ba975 100644 --- a/tests/unit/test_macos_broker_protocol.cpp +++ b/tests/unit/test_macos_broker_protocol.cpp @@ -117,8 +117,8 @@ TEST(MacosBrokerProtocolTest, XboxOneAndSeriesUseDistinctIdentitiesAndGipInput) EXPECT_EQ(report[5], 0x04U); // D-pad left. EXPECT_EQ(report[10], 0xFFU); // Left X fully right. EXPECT_EQ(report[11], 0x7FU); - EXPECT_EQ(report[12], 0xFFU); // Left Y fully up before consumer inversion. - EXPECT_EQ(report[13], 0x7FU); + EXPECT_EQ(report[12], 0x00U); // Left Y is inverted for Steam's GIP decoder. + EXPECT_EQ(report[13], 0x80U); EXPECT_EQ(report[18], requested.gamepad_kind == lvh::GamepadProfileKind::xbox_series ? 1U : 0U); EXPECT_EQ(report[20], 0x07U); // Separate Guide packet. EXPECT_EQ(report[21], 0x20U); @@ -199,7 +199,7 @@ TEST(MacosBrokerProtocolTest, XboxGipRumbleUsesFourMotorOutputDecoder) { EXPECT_EQ(wireless_outputs[1].left_trigger_rumble, outputs[1].left_trigger_rumble); } -TEST(MacosBrokerProtocolTest, XboxTransportKeepsAxesAndSharedReportsUntouched) { +TEST(MacosBrokerProtocolTest, Xbox360TransportChangesOnlyButtonsAndVerticalAxes) { lvh::GamepadState state; state.left_stick = {1.0F, -1.0F}; state.right_stick = {-1.0F, 1.0F}; @@ -213,7 +213,36 @@ TEST(MacosBrokerProtocolTest, XboxTransportKeepsAxesAndSharedReportsUntouched) { EXPECT_EQ(shared[1], 0x40U); EXPECT_EQ(transport[2], 0x02U); for (std::size_t index = 3; index < shared.size(); ++index) { - EXPECT_EQ(transport[index], shared[index]); + if (index == 4U || index == 7U) { + EXPECT_EQ(transport[index], 0xFFU - shared[index]); + } else { + EXPECT_EQ(transport[index], shared[index]); + } + } +} + +TEST(MacosBrokerProtocolTest, XboxVerticalSticksUsePositiveUpOnMacOS) { + lvh::GamepadState state; + state.left_stick.y = 1.0F; + state.right_stick.y = -1.0F; + + const auto xbox_360 = lvh::detail::macos::xbox_transport_input_report(state); + ASSERT_EQ(xbox_360.size(), 9U); + EXPECT_EQ(xbox_360[4], 0xFFU); + EXPECT_EQ(xbox_360[7], 0x00U); + + for (const auto &profile : {lvh::profiles::xbox_one(), lvh::profiles::xbox_series()}) { + const auto packed = lvh::reports::pack_input_report(profile, state); + const auto report = lvh::detail::macos::xbox_gip_transport_input_report( + state, + packed, + profile.gamepad_kind == lvh::GamepadProfileKind::xbox_series + ); + ASSERT_EQ(report.size(), 25U); + EXPECT_EQ(report[12], 0xFFU); + EXPECT_EQ(report[13], 0x7FU); + EXPECT_EQ(report[16], 0x00U); + EXPECT_EQ(report[17], 0x80U); } } From 7f1d66be2aeea7d377f33125389ef6dab206ab7f Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:41:58 -0400 Subject: [PATCH 29/32] Fix Linux Xbox rumble test constant after transport refactor --- tests/fixtures/linux_backend_test_hooks.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/fixtures/linux_backend_test_hooks.cpp b/tests/fixtures/linux_backend_test_hooks.cpp index a4282654..49b37daa 100644 --- a/tests/fixtures/linux_backend_test_hooks.cpp +++ b/tests/fixtures/linux_backend_test_hooks.cpp @@ -1787,7 +1787,7 @@ namespace lvh::detail::test { } constexpr std::array motor_report { - xbox_bluetooth_rumble_report_id, + xbox_bluetooth::xbox_bluetooth_rumble_report_id, 0x0F, 25, 50, From 218d11a93cf8eb9824418226fd2998f32dde3d42 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Fri, 25 Sep 2026 21:17:47 -0400 Subject: [PATCH 30/32] Document verified macOS Xbox controller behavior --- docs/macos-gamepad.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index ca66cbfb..5ffb3f0d 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -22,9 +22,12 @@ frames their input as wired Xbox GIP packets for Steam's Xbox HID decoder. The backend accepts both wired GIP and report-ID-3 rumble output for these profiles. The macOS Xbox reports reverse the vertical stick bytes so positive public Y moves the sticks up in Steam, as with the PlayStation and Switch profiles. -Windows XUSB/XInput personality is Windows-specific. Individual games may use Apple's -Game Controller framework or their own HID mappings, so a signed installed -build still needs consumer testing for each profile. +Xbox 360, Xbox One, and Xbox Series input, plus Xbox One and Series rumble, +were validated with Steam's controller tester on an installed, notarized build. +Windows uses a separate XUSB/XInput personality. Individual games may use +Apple's Game Controller framework or their own HID mappings, so each profile +still needs testing in the intended consumer. + When metadata omits a stable ID, the client derives a locally administered `02:00:xx:xx:xx:xx` identifier from the device ID. From d3d77d449ebcede04da3b0496eec29f270a0f977 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:53:17 -0400 Subject: [PATCH 31/32] Update end-user-gamepad-guide.md --- docs/end-user-gamepad-guide.md | 123 +++++++++++++++++---------------- 1 file changed, 64 insertions(+), 59 deletions(-) diff --git a/docs/end-user-gamepad-guide.md b/docs/end-user-gamepad-guide.md index 99f74cfa..a4b9d828 100644 --- a/docs/end-user-gamepad-guide.md +++ b/docs/end-user-gamepad-guide.md @@ -56,8 +56,8 @@ that Moonlight client. ## Configure Sunshine -Use the current Sunshine and Virtual HID Driver versions recommended by the -Sunshine release you installed. On Windows, the Virtual HID Driver must be +Use the current Sunshine and Virtual HID Broker versions recommended by the +Sunshine release you installed. On Windows, the Virtual HID Broker must be installed and have a valid machine license before Sunshine can create a driver-backed controller. After installing or updating the driver, restart Windows. @@ -104,57 +104,60 @@ in a dev build or future release of the client. The backend columns summarize host-backend capability across the recorded tests. The client columns record client-specific results; footnotes identify platform, version, connection, or host-backend qualifiers. - -| Feature | Backend: Windows via Virtual HID Driver | Backend: Linux via `libvirtualhid` | Client: [Moonlight Qt](https://github.com/moonlight-stream/moonlight-qt) | Client: [Moonlight Android](https://github.com/moonlight-stream/moonlight-android) | Client: [Moonlight iOS](https://github.com/moonlight-stream/moonlight-ios) | Client: [Moonlight Xbox](https://github.com/TheElixZammuto/moonlight-xbox) | -|-------------------------------------|------------------------------------------------------|------------------------------------------------------|--------------------------------------------------------------------------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------| -| **Xbox 360** | | | | | | | -| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Xbox One** | | | | | | | -| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Impulse Triggers | ✅ | ✅ | ✅ | ✅1 | ✅2 | ✅ | -| Battery state | ❌8 | ✅3 | ❌9 | ❌9 | ❌10 | 🟡20 | -| **Xbox Series** | | | | | | | -| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | -| Impulse Triggers | ✅ | ✅ | ✅ | ✅1 | ✅2 | ✅ | -| Battery state | ❌8 | ✅3 | ❌9 | ❌9 | ❌10 | 🟡20 | -| Share button | ❌11 | ✅ | 🟡21 | ✅ | ✅ | 🟡22 | -| **DualShock 4** | | | | | | | -| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Basic rumble | ✅4 | ✅4 | ✅ | ✅5 | ✅ | ➖ | -| Motion/gyro | ✅ | ✅ | ✅ | ✅6 | ✅2 | ➖ | -| Touchpad position | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | -| Touchpad click | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | -| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ | -| Battery state | ❌8 | ✅ | ✅ | ✅ | ✅ | ➖ | -| **DualSense** | | | | | | | -| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Basic rumble | ✅4 | ✅4 | ✅ | ✅5 | ✅ | ➖ | -| Motion/gyro | ✅ | ✅ | ✅ | ✅6 | ❌ | ➖ | -| Touchpad position | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | -| Touchpad click | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | -| Light bar (RGB) | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ | -| Battery state | ❌8 | ✅ | ✅ | ✅ | ✅ | ➖ | -| Adaptive triggers | ❓24 | ✅ | 🟡23 | ❌12 | ❓ | ➖ | -| Player indicator | ❌13 | ❌13 | ❌ | ❌ | ❌ | ➖ | -| MUTE button | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ | -| MUTE button LED | ❌13 | ❌13 | ❌ | ❌14 | ❌ | ➖ | -| **Nintendo Switch Pro Controller** | | | | | | | -| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Basic rumble | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ | -| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | -| Battery state | ❌8 | ✅ | ✅ | ✅ | ❌ | ➖ | -| HOME LED | ❌15 | ❌15 | ❌16 | ❌ | ❌ | ➖ | -| Player LED | ❌17 | ❌17 | ❌18 | ❌18 | ❌18 | ➖ | -| Capture button | ✅ | ✅ | ✅ | ❌19 | ✅2 | ➖ | +The macOS results include a Sunshine stream from Moonlight Qt on Windows, +checked in Steam's controller tester on macOS. A direct Virtual HID Control +test is noted separately where it helps isolate the host backend. + +| Feature | Backend: Windows via Virtual HID Broker | Backend: Linux via `libvirtualhid` | Backend: macOS via Virtual HID Broker | Client: [Moonlight Qt](https://github.com/moonlight-stream/moonlight-qt) | Client: [Moonlight Android](https://github.com/moonlight-stream/moonlight-android) | Client: [Moonlight iOS](https://github.com/moonlight-stream/moonlight-ios) | Client: [Moonlight Xbox](https://github.com/TheElixZammuto/moonlight-xbox) | +|-------------------------------------|------------------------------------------------------|------------------------------------------------------|------------------------------------------------------|--------------------------------------------------------------------------|------------------------------------------------------------------------------------|----------------------------------------------------------------------------|----------------------------------------------------------------------------| +| **Xbox 360** | | | | | | | | +| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Basic rumble | ✅ | ✅ | ❓25 | ✅ | ✅ | ✅ | ✅ | +| **Xbox One** | | | | | | | | +| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Impulse Triggers | ✅ | ✅ | ❓ | ✅ | ✅1 | ✅2 | ✅ | +| Battery state | ❌8 | ✅3 | ❌26 | ❌9 | ❌9 | ❌10 | 🟡20 | +| **Xbox Series** | | | | | | | | +| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Basic rumble | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Impulse Triggers | ✅ | ✅ | ❓ | ✅ | ✅1 | ✅2 | ✅ | +| Battery state | ❌8 | ✅3 | ❌26 | ❌9 | ❌9 | ❌10 | 🟡20 | +| Share button | ❌11 | ✅ | ✅ | 🟡21 | ✅ | ✅ | 🟡22 | +| **DualShock 4** | | | | | | | | +| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Basic rumble | ✅4 | ✅4 | ✅ | ✅ | ✅5 | ✅ | ➖ | +| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅6 | ✅2 | ➖ | +| Touchpad position | ✅ | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | +| Touchpad click | ✅ | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | +| Light bar (RGB/player color) | ✅ | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ | +| Battery state | ❌8 | ✅ | ❌26 | ✅ | ✅ | ✅ | ➖ | +| **DualSense** | | | | | | | | +| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Analog trigger input (0 to 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Basic rumble | ✅4 | ✅4 | ✅ | ✅ | ✅5 | ✅ | ➖ | +| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅6 | ❌ | ➖ | +| Touchpad position | ✅ | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | +| Touchpad click | ✅ | ✅ | ✅ | ✅ | ❌12 | ✅2 | ➖ | +| Light bar (RGB) | ✅ | ✅ | ✅ | ✅ | ✅7 | ✅2 | ➖ | +| Battery state | ❌8 | ✅ | ❌26 | ✅ | ✅ | ✅ | ➖ | +| Adaptive triggers | ❓24 | ✅ | ❓ | 🟡23 | ❌12 | ❓ | ➖ | +| Player indicator | ❌13 | ❌13 | ❌13 | ❌ | ❌ | ❌ | ➖ | +| MUTE button | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ | +| MUTE button LED | ❌13 | ❌13 | ❌13 | ❌ | ❌14 | ❌ | ➖ | +| **Nintendo Switch Pro Controller** | | | | | | | | +| Standard buttons, sticks, and D-pad | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Digital trigger input (0 or 1) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Basic rumble | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ➖ | +| Motion/gyro | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ➖ | +| Battery state | ❌8 | ✅ | ❌26 | ✅ | ✅ | ❌ | ➖ | +| HOME LED | ❌15 | ❌15 | ❌15 | ❌16 | ❌ | ❌ | ➖ | +| Player LED | ❌17 | ❌17 | ❌17 | ❌18 | ❌18 | ❌18 | ➖ | +| Capture button | ✅ | ✅ | ✅ | ✅ | ❌19 | ✅2 | ➖ | When a backend is marked ❌, that path cannot establish whether an additional client-side limitation exists. The owner below identifies the first known layer @@ -172,13 +175,13 @@ that prevents the feature from working end to end. | 8 | Windows host backend and external consumers | Current source carries battery updates through Windows HID input reports, and SDL3 consumer tests receive the Windows DualShock 4, DualSense, and Switch Pro values. The current Steam client hides its battery indicator for Windows VHF devices because it only renders the indicator for controllers classified as Bluetooth or wireless, while VHF exposes a wired virtual transport. For Xbox, a tested Windows desktop returned disconnected/empty from `XInputGetBatteryInformation` even while `XInputGetState` received the controller, while headless Windows CI did not expose an XInput slot for the same device; neither path exposes the submitted remote battery through XInput. Steam also did not show battery for the tested physical Xbox Series controller; Windows Game Bar showed its Bluetooth battery but not the virtual controller. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107), [XInput battery information](https://learn.microsoft.com/en-us/windows/win32/api/xinput/nf-xinput-xinputgetbatteryinformation), and [Virtual HID Framework configuration](https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/vhf/ns-vhf-_vhf_config) | | 9 | Client battery reporting | Moonlight Qt and Android did not forward Xbox One or Xbox Series battery state in the recorded tests. | [libvirtualhid issue #107](https://github.com/LizardByte/libvirtualhid/issues/107) | | 10 | Client platform | In the recorded iOS tests, Xbox One and Xbox Series battery reporting remained at 100% even when the physical controller's actual charge was substantially lower. | [Moonlight iOS battery reporting](https://github.com/moonlight-stream/moonlight-ios/blob/85af0f75622bb2636481afda8b0fc5cc33d5956e/Limelight/Input/ControllerSupport.m#L403-L440) | -| 11 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Driver on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) | +| 11 | Windows host backend | Steam does not expose the Xbox Series Share button through Virtual HID Broker on Windows. | [libvirtualhid issue #106](https://github.com/LizardByte/libvirtualhid/issues/106) | | 12 | Client platform | Android may expose a PlayStation touchpad as a mouse instead of a native controller touchpad. Leave **Gamepad touchpad as mouse** disabled when native forwarding is available. DualSense support requires Android 12 or later, and Sony documents that adaptive triggers are unavailable on Android mobile devices. | [Sony Android requirements](https://www.playstation.com/en-us/support/hardware/pair-dualsense-controller-bluetooth/) and [Moonlight Android touchpad handling](https://github.com/moonlight-stream/moonlight-android/blob/f10085f552b367cf7203007693d91c322a0a2936/app/src/main/java/com/limelight/binding/input/ControllerHandler.java#L1680-L1778) | -| 13 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through both host backends with Moonlight Qt. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) | +| 13 | Host output pipeline | DualSense player-indicator and MUTE-button LED forwarding is covered by open host pull requests. This note applies only to the LEDs; the MUTE button input works through Windows, Linux, and macOS with Moonlight Qt. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) | | 14 | Client platform | When a DualSense is connected to Android, its physical MUTE-button LED works locally, but that LED state is not forwarded to the virtual controller on the host. | [libvirtualhid pull request #97](https://github.com/LizardByte/libvirtualhid/pull/97) and [Sunshine pull request #5537](https://github.com/LizardByte/Sunshine/pull/5537) | -| 15 | Host output callback | Both host backends decode Switch Pro Set HOME Light output as a grayscale LED callback. End-to-end output remains unavailable because no tested client advertised compatible HOME-light support to Sunshine. | [libvirtualhid Switch Pro output handling](platform-support.md#linux) | +| 15 | Host output callback | Windows and Linux decode Switch Pro Set HOME Light output as a grayscale LED callback. End-to-end output remained unavailable in the macOS stream test because no tested client advertised compatible HOME-light support to Sunshine. | [libvirtualhid Switch Pro output handling](platform-support.md#linux) | | 16 | Client capability and output pipeline | Moonlight Qt v6.1.0 uses SDL2's RGB-style LED capability check, and the tested controller reported no LED. Moonlight Qt master uses SDL3 through sdl2-compat; SDL3 identifies HOME as a mono LED, but the compatibility check maps only the RGB capability. Neither path advertises LED support to Sunshine, so it never sends the HOME-light command. | [Moonlight Qt LED capability check](https://github.com/moonlight-stream/moonlight-qt/blob/v6.1.0/app/streaming/input/gamepad.cpp), [SDL Switch HOME-light capability](https://github.com/libsdl-org/SDL/blob/147a8ee32dbf9ac02f3794964490687b6bbda1bc/src/joystick/hidapi/SDL_hidapi_switch.c), and [sdl2-compat LED mapping](https://github.com/libsdl-org/sdl2-compat/blob/a53b6ad90ecd2d0ccfe01d5cfd2059793acf8c12/src/sdl2_compat.c) | -| 17 | Host output pipeline | Both host backends decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. The full host-to-client path is not available in released software. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) | +| 17 | Host output pipeline | Windows and Linux decode Switch Pro Set Player Lights output into solid and flashing player-indicator callbacks, and Sunshine can serialize those masks through its proposed protocol extension. The full host-to-client path was also unavailable in the macOS stream test. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) | | 18 | Client output protocol | Released moonlight-common-c and Moonlight clients do not consume the Switch Pro player-light extension, so testing with Moonlight Qt v6.1.0 leaves the physical player LEDs unchanged. | [libvirtualhid issue #113](https://github.com/LizardByte/libvirtualhid/issues/113) and [Sunshine player-LED integration](https://github.com/LizardByte/Sunshine/commit/596fbf9dc53775de87bc383a5293d4fcd546f837) | | 19 | Client | Moonlight Android exposes the tested Switch Pro Capture input as A instead of Capture. A broader Android Switch Pro mapping issue exists, but the exact Capture symptom is not explicitly tracked. | [Moonlight Android issue #842](https://github.com/moonlight-stream/moonlight-android/issues/842) | | 20 | Client battery reporting | The changes in Moonlight Xbox pull request #291 forward the physical controller's battery capability and charge updates. The released client does not yet include this support. | [Moonlight Xbox pull request #291](https://github.com/TheElixZammuto/moonlight-xbox/pull/291) | @@ -186,6 +189,8 @@ that prevents the feature from working end to end. | 22 | Client platform | On the tested Xbox console, the Xbox operating system captured the Share button instead of delivering it to Moonlight Xbox. The client reads `Windows.Gaming.Input.GamepadReading`, whose `GamepadButtons` bitfield has no Share value, so Moonlight Xbox cannot forward this input to Sunshine. | [Moonlight Xbox pull request #289](https://github.com/TheElixZammuto/moonlight-xbox/pull/289), [Moonlight Xbox gamepad-reading path](https://github.com/TheElixZammuto/moonlight-xbox/blob/d6c0636403ebfcee9071732f5ee10bb947c2ba44/Streaming/moonlight_xbox_dxMain.cpp#L529-L682), and [Windows GamepadButtons](https://learn.microsoft.com/en-us/uwp/api/windows.gaming.input.gamepadbuttons) | | 23 | Client release | Moonlight Qt adaptive-trigger support and its protocol and Sunshine dependencies are merged, but the latest published Moonlight Qt release predates them. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738), and [Moonlight Qt v6.1.0](https://github.com/moonlight-stream/moonlight-qt/releases/tag/v6.1.0) | | 24 | Windows host backend | Adaptive triggers have not been validated end to end through the Windows backend with the merged Moonlight and Sunshine protocol path. | [Moonlight Qt pull request #1561](https://github.com/moonlight-stream/moonlight-qt/pull/1561), [moonlight-common-c pull request #102](https://github.com/moonlight-stream/moonlight-common-c/pull/102), and [Sunshine pull request #3738](https://github.com/LizardByte/Sunshine/pull/3738) | +| 25 | macOS test control | Steam's controller tester did not offer a rumble control for the macOS Xbox 360 pad. Rumble was therefore not tested; the missing control alone does not establish a broker failure. | Sunshine stream from Moonlight Qt on Windows to Steam on macOS | +| 26 | macOS battery display | Steam did not show a battery indicator for any of the tested virtual pads in the macOS stream test. This observation does not establish whether another macOS HID consumer can read them. The Xbox 360 profile does not expose remote battery state. | Sunshine stream from Moonlight Qt on Windows to Steam on macOS | Analog trigger input reports intermediate values between 0 and 1. Switch Pro ZL/ZR input is digital and reports only 0 or 1. Trigger input is also separate @@ -202,7 +207,7 @@ battery or LED data. 1. Confirm that the physical controller works on the client before starting Moonlight. 2. Confirm that controller input is enabled in Sunshine. -3. On Windows, check the Virtual HID Driver version and license status on +3. On Windows, check the Virtual HID Broker version and license status on Sunshine's **Troubleshooting** page. On macOS, check that the signed broker is running and its machine license is active; see [macOS gamepad setup](macos-gamepad.md). @@ -276,7 +281,7 @@ Include enough information to identify which layer failed: - Physical controller model and firmware version. - Sunshine version, host operating system, and selected virtual-gamepad profile. -- Virtual HID Driver version on Windows. +- Virtual HID Broker version on Windows. - Game or test tool, whether Steam Input is enabled, and whether standard input works. - The exact missing feature and its direction, such as Switch Pro motion to the From 3c542e8cdc48191f09587bc2fff54b9a5be73116 Mon Sep 17 00:00:00 2001 From: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:01:38 -0400 Subject: [PATCH 32/32] Apply batched suggestions from code review Co-authored-by: Dave Lane <42013603+ReenigneArcher@users.noreply.github.com> --- docs/macos-gamepad.md | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/docs/macos-gamepad.md b/docs/macos-gamepad.md index 5ffb3f0d..00c8700e 100644 --- a/docs/macos-gamepad.md +++ b/docs/macos-gamepad.md @@ -40,20 +40,6 @@ certificate and notarization credentials can be reused for this Apple team. A profile for another bundle ID cannot authorize the broker. Apple explains the [restricted entitlement bundle and embedded profile](https://developer.apple.com/documentation/xcode/signing-a-daemon-with-a-restricted-entitlement). -Release CI reads the profile from the -`APPLE_MACOS_VIRTUAL_HID_PROVISIONING_PROFILE_BASE64` secret. The profile is a -binary file; on Windows, encode it with PowerShell and paste the clipboard -contents into that secret: - -```powershell -[Convert]::ToBase64String([IO.File]::ReadAllBytes("C:\path\to\broker.provisionprofile")) | Set-Clipboard -``` - -Release CI also uses `APPLE_CODESIGN_IDENTITY`, -`APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_BASE64`, -`APPLE_DEVELOPER_ID_APPLICATION_CERTIFICATE_P12_PASSWORD`, `APPLE_ID`, -`APPLE_TEAM_ID`, and `APPLE_NOTARYTOOL_PASSWORD`. - ## Build and distribute On macOS with Xcode and CMake installed: @@ -97,9 +83,9 @@ its own bundle ID and does not need the broker's restricted entitlement. Release CI performs these steps with the corresponding certificate, profile, and notarization secrets. -### Test a PR on macOS +### Test locally -Check out the PR branch on a Mac, install Xcode, and copy +Check out the branch of interest on a Mac, install Xcode, and copy `.env.example` to `.env` in the repository root. Fill in the Apple ID, notarization app-specific password, Developer ID Application `.p12` file path and export password, and the provisioning profile path. Paths must be absolute.