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.
-

+
# 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