▸
.github/workflows/ci-linux.yml
+10
−4
@@ -59,7 +59,8 @@
libwayland-dev \
libx11-xcb-dev \
libxcb-dri3-dev \
libxfixes-dev
libxfixes-dev \
libxtst-dev
- name: Build latest libva
env:
@@ -178,9 +179,14 @@
- name: Sync Python tools
run: |
uv_sync_args=(
uv sync --locked \
--python "${PYTHON_VERSION}" \
--no-python-downloads --no-install-project
--locked
--python "${PYTHON_VERSION}"
--no-python-downloads
--no-install-project
)
# uv.lock pins external artifacts; reviewed local build hooks are required for gcovr.
uv sync "${uv_sync_args[@]}" # NOSONAR(githubactions:S8541)
- name: Run tests
id: test
▸
.gitmodules
+4
−4
@@ -13,13 +13,13 @@
[submodule "third-party/glad"]
path = third-party/glad
url = https://github.com/Dav1dde/glad.git
[submodule "third-party/inputtino"]
path = third-party/inputtino
url = https://github.com/games-on-whales/inputtino.git
branch = stable
[submodule "third-party/libdisplaydevice"]
path = third-party/libdisplaydevice
url = https://github.com/LizardByte/libdisplaydevice.git
branch = master
[submodule "third-party/libvirtualhid"]
path = third-party/libvirtualhid
url = https://github.com/LizardByte/libvirtualhid.git
branch = master
[submodule "third-party/lizardbyte-common"]
path = third-party/lizardbyte-common
▸
cmake/compile_definitions/common.cmake
+12
−0
@@ -59,6 +59,18 @@
endif()
endif()
# libvirtualhid
add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/libvirtualhid")
list(APPEND SUNSHINE_EXTERNAL_LIBRARIES libvirtualhid::libvirtualhid)
list(APPEND PLATFORM_TARGET_FILES
"${CMAKE_SOURCE_DIR}/src/platform/virtualhid_input.h"
"${CMAKE_SOURCE_DIR}/src/platform/virtualhid_input.cpp")
# build libevdev before the libvirtualhid target when using the ExternalProject fallback
if(EXTERNAL_PROJECT_LIBEVDEV_USED AND TARGET libvirtualhid)
add_dependencies(libvirtualhid libevdev)
endif()
set(NVENC_PUBLIC_SOURCES
"${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_config.h"
"${CMAKE_SOURCE_DIR}/src/nvenc/nvenc_d3d11_interface.h"
▸
cmake/compile_definitions/linux.cmake
+3
−15
@@ -296,24 +296,12 @@
message(FATAL_ERROR "Couldn't find either cuda, libdrm, libva, kwin, pipewire, portal, wayland or x11")
endif()
# These need to be set before adding the inputtino subdirectory in order for them to be picked up
# These need to be set before common.cmake adds the libvirtualhid subdirectory in order for them to be picked up
set(LIBEVDEV_CUSTOM_INCLUDE_DIR "${EVDEV_INCLUDE_DIR}")
set(LIBEVDEV_CUSTOM_LIBRARY "${EVDEV_LIBRARY}")
if(FREEBSD)
set(USE_UHID OFF)
endif()
add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/inputtino")
list(APPEND SUNSHINE_EXTERNAL_LIBRARIES inputtino::libinputtino)
file(GLOB_RECURSE INPUTTINO_SOURCES
${CMAKE_SOURCE_DIR}/src/platform/linux/input/inputtino*.h
${CMAKE_SOURCE_DIR}/src/platform/linux/input/inputtino*.cpp)
list(APPEND PLATFORM_TARGET_FILES ${INPUTTINO_SOURCES})
# build libevdev before the libinputtino target
if(EXTERNAL_PROJECT_LIBEVDEV_USED)
add_dependencies(libinputtino libevdev)
endif()
list(APPEND PLATFORM_TARGET_FILES
"${CMAKE_SOURCE_DIR}/src/platform/linux/input/virtualhid.cpp")
# AppImage and Flatpak
if (${SUNSHINE_BUILD_APPIMAGE})
▸
cmake/compile_definitions/windows.cmake
+0
−5
@@ -62,11 +62,6 @@
INCLUDE_DIRECTORIES ""
)
# ViGEmBus version
set(VIGEMBUS_PACKAGED_V "1.21.442")
set(VIGEMBUS_PACKAGED_V_2 "${VIGEMBUS_PACKAGED_V}.0")
list(APPEND SUNSHINE_DEFINITIONS VIGEMBUS_PACKAGED_VERSION="${VIGEMBUS_PACKAGED_V_2}")
set(PLATFORM_TARGET_FILES
"${CMAKE_SOURCE_DIR}/src/platform/windows/publish.cpp"
"${CMAKE_SOURCE_DIR}/src/platform/windows/misc.h"
▸
cmake/packaging/common.cmake
+8
−0
@@ -36,6 +36,14 @@
"${CMAKE_CURRENT_BINARY_DIR}/assets/web/images/logo-sunshine.svg"
COPYONLY)
# Copy the Virtual HID Driver icon for Windows tray notifications.
if(WIN32)
configure_file(
"${CMAKE_SOURCE_DIR}/third-party/libvirtualhid/libvirtualhid.svg"
"${CMAKE_CURRENT_BINARY_DIR}/assets/web/images/logo-libvirtualhid.svg"
COPYONLY)
endif()
# install built vite assets
install(DIRECTORY "${CMAKE_CURRENT_BINARY_DIR}/assets/web"
DESTINATION "${SUNSHINE_ASSETS_DIR}")
▸
cmake/packaging/windows.cmake
+0
−25
@@ -9,24 +9,6 @@
install(FILES "${_MINHOOK_DLL}" DESTINATION "." COMPONENT application)
endif()
# ViGEmBus installer
set(SUNSHINE_THIRD_PARTY_DIR "third-party")
set(VIGEMBUS_INSTALLER "${CMAKE_BINARY_DIR}/${SUNSHINE_THIRD_PARTY_DIR}/vigembus_installer.exe")
set(VIGEMBUS_DOWNLOAD_URL_1 "https://github.com/nefarius/ViGEmBus/releases/download")
set(VIGEMBUS_DOWNLOAD_URL_2 "v${VIGEMBUS_PACKAGED_V_2}/ViGEmBus_${VIGEMBUS_PACKAGED_V}_x64_x86_arm64.exe")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/${SUNSHINE_THIRD_PARTY_DIR}")
file(DOWNLOAD
"${VIGEMBUS_DOWNLOAD_URL_1}/${VIGEMBUS_DOWNLOAD_URL_2}"
${VIGEMBUS_INSTALLER}
SHOW_PROGRESS
EXPECTED_HASH SHA256=155c50f1eec07bdc28d2f61a3e3c2c6c132fee7328412de224695f89143316bc
TIMEOUT 60
)
install(FILES ${VIGEMBUS_INSTALLER}
DESTINATION "${SUNSHINE_THIRD_PARTY_DIR}"
RENAME "vigembus_installer.exe"
COMPONENT gamepad)
# Adding tools
install(TARGETS dxgi-info RUNTIME DESTINATION "tools" COMPONENT dxgi)
install(TARGETS audio-info RUNTIME DESTINATION "tools" COMPONENT audio)
@@ -80,8 +62,6 @@
# Setting components groups and dependencies
set(CPACK_COMPONENT_GROUP_CORE_EXPANDED true)
set(CPACK_COMPONENT_GROUP_THIRDPARTY_DISPLAY_NAME "Third Party")
set(CPACK_COMPONENT_GROUP_THIRDPARTY_DESCRIPTION "Bundled third-party installers and optional components.")
# sunshine binary
set(CPACK_COMPONENT_APPLICATION_DISPLAY_NAME "${CMAKE_PROJECT_NAME}")
@@ -115,11 +95,6 @@
set(CPACK_COMPONENT_FIREWALL_DISPLAY_NAME "Add Firewall Exclusions")
set(CPACK_COMPONENT_FIREWALL_DESCRIPTION "Scripts to enable or disable firewall rules.")
set(CPACK_COMPONENT_FIREWALL_GROUP "Scripts")
# gamepad third-party installer
set(CPACK_COMPONENT_GAMEPAD_DISPLAY_NAME "Virtual Gamepad")
set(CPACK_COMPONENT_GAMEPAD_DESCRIPTION "ViGEmBus installer for virtual gamepad support.")
set(CPACK_COMPONENT_GAMEPAD_GROUP "ThirdParty")
# include specific packaging
include(${CMAKE_MODULE_PATH}/packaging/windows_nsis.cmake)
▸
CMakeLists.txt
+2
−1
@@ -1,7 +1,8 @@
cmake_minimum_required(VERSION 3.20)
cmake_minimum_required(VERSION 3.24)
# `CMAKE_CUDA_ARCHITECTURES` requires 3.18
# `set_source_files_properties` requires 3.18
# `cmake_path(CONVERT ... TO_NATIVE_PATH_LIST ...)` requires 3.20
# `third-party/libvirtualhid` requires 3.24
# todo - set this conditionally
project(Sunshine VERSION 0.0.0
▸
docs/api.md
+2
−5
@@ -94,10 +94,7 @@
## POST /api/restart
@copydoc confighttp::restart()
## GET /api/vigembus/status
@copydoc confighttp::getViGEmBusStatus()
## POST /api/vigembus/install
@copydoc confighttp::installViGEmBus()
## GET /api/virtual-input/status
@copydoc confighttp::getVirtualInputStatus()
<div class="section_buttons">
▸
docs/configuration.md
+24
−21
@@ -317,6 +317,7 @@
<td>Description</td>
<td colspan="2">
The type of gamepad to emulate on the host.
@note{This option applies to FreeBSD, Linux, and Windows.}
</td>
</tr>
<tr>
@@ -332,29 +333,32 @@
@endcode</td>
</tr>
<tr>
<td rowspan="7">Choices</td>
<td rowspan="6">Choices</td>
<td>generic</td>
<td>Generic HID gamepad</td>
</tr>
<tr>
<td>ds4</td>
<td>DualShock 4 controller (PS4)
@note{This option applies to Windows only.}</td>
<td>DualShock 4 controller (PS4)</td>
</tr>
<tr>
<td>ds5</td>
<td>DualShock 5 controller (PS5)</td>
<td>DualShock 5 controller (PS5)
@note{This option applies to FreeBSD and Linux only.}</td>
</tr>
<tr>
<td>switch</td>
<td>Switch Pro controller
@note{This option applies to FreeBSD and Linux only.}</td>
<td>Switch Pro controller</td>
</tr>
<tr>
<td>x360</td>
<td>Xbox 360 controller</td>
<td>Xbox 360 controller
@note{This option applies to Windows only.}</td>
</tr>
<tr>
<td>xone</td>
<td>Xbox One controller</td>
</tr>
<tr>
<td>xseries</td>
<td>Xbox One controller
@note{This option applies to FreeBSD and Linux only.}</td>
<td>Xbox Series controller</td>
</tr>
</table>
@@ -365,9 +369,9 @@
<tr>
<td>Description</td>
<td colspan="2">
Allow Select/Back inputs to also trigger a PlayStation-style gamepad touchpad click. Useful
for clients looking to emulate touchpad click on XInput devices.
@hint{Applies to ds4, ds5, and automatically selected PlayStation-style gamepads.}
Allow Select/Back inputs to also trigger DS4 touchpad click. Useful for clients looking to
emulate touchpad click on Xinput devices.
@hint{Only applies when gamepad is set to ds4 manually. Unused in other gamepad modes.}
</td>
</tr>
<tr>
@@ -391,7 +395,7 @@
<td>Description</td>
<td colspan="2">
If a client reports that a connected gamepad has motion sensor support, emulate it on the
host as a DS4 controller.
host as a PlayStation-style controller.
<br>
<br>
When disabled, motion sensors will not be taken into account during gamepad type selection.
@@ -418,8 +422,8 @@
<tr>
<td>Description</td>
<td colspan="2">
If a client reports that a connected gamepad has a touchpad, emulate it on the host
as a DS4 controller.
If a client reports that a connected gamepad has a touchpad, emulate it on the host as a
PlayStation-style controller.
<br>
<br>
When disabled, touchpad presence will not be taken into account during gamepad type selection.
@@ -440,14 +444,13 @@
</tr>
</table>
### virtualhid_randomize_mac
### ds5_inputtino_randomize_mac
<table>
<tr>
<td>Description</td>
<td colspan="2">
Randomize the MAC-Address for the generated virtual controller.
@hint{Only applies on linux for gamepads created as PS5-style controllers}
Randomize the MAC address for PlayStation-style virtual controllers created by libvirtualhid.
</td>
</tr>
<tr>
@@ -459,7 +462,7 @@
<tr>
<td>Example</td>
<td colspan="2">@code{}
ds5_inputtino_randomize_mac = enabled
virtualhid_randomize_mac = enabled
@endcode</td>
</tr>
</table>
▸
docs/getting_started.md
+16
−5
@@ -474,10 +474,20 @@
> Gamepads are not currently supported.
### Windows
Sunshine uses libvirtualhid for virtual gamepads on Windows. You must install the
[Virtual HID Driver](https://github.com/LizardByte/libvirtualhid/releases/latest) separately for full virtual gamepad
support. ViGEmBus is detected only as a limited fallback for Xbox 360 and DualShock 4 gamepads when libvirtualhid is
unavailable.
Compared with the ViGEmBus fallback, Virtual HID Driver can create Xbox One, Xbox Series, DualSense, Nintendo Switch
Pro, and Generic gamepads in addition to Xbox 360 and DualShock 4. It can also expose controller-specific features such
as motion, touchpads, LEDs, and adaptive triggers when supported. Virtual HID Driver is actively developed and
supported by the LizardByte team.
The Virtual HID Driver also requires an active machine license. Sunshine shows the current license status and actions
In order for virtual gamepads to work, you must install ViGEmBus. You can do this from the troubleshooting tab
in the web UI, as long as you are running Sunshine as a service or as an administrator. After installation, it is
recommended to restart your computer.
on the Web UI Troubleshooting page and in the **Virtual HID Driver** system tray submenu. When Sunshine starts on an
unactivated machine, select its tray notification to open the activation and purchase options in the Web UI.

After installing or updating virtual input drivers, it is recommended to restart your computer.
## Usage
@@ -612,7 +622,8 @@
The following are known limitations.
* Only X11 and Wayland capture are supported
* Gamepads use libvirtualhid's uinput backend, so descriptor-driven features such as motion, touchpad input,
* DualSense/DS5 emulation is not available due to missing uhid features
battery state, RGB LEDs, adaptive triggers, and raw HID output reports are unavailable
### HDR Support
▸
docs/troubleshooting.md
+13
−4
@@ -205,6 +205,6 @@
When the seat is not `seat0`, Sunshine appends the seat name to its virtual device names, for example:
- Keyboard passthrough (seat1)
- Sunshine PS5 (virtual) pad (seat1)
- Sunshine (libvirtualhid) PS5 Controller (seat1)
Sunshine creates two mouse devices: a relative one and an absolute one.
@@ -290,9 +290,18 @@
## Windows
### No gamepad detected
Sunshine uses libvirtualhid for virtual gamepads on Windows. Install the
[Virtual HID Driver](https://github.com/LizardByte/libvirtualhid/releases/latest) separately for full virtual gamepad
support. ViGEmBus is detected only as a limited fallback for Xbox 360 and DualShock 4 gamepads when libvirtualhid is
unavailable. If you use the [ViGEmBus fallback](https://github.com/nefarius/ViGEmBus/releases/latest), you must use
version 1.17 or newer.
Virtual HID Driver adds Xbox One, Xbox Series, DualSense, Nintendo Switch Pro, and Generic gamepads, plus advanced
You must install ViGEmBus to use virtual gamepads. You can install this from the troubleshooting tab of the web UI.
controller features such as motion, touchpads, LEDs, and adaptive triggers when supported. Unlike the discontinued
ViGEmBus project, Virtual HID Driver is actively developed and supported by the LizardByte team.
Alternatively, you can manually install it from
[ViGEmBus releases](https://github.com/nefarius/ViGEmBus/releases/latest). You must use version 1.17 or newer.
An active Virtual HID Driver machine license is required before Sunshine can create libvirtualhid gamepads. Follow
the warning on the Web UI home page, the startup tray notification, or the **Virtual HID Driver** tray submenu to open
the license section on the Troubleshooting page, where you can activate a key or follow the purchase link.
After installation, it is recommended to restart your computer.
▸
gh-pages-template/_data/features.yml
+0
−1
@@ -30,6 +30,5 @@
Sunshine emulates an Xbox, PlayStation, or Nintendo Switch controller.
Use nearly any controller on your Moonlight client!
<br><small><ul>
<li>Nintendo Switch emulation is only available on Linux.</li>
<li>Gamepad emulation is not currently supported on macOS.</li>
</ul></small>
▸
README.md
+29
−12
@@ -53,42 +53,59 @@
</td>
</tr>
<tr>
<td>Generic</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>DualShock / DS4 (PlayStation 4)</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>➖</td>
<td>➖</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>DualSense / DS5 (PlayStation 5)</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>❌</td>
<td>✅</td>
<td>❌</td>
<td>❌</td>
</tr>
<tr>
<td>Nintendo Switch Pro</td>
<td>✅</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>❌</td>
<td>✅</td>
<td>❌</td>
</tr>
<tr>
<td>Xbox 360</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>➖</td>
<td>➖</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Xbox One</td>
<td>Xbox One/Series</td>
<td>✅</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Xbox Series</td>
<td>🟡<sup>1</sup></td>
<td>✅</td>
<td>❌</td>
<td>✅</td>
</tr>
</table>
> [!NOTE]
> <sup>1</sup> Missing motion, touchpad input, battery state, RGB LEDs, adaptive triggers, and raw HID output reports.
<table>
<caption id="encoding_api">Encoding API</caption>
<tr>
@@ -381,7 +398,7 @@
</tr>
<tr>
<td rowspan="6">OS</td>
<td>FreeBSD: 15.1+</td>
<td>FreeBSD: 14.4+</td>
</tr>
<tr>
<td>Linux/Debian: 13+ (trixie)</td>
@@ -396,7 +413,7 @@
<td>macOS: 14.2+</td>
</tr>
<tr>
<td>Windows: 11+ (Windows Server does not support virtual gamepads)</td>
<td>Windows: 11+</td>
</tr>
<tr>
<td rowspan="2">Network</td>
▸
src/audio.cpp
+6
−6
@@ -54,7 +54,7 @@
2,
1,
1,
platf::speaker::map_stereo,
platf::speaker::map_stereo.data(),
96000,
},
{
@@ -62,7 +62,7 @@
2,
1,
1,
platf::speaker::map_stereo,
platf::speaker::map_stereo.data(),
512000,
},
{
@@ -70,7 +70,7 @@
6,
4,
2,
platf::speaker::map_surround51.data(),
platf::speaker::map_surround51,
256000,
},
{
@@ -78,7 +78,7 @@
6,
6,
0,
platf::speaker::map_surround51,
platf::speaker::map_surround51.data(),
1536000,
},
{
@@ -86,7 +86,7 @@
8,
5,
3,
platf::speaker::map_surround71,
platf::speaker::map_surround71.data(),
450000,
},
{
@@ -94,7 +94,7 @@
8,
8,
0,
platf::speaker::map_surround71.data(),
platf::speaker::map_surround71,
2048000,
},
};
▸
src/config.cpp
+33
−40
@@ -33,6 +33,15 @@
#include <shellapi.h>
#endif
#if defined(_WIN32) && !defined(DOXYGEN)
#ifdef _GLIBCXX_USE_C99_INTTYPES
#undef _GLIBCXX_USE_C99_INTTYPES
#endif
#include <AMF/components/VideoEncoderAV1.h>
#include <AMF/components/VideoEncoderHEVC.h>
#include <AMF/components/VideoEncoderVCE.h>
#endif
#if !defined(__ANDROID__) && !defined(__APPLE__)
// For NVENC legacy constants
#include <ffnvcodec/nvEncodeAPI.h>
@@ -140,13 +149,6 @@
constexpr int AMF_VIDEO_ENCODER_UNDEFINED = 0; ///< Fallback AMF enum value for undefined.
constexpr int AMF_VIDEO_ENCODER_CABAC = 1; ///< Fallback AMF enum value for cabac.
constexpr int AMF_VIDEO_ENCODER_CALV = 2; ///< Fallback AMF enum value for calv.
#else
#ifdef _GLIBCXX_USE_C99_INTTYPES
#undef _GLIBCXX_USE_C99_INTTYPES
#endif
#include <AMF/components/VideoEncoderAV1.h>
#include <AMF/components/VideoEncoderHEVC.h>
#include <AMF/components/VideoEncoderVCE.h>
#endif
/**
@@ -847,10 +849,10 @@
platf::supported_gamepads(nullptr).front().name.data(),
platf::supported_gamepads(nullptr).front().name.size(),
}, // Default gamepad
true, // back as touchpad click enabled for PlayStation-style gamepads
true, // client gamepads with motion events use PlayStation-style emulation
true, // client gamepads with touchpads use PlayStation-style emulation
true, // back as touchpad click enabled (manual DS4 only)
true, // client gamepads with motion events are emulated as DS4
true, // client gamepads with touchpads are emulated as DS4
true, // ds5_inputtino_randomize_mac
true, // virtualhid_randomize_mac
true, // keyboard enabled
true, // mouse enabled
@@ -1164,6 +1166,23 @@
}
/**
* @brief Parse a decimal or hexadecimal integer configuration value.
*
* @param value Raw configuration value, optionally surrounded by quotes.
* @return Parsed integer value.
*/
int parse_config_integer(std::string_view value) {
if (value.size() >= 2 && value.front() == '"') {
value = value.substr(1, value.size() - 2);
}
if (value.starts_with("0x"sv)) {
return util::from_hex<int>(value.substr(2));
}
return static_cast<int>(util::from_view(value));
}
/**
* @brief Consume an integer setting from decimal or hexadecimal configuration text.
*
* @param vars Parsed configuration entries; consumed keys are erased.
@@ -1175,21 +1194,8 @@
if (it == std::end(vars)) {
return;
}
std::string_view val = it->second;
// If value is something like: "756" instead of 756
if (val.size() >= 2 && val[0] == '"') {
val = val.substr(1, val.size() - 2);
}
// If that integer is in hexadecimal
if (val.size() >= 2 && val.substr(0, 2) == "0x"sv) {
input = util::from_hex<int>(val.substr(2));
} else {
input = (int) util::from_view(val);
}
input = parse_config_integer(it->second);
vars.erase(it);
}
@@ -1207,20 +1213,7 @@
if (it == std::end(vars)) {
return;
}
std::string_view val = it->second;
// If value is something like: "756" instead of 756
if (val.size() >= 2 && val[0] == '"') {
val = val.substr(1, val.size() - 2);
}
// If that integer is in hexadecimal
if (val.size() >= 2 && val.substr(0, 2) == "0x"sv) {
input = util::from_hex<int>(val.substr(2));
} else {
input = util::from_view(val);
input = parse_config_integer(it->second);
}
vars.erase(it);
}
@@ -1792,7 +1785,7 @@
bool_f(vars, "ds4_back_as_touchpad_click", input.ds4_back_as_touchpad_click);
bool_f(vars, "motion_as_ds4", input.motion_as_ds4);
bool_f(vars, "touchpad_as_ds4", input.touchpad_as_ds4);
bool_f(vars, "ds5_inputtino_randomize_mac", input.ds5_inputtino_randomize_mac);
bool_f(vars, "virtualhid_randomize_mac", input.virtualhid_randomize_mac);
bool_f(vars, "mouse", input.mouse);
bool_f(vars, "keyboard", input.keyboard);
▸
src/config.h
+4
−4
@@ -275,10 +275,10 @@
std::chrono::duration<double> key_repeat_period; ///< Interval between repeated keyboard key events.
std::string gamepad; ///< Virtual controller backend selected by configuration.
bool ds4_back_as_touchpad_click; ///< Map the DS4 Back button to a touchpad click.
bool motion_as_ds4; ///< Expose motion controls through the DS4 protocol.
bool touchpad_as_ds4; ///< Expose touchpad input through the DS4 protocol.
bool ds5_inputtino_randomize_mac; ///< Randomize the inputtino DualSense MAC address.
bool ds4_back_as_touchpad_click; ///< Map Back/Select to touchpad click for PlayStation-style gamepads.
bool motion_as_ds4; ///< Prefer PlayStation-style emulation for client gamepads with motion controls.
bool touchpad_as_ds4; ///< Prefer PlayStation-style emulation for client gamepads with touchpad input.
bool virtualhid_randomize_mac; ///< Randomize the libvirtualhid virtual controller MAC address.
bool keyboard; ///< Enable keyboard input from clients.
bool key_rightalt_to_key_win; ///< Map the client Right Alt key to the Windows key.
▸
src/confighttp.cpp
+487
−87
@@ -8,22 +8,29 @@
// standard includes
#include <algorithm>
#include <charconv>
#include <filesystem>
#include <format>
#include <fstream>
#include <new>
#include <optional>
#include <string_view>
#include <utility>
#include <vector>
// lib includes
#include <boost/algorithm/string.hpp>
#include <boost/asio/ssl/context.hpp>
#include <boost/filesystem.hpp>
#include <lizardbyte/common/env.h>
#include <nlohmann/json.hpp>
#include <Simple-Web-Server/crypto.hpp>
#include <Simple-Web-Server/server_https.hpp>
#ifdef _WIN32
#include "platform/virtualhid_input.h"
#include "platform/windows/misc.h"
#include "platform/windows/utf_utils.h"
#include <vector>
#include <Windows.h>
#endif
@@ -42,5 +49,6 @@
#include "platform/common.h"
#include "process.h"
#include "rtsp.h"
#include "system_tray.h"
#include "utility.h"
#include "uuid.h"
@@ -72,6 +80,28 @@
*/
using https_handler_t = std::function<void(resp_https_t, req_https_t)>;
namespace {
using license_status_provider_t = std::function<lvh::LicenseResult()>; ///< Provider for the current libvirtualhid license status.
/**
* @brief Return the current libvirtualhid license status provider.
*
* Unit-test builds expose a mutable provider so the HTTP fixture can avoid
* contacting an installed Windows broker. Production builds keep the
* provider const and always call libvirtualhid directly.
*
* @return License status provider for the current build.
*/
auto &virtual_input_license_status_provider() {
#ifdef SUNSHINE_TESTS
static license_status_provider_t status_provider = lvh::get_license_status;
#else
static const license_status_provider_t status_provider = lvh::get_license_status;
#endif
return status_provider;
}
} // namespace
/**
* @brief Client certificate operations accepted by the configuration API.
*/
@@ -80,6 +110,48 @@
REMOVE ///< Remove client
};
/**
* @brief Overwrite a request-local sensitive string when leaving scope.
*/
class scoped_sensitive_string_clear_t {
public:
/**
* @brief Register a sensitive string for best-effort clearing.
*
* @param value Mutable sensitive string.
*/
explicit scoped_sensitive_string_clear_t(std::string &value):
value_ {value} {}
scoped_sensitive_string_clear_t(const scoped_sensitive_string_clear_t &) = delete;
scoped_sensitive_string_clear_t &operator=(const scoped_sensitive_string_clear_t &) = delete;
/**
* @brief Overwrite and clear the registered string.
*/
~scoped_sensitive_string_clear_t() {
std::fill(value_.begin(), value_.end(), '\0');
value_.clear();
}
private:
std::string &value_; ///< Sensitive request-local string.
};
#ifdef SUNSHINE_TESTS
void set_virtual_input_license_status_provider_for_testing(virtual_input_license_status_provider_t status_provider) {
virtual_input_license_status_provider() = std::move(status_provider);
}
void reset_virtual_input_license_status_provider_for_testing() {
virtual_input_license_status_provider() = lvh::get_license_status;
}
void clear_sensitive_string_for_testing(std::string &value) {
const scoped_sensitive_string_clear_t clear_value {value};
}
#endif
// CSRF token management
/**
* @brief CSRF token value and its expiration deadline.
@@ -101,8 +173,304 @@
* @brief Amount of time a generated CSRF token remains valid.
*/
constexpr auto CSRF_TOKEN_LIFETIME = std::chrono::hours(1); // Tokens valid for 1 hour
constexpr auto LIBVIRTUALHID_MINIMUM_VERSION = ""sv; ///< Minimum supported libvirtualhid driver version; empty means any version.
constexpr auto VIGEMBUS_MINIMUM_VERSION = "1.17.0.0"sv; ///< Minimum supported ViGEmBus fallback driver version. // NOSONAR(cpp:S1313): not an IP address
/**
* @brief Parse one dotted driver-version component.
*
* @param part Version component text.
* @return Parsed component value, or empty when invalid.
*/
std::optional<unsigned int> parse_driver_version_part(std::string_view part) {
if (part.empty()) {
return std::nullopt;
}
unsigned int value = 0;
const auto *begin = part.data();
const auto *end = part.data() + part.size();
const auto [ptr, ec] = std::from_chars(begin, end, value);
if (ec != std::errc {} || ptr != end) {
return std::nullopt;
}
return value;
}
/**
* @brief Parse a dotted driver version into numeric components.
*
* @param version Driver version text.
* @return Parsed version parts, or empty when invalid.
*/
std::optional<std::vector<unsigned int>> parse_driver_version(std::string_view version) {
if (version.empty()) {
return std::nullopt;
}
std::vector<unsigned int> parts;
std::size_t start = 0;
while (start <= version.size()) {
const auto dot = version.find('.', start);
const auto length = dot == std::string_view::npos ? std::string_view::npos : dot - start;
const auto part = parse_driver_version_part(version.substr(start, length));
if (!part.has_value()) {
return std::nullopt;
}
parts.push_back(*part);
if (dot == std::string_view::npos) {
break;
}
start = dot + 1;
}
return parts;
}
bool is_driver_version_supported(std::string_view version, std::string_view minimum_version) {
if (minimum_version.empty()) {
return true;
}
const auto version_parts = parse_driver_version(version);
const auto minimum_parts = parse_driver_version(minimum_version);
if (!version_parts || !minimum_parts) {
return false;
}
const auto part_count = std::max(version_parts->size(), minimum_parts->size());
for (std::size_t i = 0; i < part_count; ++i) {
const auto version_part = i < version_parts->size() ? (*version_parts)[i] : 0U;
const auto minimum_part = i < minimum_parts->size() ? (*minimum_parts)[i] : 0U;
if (version_part != minimum_part) {
return version_part > minimum_part;
}
}
return true;
}
nlohmann::json build_driver_status(bool installed, const std::string &version, std::string_view minimum_version) {
const auto minimum_version_text = std::string {minimum_version};
nlohmann::json output_tree;
output_tree["installed"] = installed;
output_tree["version"] = version;
output_tree["minimum_version"] = minimum_version_text;
output_tree["supported_versions"] = minimum_version.empty() ? "Any" : std::format(">= {}", minimum_version_text);
output_tree["version_compatible"] = installed && is_driver_version_supported(version, minimum_version);
return output_tree;
}
/**
* @brief Return a stable Web UI name for a libvirtualhid license state.
*
* @param state License state.
* @return Lowercase state name.
*/
std::string_view virtualhid_license_state_name(lvh::LicenseState state) {
using enum lvh::LicenseState;
switch (state) {
case unlicensed:
return "unlicensed";
case licensed:
return "licensed";
case expired:
return "expired";
case disabled:
return "disabled";
case invalid:
return "invalid";
case unavailable:
default:
return "unavailable";
}
}
nlohmann::json build_virtualhid_license_status(const lvh::LicenseResult &result) {
const auto &license = result.license;
nlohmann::json output_tree;
output_tree["operation_ok"] = result.status.ok();
output_tree["service_available"] = license.service_available;
output_tree["state"] = virtualhid_license_state_name(license.state);
output_tree["licensed"] = license.licensed();
output_tree["active_devices"] = license.active_devices;
output_tree["activation_limit"] = license.activation_limit;
output_tree["activation_usage"] = license.activation_usage;
output_tree["plan_name"] = license.plan_name;
output_tree["customer_email"] = license.customer_email;
output_tree["expires_at"] = license.expires_at;
output_tree["message"] = license.message;
output_tree["purchase_url"] = license.purchase_url;
output_tree["manage_account_url"] = license.manage_account_url;
output_tree["error"] = result.status.ok() ? "" : result.status.message();
return output_tree;
}
namespace {
/**
* @brief Handle a virtual-input license request using the configured status provider.
*
* @param response HTTP response object.
* @param request Authenticated HTTP request.
*/
void get_virtual_input_license(const resp_https_t &response, const req_https_t &request) {
if (!authenticate(response, request)) {
return;
}
print_req(request);
send_response(response, build_virtualhid_license_status(virtual_input_license_status_provider()()));
}
} // namespace
#ifdef _WIN32
/**
* @brief RAII wrapper for a Windows registry key handle.
*/
class registry_key_t {
public:
/**
* @brief Construct an empty registry key wrapper.
*/
registry_key_t() = default;
/**
* @brief Copy construction is disabled because the wrapper owns a handle.
*/
registry_key_t(const registry_key_t &) = delete;
/**
* @brief Copy assignment is disabled because the wrapper owns a handle.
*
* @return This registry key wrapper.
*/
registry_key_t &operator=(const registry_key_t &) = delete;
/**
* @brief Close the owned registry key handle.
*/
~registry_key_t() {
close();
}
/**
* @brief Get the owned registry key handle.
*
* @return Registry key handle.
*/
HKEY get() const {
return handle;
}
/**
* @brief Prepare the wrapper to receive a registry key handle.
*
* @return Address of the wrapped handle.
*/
HKEY *put() {
close();
return &handle;
}
private:
/**
* @brief Close the owned registry key handle if one is open.
*/
void close() {
if (handle) {
RegCloseKey(handle);
handle = nullptr;
}
}
HKEY handle = nullptr; ///< Owned Windows registry key handle.
};
/**
* @brief Read a string value from a Windows registry key.
*
* @param key Registry key to query.
* @param value_name Registry value name.
* @return Registry string value, or empty when unavailable.
*/
std::optional<std::wstring> read_registry_string_value(HKEY key, const wchar_t *value_name) {
DWORD value_type = 0;
DWORD value_size = 0;
if (RegGetValueW(key, nullptr, value_name, RRF_RT_REG_SZ, &value_type, nullptr, &value_size) != ERROR_SUCCESS || value_size == 0) {
return std::nullopt;
}
std::wstring value(value_size / sizeof(wchar_t), L'\0');
if (RegGetValueW(key, nullptr, value_name, RRF_RT_REG_SZ, &value_type, value.data(), &value_size) != ERROR_SUCCESS) {
return std::nullopt;
}
while (!value.empty() && value.back() == L'\0') {
value.pop_back();
}
return value;
}
/**
* @brief Read the installed libvirtualhid driver version from the Windows device registry.
*
* @return Driver version string, or empty when unavailable.
*/
std::string read_libvirtualhid_driver_version() {
registry_key_t root_key;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Enum\\ROOT\\LIBVIRTUALHID", 0, KEY_READ, root_key.put()) != ERROR_SUCCESS) {
return {};
}
for (DWORD index = 0;; ++index) {
std::wstring subkey_name(256, L'\0');
auto subkey_name_size = static_cast<DWORD>(subkey_name.size());
const auto enum_status = RegEnumKeyExW(root_key.get(), index, subkey_name.data(), &subkey_name_size, nullptr, nullptr, nullptr, nullptr);
if (enum_status == ERROR_NO_MORE_ITEMS) {
break;
}
if (enum_status != ERROR_SUCCESS) {
continue;
}
std::wstring device_key_path = L"SYSTEM\\CurrentControlSet\\Enum\\ROOT\\LIBVIRTUALHID\\";
device_key_path.append(subkey_name, 0, subkey_name_size);
registry_key_t device_key;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, device_key_path.c_str(), 0, KEY_READ, device_key.put()) != ERROR_SUCCESS) {
continue;
}
const auto driver_key_suffix = read_registry_string_value(device_key.get(), L"Driver");
if (!driver_key_suffix) {
continue;
}
std::wstring driver_key_path = L"SYSTEM\\CurrentControlSet\\Control\\Class\\";
driver_key_path += *driver_key_suffix;
registry_key_t driver_key;
if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, driver_key_path.c_str(), 0, KEY_READ, driver_key.put()) != ERROR_SUCCESS) {
continue;
}
if (const auto version = read_registry_string_value(driver_key.get(), L"DriverVersion")) {
return utf_utils::to_utf8(*version);
}
}
return {};
}
#endif
/**
* @brief Log the request details.
* @param request The HTTP request object.
*/
@@ -1431,131 +1799,162 @@
}
/**
* @brief Build libvirtualhid driver version and installation status.
* @brief Get ViGEmBus driver version and installation status.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/vigembus/status| GET| null}
* @return libvirtualhid driver status JSON.
*/
void getViGEmBusStatus(const resp_https_t &response, const req_https_t &request) {
if (!authenticate(response, request)) {
return;
nlohmann::json get_virtualhid_driver_status() {
#ifdef _WIN32
const auto version_str = read_libvirtualhid_driver_version();
auto output_tree = build_driver_status(false, version_str, LIBVIRTUALHID_MINIMUM_VERSION);
bool requires_installed_driver = true;
std::string backend_name;
std::string runtime_error_message;
try {
const auto runtime = platf::virtualhid::create_runtime();
if (runtime) {
const auto &capabilities = runtime->capabilities();
backend_name = capabilities.backend_name;
requires_installed_driver = capabilities.requires_installed_driver;
output_tree = build_driver_status(capabilities.supports_gamepad, version_str, LIBVIRTUALHID_MINIMUM_VERSION);
}
} catch (const std::bad_alloc &exception) {
runtime_error_message = exception.what();
}
output_tree["backend_name"] = backend_name;
output_tree["requires_installed_driver"] = requires_installed_driver;
if (!runtime_error_message.empty()) {
print_req(request);
output_tree["error"] = runtime_error_message;
}
#else
auto output_tree = build_driver_status(false, "", LIBVIRTUALHID_MINIMUM_VERSION);
output_tree["error"] = "libvirtualhid driver status is only available on Windows";
output_tree["backend_name"] = "";
output_tree["requires_installed_driver"] = false;
#endif
nlohmann::json output_tree;
return output_tree;
}
/**
* @brief Build ViGEmBus fallback driver version and installation status.
*
* @return ViGEmBus fallback driver status JSON.
*/
nlohmann::json get_vigembus_driver_status() {
#ifdef _WIN32
std::string version_str;
bool installed = false;
bool version_compatible = false;
// Check if ViGEmBus driver exists
std::filesystem::path driver_path = std::filesystem::path(std::getenv("SystemRoot") ? std::getenv("SystemRoot") : "C:\\Windows") / "System32" / "drivers" / "ViGEmBus.sys";
if (std::filesystem::exists(driver_path)) {
std::string system_root;
if (!lizardbyte::common::get_env("SystemRoot", system_root)) {
system_root = "C:\\Windows";
}
const std::filesystem::path driver_path = std::filesystem::path(system_root) / "System32" / "drivers" / "ViGEmBus.sys";
const auto installed = std::filesystem::exists(driver_path);
if (installed) {
platf::getFileVersionInfo(driver_path, version_str);
installed = platf::getFileVersionInfo(driver_path, version_str);
if (installed) {
// Parse version string to check compatibility (>= 1.17.0.0)
std::vector<std::string> version_parts;
std::stringstream ss(version_str);
std::string part;
while (std::getline(ss, part, '.')) {
version_parts.push_back(part);
}
if (version_parts.size() >= 2) {
int major = std::stoi(version_parts[0]);
int minor = std::stoi(version_parts[1]);
version_compatible = (major > 1) || (major == 1 && minor >= 17);
}
}
}
output_tree["installed"] = installed;
output_tree["version"] = version_str;
output_tree["version_compatible"] = version_compatible;
output_tree["packaged_version"] = VIGEMBUS_PACKAGED_VERSION;
auto output_tree = build_driver_status(installed, version_str, VIGEMBUS_MINIMUM_VERSION);
#else
auto output_tree = build_driver_status(false, "", VIGEMBUS_MINIMUM_VERSION);
output_tree["error"] = "ViGEmBus is only available on Windows";
output_tree["installed"] = false;
output_tree["version"] = "";
output_tree["version_compatible"] = false;
output_tree["packaged_version"] = "";
#endif
send_response(response, output_tree);
return output_tree;
}
/**
* @brief Get virtual input driver version and installation status.
* @brief Install ViGEmBus driver with elevated permissions.
* @param response The HTTP response object.
* @param request The HTTP request object.
*
* @api_examples{/api/vigembus/install| POST| null}
* @api_examples{/api/virtual-input/status| GET| null}
*/
void installViGEmBus(const resp_https_t &response, const req_https_t &request) {
void getVirtualInputStatus(const resp_https_t &response, const req_https_t &request) {
if (!authenticate(response, request)) {
return;
}
std::string client_id = get_client_id(request);
if (!validate_csrf_token(response, request, client_id)) {
return;
}
print_req(request);
nlohmann::json output_tree;
output_tree["virtualhid"] = get_virtualhid_driver_status();
output_tree["vigembus"] = get_vigembus_driver_status();
send_response(response, output_tree);
}
#ifdef _WIN32
// Get the path to the packaged ViGEmBus installer.
const std::filesystem::path installer_path = platf::appdata().parent_path() / "third-party" / "vigembus_installer.exe";
/**
* @brief Get the current libvirtualhid machine license status.
*
* @param response HTTP response object.
* @param request Authenticated HTTP request.
*
* @api_examples{/api/virtual-input/license| GET| null}
*/
void getVirtualInputLicense(const resp_https_t &response, const req_https_t &request) {
get_virtual_input_license(response, request);
}
/**
* @brief Activate, validate, or deactivate the libvirtualhid machine license.
if (!std::filesystem::exists(installer_path)) {
*
* Submitted license keys are used only for the synchronous broker call. They are
* never logged or saved in Sunshine's configuration, and extracted mutable copies
* are overwritten before the handler returns.
*
* @param response HTTP response object.
* @param request Authenticated HTTP request with a JSON action.
*
* @api_examples{/api/virtual-input/license| POST| {"action":"validate"}}
*/
void updateVirtualInputLicense(const resp_https_t &response, const req_https_t &request) {
if (!authenticate(response, request)) {
output_tree["status"] = false;
output_tree["error"] = "ViGEmBus installer not found";
send_response(response, output_tree);
return;
}
// Run the installer with elevated permissions
std::error_code ec;
boost::filesystem::path working_dir = boost::filesystem::path(installer_path.string()).parent_path();
boost::process::v1::environment env = boost::this_process::environment();
// Run with elevated permissions, non-interactive
const std::string install_cmd = std::format("{} /quiet", installer_path.string());
auto child = platf::run_command(true, false, install_cmd, working_dir, env, nullptr, ec, nullptr);
if (ec) {
output_tree["status"] = false;
output_tree["error"] = "Failed to start installer: " + ec.message();
send_response(response, output_tree);
const auto client_id = get_client_id(request);
if (!validate_csrf_token(response, request, client_id)) {
return;
}
print_req(request);
try {
std::stringstream content;
content << request->content.rdbuf();
auto input_tree = nlohmann::json::parse(content);
// Wait for the installer to complete
child.wait(ec);
const auto action = input_tree.value("action", "");
lvh::LicenseResult result;
if (action == "activate") {
auto license_key = input_tree.value("license_key", "");
input_tree["license_key"] = "";
if (license_key.empty()) {
bad_request(response, request, "License key is required");
return;
}
const scoped_sensitive_string_clear_t clear_license_key {license_key};
if (ec) {
output_tree["status"] = false;
output_tree["error"] = "Installer failed: " + ec.message();
} else {
int exit_code = child.exit_code();
output_tree["status"] = (exit_code == 0);
output_tree["exit_code"] = exit_code;
if (exit_code != 0) {
output_tree["error"] = std::format("Installer exited with code {}", exit_code);
result = lvh::activate_license(license_key);
} else if (action == "validate") {
result = lvh::validate_license();
} else if (action == "deactivate") {
result = lvh::deactivate_license();
} else {
bad_request(response, request, "Unknown license action");
return;
}
#if defined(_WIN32) && defined(SUNSHINE_TRAY) && SUNSHINE_TRAY >= 1
system_tray::update_tray_virtualhid_license(result.license, false);
#endif
send_response(response, build_virtualhid_license_status(result));
} catch (const nlohmann::json::exception &) {
bad_request(response, request, "Invalid license request");
}
#else
output_tree["status"] = false;
output_tree["error"] = "ViGEmBus installation is only available on Windows";
#endif
send_response(response, output_tree);
}
/**
@@ -1812,8 +2211,9 @@
server.resource["^/api/logs$"]["GET"] = getLogs;
server.resource["^/api/reset-display-device-persistence$"]["POST"] = resetDisplayDevicePersistence;
server.resource["^/api/restart$"]["POST"] = restart;
server.resource["^/api/vigembus/status$"]["GET"] = getViGEmBusStatus;
server.resource["^/api/vigembus/install$"]["POST"] = installViGEmBus;
server.resource["^/api/virtual-input/license$"]["GET"] = getVirtualInputLicense;
server.resource["^/api/virtual-input/license$"]["POST"] = updateVirtualInputLicense;
server.resource["^/api/virtual-input/status$"]["GET"] = getVirtualInputStatus;
// static/dynamic resources
server.resource["^/images/sunshine.ico$"]["GET"] = getFaviconImage;
▸
src/confighttp.h
+76
−0
@@ -6,9 +6,12 @@
// standard includes
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <string_view>
// lib includes
#include <libvirtualhid/license.hpp>
#include <nlohmann/json.hpp>
#include <Simple-Web-Server/server_https.hpp>
@@ -74,6 +77,79 @@
void browseDirectory(const resp_https_t &response, const req_https_t &request);
void getLocale(const resp_https_t &response, const req_https_t &request);
void getCSRFToken(const resp_https_t &response, const req_https_t &request);
/**
* @brief Check whether a detected driver version satisfies a minimum version.
*
* Empty minimum versions accept any detected version. Non-empty minimum versions
* require a fully numeric dotted version string.
*
* @param version Detected driver version.
* @param minimum_version Minimum supported driver version, or empty for any version.
* @return True when the driver version is supported.
*/
bool is_driver_version_supported(std::string_view version, std::string_view minimum_version);
/**
* @brief Build a standard driver status response.
*
* @param installed Whether the driver was detected.
* @param version Detected driver version.
* @param minimum_version Minimum supported driver version, or empty for any version.
* @return Driver status JSON object.
*/
nlohmann::json build_driver_status(bool installed, const std::string &version, std::string_view minimum_version);
/**
* @brief Convert a libvirtualhid license result into a Web UI response.
*
* @param result License operation result.
* @return License status JSON object without the submitted license key.
*/
nlohmann::json build_virtualhid_license_status(const lvh::LicenseResult &result);
/**
* @brief Build libvirtualhid driver version and installation status.
*
* @return libvirtualhid driver status JSON.
*/
nlohmann::json get_virtualhid_driver_status();
/**
* @brief Build ViGEmBus fallback driver version and installation status.
*
* @return ViGEmBus fallback driver status JSON.
*/
nlohmann::json get_vigembus_driver_status();
void getVirtualInputStatus(const resp_https_t &response, const req_https_t &request);
void getVirtualInputLicense(const resp_https_t &response, const req_https_t &request);
void updateVirtualInputLicense(const resp_https_t &response, const req_https_t &request);
#ifdef SUNSHINE_TESTS
using virtual_input_license_status_provider_t = std::function<lvh::LicenseResult()>; ///< Test provider for current libvirtualhid license status.
/**
* @brief Replace the virtual-input license status provider for unit tests.
*
* @param status_provider Provider returning the license status for the response.
*/
void set_virtual_input_license_status_provider_for_testing(virtual_input_license_status_provider_t status_provider);
/**
* @brief Restore the production virtual-input license status provider after a unit test.
*/
void reset_virtual_input_license_status_provider_for_testing();
/**
* @brief Exercise request-local sensitive string clearing for unit tests.
*
* @param value Mutable sensitive string to overwrite and clear.
*/
void clear_sensitive_string_for_testing(std::string &value);
#endif
// Browse helper functions (also exposed for unit testing)
/**
▸
src/input.cpp
+370
−124
@@ -9,9 +9,15 @@
}
// standard includes
#include <algorithm>
#include <bitset>
#include <chrono>
#include <cmath>
#include <functional>
#include <list>
#include <memory>
#include <mutex>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
@@ -186,8 +192,12 @@
~gamepad_t() {
if (id >= 0) {
task_pool.push([id = this->id]() {
free_gamepad(platf_input, id);
});
if (task_pool.running()) {
task_pool.push([id = this->id]() {
::input::free_gamepad(platf_input, id);
});
} else {
::input::free_gamepad(platf_input, id);
}
}
}
@@ -265,6 +275,117 @@
};
/**
* @brief Hash string-like session identifiers without allocating temporary strings.
*/
struct transparent_string_hash_t {
using is_transparent = void; ///< Enable heterogeneous unordered-map lookup.
/**
* @brief Hash a string view.
*
* @param value Session identifier to hash.
* @return Hash value for the supplied identifier.
*/
std::size_t operator()(const std::string_view value) const noexcept {
return std::hash<std::string_view> {}(value);
}
};
using retained_input_map_t = std::unordered_map<
std::string,
std::shared_ptr<input_t>,
transparent_string_hash_t,
std::equal_to<>>; ///< Retained inputs keyed by paired-client identity.
/**
* @brief Synchronized storage for retained input sessions.
*/
struct retained_input_state_t {
std::mutex mutex; ///< Synchronizes retained session access across transport threads.
retained_input_map_t inputs; ///< Paused input sessions keyed by paired-client identity.
};
/**
* @brief Access process-wide retained input session storage.
*
* @return Mutable retained input session state.
*/
retained_input_state_t &retained_input_state() {
static retained_input_state_t state;
return state;
}
/**
* @brief Execute lifecycle work on the input task thread when it is running.
*
* @tparam Function Callable type.
* @param function Lifecycle operation to execute.
*/
template<typename Function>
void dispatch_input_task(Function &&function) {
if (task_pool.running()) {
task_pool.push(std::forward<Function>(function));
} else {
std::forward<Function>(function)();
}
}
/**
* @brief Destroy the virtual gamepads owned by retained input sessions.
*
* @param input Retained input session whose gamepads should be destroyed.
*/
void destroy_gamepads(const std::shared_ptr<input_t> &input) {
for (auto &gamepad : input->gamepads) {
if (gamepad.back_timeout_id) {
task_pool.cancel(gamepad.back_timeout_id);
gamepad.back_timeout_id = nullptr;
}
if (gamepad.id >= 0) {
::input::free_gamepad(platf_input, gamepad.id);
gamepad.id = -1;
}
gamepad.gamepad_state = {};
gamepad.back_button_state = button_state_e::NONE;
}
}
/**
* @brief Destroy the virtual gamepads owned by retained input sessions.
*
* @param inputs Retained input sessions whose gamepads should be destroyed.
*/
void destroy_gamepads(const retained_input_map_t &inputs) {
for (const auto &[session_id, input] : inputs) {
static_cast<void>(session_id);
destroy_gamepads(input);
}
}
/**
* @brief Rebind retained input state to a resumed stream mailbox.
*
* @param input Retained input state.
* @param mail Mailbox for the resumed stream connection.
*/
void rebind_input(const std::shared_ptr<input_t> &input, const safe::mail_t &mail) {
input->touch_port_event = mail->event<input::touch_port_t>(mail::touch_port);
input->feedback_queue = mail->queue<platf::gamepad_feedback_msg_t>(mail::gamepad_feedback);
for (int client_index = 0; client_index < input->gamepads.size(); ++client_index) {
auto &gamepad = input->gamepads[client_index];
if (gamepad.id < 0) {
continue;
}
if (platf::rebind_gamepad(platf_input, {gamepad.id, static_cast<std::uint8_t>(client_index)}, input->feedback_queue) != 0) {
free_id(gamepadMask, gamepad.id);
gamepad.id = -1;
}
}
}
/**
* @brief Apply shortcut based on VKEY
* @param keyCode The VKEY code
* @return 0 if no shortcut applied, > 0 if shortcut applied.
@@ -610,8 +731,8 @@
This final operation is a bit weird and has been brought about with lots of trial and error. A better
way to do this may exist.
Basically, this is what makes the touchscreen map to the coordinates inputtino expects properly.
Since inputtino's dimensions are now logical (because scaling breaks everything otherwise), using the previous
Basically, this is what makes the touchscreen map to the logical virtual input coordinates properly.
Since the virtual input dimensions are logical (because scaling breaks everything otherwise), using the previous
x and y coordinates would be incorrect when screens are scaled, because the touch port is smaller (or larger)
by a factor (that factor is touch_port.scalar_tpcoords), and that factor must be used to account for that difference
when moving the cursor. Otherwise, it will move either slower or faster than your finger proportionally to
@@ -1037,7 +1158,7 @@
*
* @param packet Protocol packet being processed.
*/
void passthrough(PNV_UNICODE_PACKET packet) {
void passthrough(const NV_UNICODE_PACKET *packet) {
if (!config::input.keyboard) {
return;
}
@@ -1047,6 +1168,40 @@
}
/**
* @brief Allocate a virtual gamepad for a client-relative controller slot.
*
* @param input Stream input state.
* @param client_index Client-relative controller index.
* @param arrival Client-reported controller metadata.
* @return Assigned global gamepad slot, or -1 when allocation fails.
*/
int alloc_gamepad(std::shared_ptr<input_t> &input, int client_index, const platf::gamepad_arrival_t &arrival) {
if (client_index < 0 || client_index >= input->gamepads.size()) {
BOOST_LOG(warning) << "ControllerNumber out of range ["sv << client_index << ']';
return -1;
}
auto &gamepad = input->gamepads[client_index];
if (gamepad.id >= 0) {
BOOST_LOG(warning) << "ControllerNumber already allocated ["sv << client_index << ']';
return gamepad.id;
}
const auto id = alloc_id(gamepadMask);
if (id < 0) {
return -1;
}
if (platf::alloc_gamepad(platf_input, {id, static_cast<std::uint8_t>(client_index)}, arrival, input->feedback_queue)) {
free_id(gamepadMask, id);
return -1;
}
gamepad.id = id;
return id;
}
/**
* @brief Called to pass a controller arrival message to the platform backend.
* @param input The input context pointer.
* @param packet The controller arrival packet.
@@ -1056,34 +1211,12 @@
return;
}
if (packet->controllerNumber < 0 || packet->controllerNumber >= input->gamepads.size()) {
BOOST_LOG(warning) << "ControllerNumber out of range ["sv << packet->controllerNumber << ']';
return;
}
if (input->gamepads[packet->controllerNumber].id >= 0) {
BOOST_LOG(warning) << "ControllerNumber already allocated ["sv << packet->controllerNumber << ']';
return;
}
platf::gamepad_arrival_t arrival {
packet->type,
util::endian::little(packet->capabilities),
util::endian::little(packet->supportedButtonFlags),
};
static_cast<void>(alloc_gamepad(input, packet->controllerNumber, arrival));
auto id = alloc_id(gamepadMask);
if (id < 0) {
return;
}
// Allocate a new gamepad
if (platf::alloc_gamepad(platf_input, {id, packet->controllerNumber}, arrival, input->feedback_queue)) {
free_id(gamepadMask, id);
return;
}
input->gamepads[packet->controllerNumber].id = id;
}
/**
@@ -1112,54 +1245,81 @@
}
/**
* @brief Shared normalized data prepared for a touch or pen event.
*/
struct absolute_pointer_data_t {
platf::touch_port_t touch_port; ///< Monitor-local touch port.
std::pair<float, float> coords; ///< Normalized monitor-local coordinates.
std::uint16_t rotation; ///< Normalized rotation in degrees.
std::pair<float, float> contact_area; ///< Scaled major and minor contact axes.
};
/**
* @brief Normalize the fields shared by touch and pen packets.
* @brief Called to pass a touch message to the platform backend.
* @param input The input context pointer.
* @param packet The touch packet.
*
* @tparam Packet Pointer type for a Moonlight touch or pen packet.
* @param input Input context that supplies the current touch-port metadata.
* @param packet Touch or pen packet to normalize.
* @return Normalized pointer data, or `std::nullopt` when input is disabled or dimensions are invalid.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_TOUCH_PACKET packet) {
template<typename Packet>
std::optional<absolute_pointer_data_t> prepare_absolute_pointer_data(std::shared_ptr<input_t> &input, Packet packet) {
if (!config::input.mouse) {
return std::nullopt;
return;
}
auto coords = client_to_touchport(
input,
// Convert the client normalized coordinates to touchport coordinates
auto coords = client_to_touchport(input, {from_clamped_netfloat(packet->x, 0.0f, 1.0f) * 65535.f, from_clamped_netfloat(packet->y, 0.0f, 1.0f) * 65535.f}, {65535.f, 65535.f});
{from_clamped_netfloat(packet->x, 0.0f, 1.0f) * 65535.f, from_clamped_netfloat(packet->y, 0.0f, 1.0f) * 65535.f},
{65535.f, 65535.f}
);
if (!coords) {
return;
return std::nullopt;
}
auto touch_port = monitor_touch_port(input->touch_port, *coords);
auto &touch_port = input->touch_port;
auto abs_port = monitor_touch_port(touch_port, *coords);
if (!abs_port) {
return;
if (!touch_port) {
return std::nullopt;
}
// Normalize rotation value to 0-359 degree range
auto rotation = util::endian::little(packet->rotation);
if (rotation != LI_ROT_UNKNOWN) {
rotation %= 360;
}
// Normalize the contact area based on the touchport
auto contact_area = scale_client_contact_area(
const auto contact_area = scale_client_contact_area(
{from_clamped_netfloat(packet->contactAreaMajor, 0.0f, 1.0f) * 65535.f,
from_clamped_netfloat(packet->contactAreaMinor, 0.0f, 1.0f) * 65535.f},
rotation,
{abs_port->width / 65535.f, abs_port->height / 65535.f}
{touch_port->width / 65535.f, touch_port->height / 65535.f}
);
return absolute_pointer_data_t {*touch_port, *coords, rotation, contact_area};
}
/**
* @brief Called to pass a touch message to the platform backend.
* @param input The input context pointer.
* @param packet The touch packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_TOUCH_PACKET packet) {
const auto pointer_data = prepare_absolute_pointer_data(input, packet);
if (!pointer_data) {
return;
}
platf::touch_input_t touch {
packet->eventType,
rotation,
pointer_data->rotation,
util::endian::little(packet->pointerId),
coords->first,
coords->second,
pointer_data->coords.first,
pointer_data->coords.second,
from_clamped_netfloat(packet->pressureOrDistance, 0.0f, 1.0f),
contact_area.first,
contact_area.second,
pointer_data->contact_area.first,
pointer_data->contact_area.second,
};
platf::touch_update(input->client_context.get(), pointer_data->touch_port, touch);
platf::touch_update(input->client_context.get(), *abs_port, touch);
}
/**
@@ -1168,51 +1328,25 @@
* @param packet The pen packet.
*/
void passthrough(std::shared_ptr<input_t> &input, PSS_PEN_PACKET packet) {
if (!config::input.mouse) {
return;
}
// Convert the client normalized coordinates to touchport coordinates
auto coords = client_to_touchport(input, {from_clamped_netfloat(packet->x, 0.0f, 1.0f) * 65535.f, from_clamped_netfloat(packet->y, 0.0f, 1.0f) * 65535.f}, {65535.f, 65535.f});
if (!coords) {
return;
}
auto &touch_port = input->touch_port;
auto abs_port = monitor_touch_port(touch_port, *coords);
if (!abs_port) {
const auto pointer_data = prepare_absolute_pointer_data(input, packet);
if (!pointer_data) {
return;
}
// Normalize rotation value to 0-359 degree range
auto rotation = util::endian::little(packet->rotation);
if (rotation != LI_ROT_UNKNOWN) {
rotation %= 360;
}
// Normalize the contact area based on the touchport
auto contact_area = scale_client_contact_area(
{from_clamped_netfloat(packet->contactAreaMajor, 0.0f, 1.0f) * 65535.f,
from_clamped_netfloat(packet->contactAreaMinor, 0.0f, 1.0f) * 65535.f},
rotation,
{abs_port->width / 65535.f, abs_port->height / 65535.f}
);
platf::pen_input_t pen {
packet->eventType,
packet->toolType,
packet->penButtons,
packet->tilt,
rotation,
coords->first,
coords->second,
pointer_data->rotation,
pointer_data->coords.first,
pointer_data->coords.second,
from_clamped_netfloat(packet->pressureOrDistance, 0.0f, 1.0f),
pointer_data->contact_area.first,
pointer_data->contact_area.second,
contact_area.first,
contact_area.second,
};
platf::pen_update(input->client_context.get(), *abs_port, pen);
platf::pen_update(input->client_context.get(), pointer_data->touch_port, pen);
}
/**
@@ -1332,20 +1466,12 @@
// If this is an event for a new gamepad, create the gamepad now. Ideally, the client would
// send a controller arrival instead of this but it's still supported for legacy clients.
if ((packet->activeGamepadMask & (1 << packet->controllerNumber)) && gamepad.id < 0) {
auto id = alloc_id(gamepadMask);
if (id < 0) {
return;
}
if (platf::alloc_gamepad(platf_input, {id, (uint8_t) packet->controllerNumber}, {}, input->feedback_queue)) {
free_id(gamepadMask, id);
if (alloc_gamepad(input, packet->controllerNumber, {}) < 0) {
return;
}
gamepad.id = id;
} else if (!(packet->activeGamepadMask & (1 << packet->controllerNumber)) && gamepad.id >= 0) {
// If this is the final event for a gamepad being removed, free the gamepad and return.
free_gamepad(platf_input, gamepad.id);
::input::free_gamepad(platf_input, gamepad.id);
gamepad.id = -1;
return;
}
@@ -1782,7 +1908,7 @@
passthrough(input, (PNV_KEYBOARD_PACKET) payload);
break;
case UTF8_TEXT_EVENT_MAGIC:
passthrough((PNV_UNICODE_PACKET) payload);
passthrough(static_cast<const NV_UNICODE_PACKET *>(static_cast<const void *>(payload)));
break;
case MULTI_CONTROLLER_MAGIC_GEN5:
passthrough(input, (PNV_MULTI_CONTROLLER_PACKET) payload);
@@ -1822,6 +1948,65 @@
}
/**
* @brief Release every pressed mouse button tracked by Sunshine.
*/
void reset_mouse_buttons() {
for (int button = 0; button < mouse_press.size(); ++button) {
if (mouse_press[button]) {
platf::button_mouse(platf_input, button, true);
mouse_press[button] = false;
}
}
}
/**
* @brief Release every pressed keyboard key tracked by Sunshine.
*/
void reset_keyboard_keys() {
for (auto &[key, pressed] : key_press) {
if (pressed) {
platf::keyboard_update(platf_input, vk_from_kpid(key) & 0x00FF, true, flags_from_kpid(key));
pressed = false;
}
}
}
/**
* @brief Neutralize retained gamepads without destroying their virtual devices.
*
* @param input Retained stream input state to neutralize.
*/
void reset_gamepads(const std::shared_ptr<input_t> &input) {
for (int client_index = 0; client_index < input->gamepads.size(); ++client_index) {
auto &gamepad = input->gamepads[client_index];
if (gamepad.back_timeout_id) {
task_pool.cancel(gamepad.back_timeout_id);
gamepad.back_timeout_id = nullptr;
}
if (gamepad.id >= 0) {
platf::gamepad_update(platf_input, gamepad.id, {});
platf::gamepad_touch(
platf_input,
{{gamepad.id, static_cast<std::uint8_t>(client_index)}, LI_TOUCH_EVENT_CANCEL_ALL, 0, 0.0F, 0.0F, 0.0F}
);
}
gamepad.gamepad_state = {};
gamepad.back_button_state = button_state_e::NONE;
}
}
/**
* @brief Reset all pressed input state for a disconnected stream.
*
* @param input Retained stream input state to reset.
*/
void reset_input_state(const std::shared_ptr<input_t> &input) {
reset_mouse_buttons();
reset_keyboard_keys();
reset_gamepads(input);
}
/**
* @brief Reset the object to its initial empty state.
*/
void reset(std::shared_ptr<input_t> &input) {
@@ -1829,21 +2014,39 @@
task_pool.cancel(input->mouse_left_button_timeout);
// Ensure input is synchronous, by using the task_pool
task_pool.push([]() {
for (int x = 0; x < mouse_press.size(); ++x) {
if (mouse_press[x]) {
platf::button_mouse(platf_input, x, true);
mouse_press[x] = false;
}
task_pool.push(reset_input_state, input);
}
}
void terminate_gamepads() {
retained_input_map_t inputs;
{
auto &state = retained_input_state();
std::lock_guard lock {state.mutex};
state.inputs.swap(inputs);
}
for (auto &kp : key_press) {
if (!kp.second) {
// already released
continue;
}
platf::keyboard_update(platf_input, vk_from_kpid(kp.first) & 0x00FF, true, flags_from_kpid(kp.first));
key_press[kp.first] = false;
if (!inputs.empty()) {
dispatch_input_task([inputs = std::move(inputs)]() {
destroy_gamepads(inputs);
});
}
}
void terminate_gamepads(const std::string_view session_id) {
std::shared_ptr<input_t> input;
{
auto &state = retained_input_state();
std::lock_guard lock {state.mutex};
const auto iter = state.inputs.find(session_id);
if (iter == state.inputs.end()) {
return;
}
input = std::move(iter->second);
state.inputs.erase(iter);
}
dispatch_input_task([input = std::move(input)]() {
destroy_gamepads(input);
});
}
@@ -1857,6 +2060,13 @@
* @brief Destroy the input subsystem deinitializer.
*/
~deinit_t() override {
retained_input_map_t inputs;
{
auto &state = retained_input_state();
std::lock_guard lock {state.mutex};
state.inputs.swap(inputs);
}
destroy_gamepads(inputs);
platf_input.reset();
}
};
@@ -1874,24 +2084,39 @@
* @brief Probe connected gamepads and update input capability state.
*/
bool probe_gamepads() {
const auto &gamepads = platf::supported_gamepads(std::addressof(platf_input));
auto input = static_cast<platf::input_t *>(platf_input.get());
const auto gamepads = platf::supported_gamepads(input);
for (auto &gamepad : gamepads) {
if (gamepad.is_enabled && gamepad.name != "auto") {
return false;
}
}
return true;
return std::ranges::none_of(gamepads, [](const auto &gamepad) {
return gamepad.is_enabled && gamepad.name != "auto";
});
}
/**
* @brief Allocate and initialize platform input state for a stream.
*/
std::shared_ptr<input_t> alloc(safe::mail_t mail, std::string session_id) {
std::shared_ptr<input_t> input;
bool resumed = false;
{
auto &state = retained_input_state();
std::lock_guard lock {state.mutex};
const auto iter = state.inputs.find(session_id);
if (iter != state.inputs.end()) {
input = iter->second;
resumed = true;
} else {
input = std::make_shared<input_t>(
mail->event<input::touch_port_t>(mail::touch_port),
mail->queue<platf::gamepad_feedback_msg_t>(mail::gamepad_feedback)
);
state.inputs.try_emplace(std::move(session_id), input);
}
}
if (resumed) {
std::shared_ptr<input_t> alloc(safe::mail_t mail) {
auto input = std::make_shared<input_t>(
mail->event<input::touch_port_t>(mail::touch_port),
mail->queue<platf::gamepad_feedback_msg_t>(mail::gamepad_feedback)
dispatch_input_task([input, mail = std::move(mail)]() {
rebind_input(input, mail);
});
}
);
// Workaround to ensure new frames will be captured when a client connects
task_pool.pushDelayed([]() {
@@ -1902,4 +2127,25 @@
return input;
}
#ifdef SUNSHINE_TESTS
namespace testing {
void set_platform_input(platf::input_t input) {
terminate_gamepads();
platf_input = std::move(input);
gamepadMask.reset();
}
int alloc_gamepad(std::shared_ptr<input_t> &input, std::uint8_t client_index, const platf::gamepad_arrival_t &metadata) {
return ::input::alloc_gamepad(input, client_index, metadata);
}
int gamepad_id(const std::shared_ptr<input_t> &input, std::uint8_t client_index) {
if (!input || client_index >= input->gamepads.size()) {
return -1;
}
return input->gamepads[client_index].id;
}
} // namespace testing
#endif
} // namespace input
▸
src/input.h
+48
−1
@@ -6,6 +6,7 @@
// standard includes
#include <functional>
#include <string_view>
// local includes
#include "platform/common.h"
@@ -28,6 +29,21 @@
void reset(std::shared_ptr<input_t> &input);
/**
* @brief Destroy every retained virtual gamepad session.
*
* Retained gamepads survive a paused transport connection so they can be reused on resume. Call this when the
* streamed application or all streaming sessions are explicitly terminated.
*/
void terminate_gamepads();
/**
* @brief Destroy virtual gamepads retained for one paired client.
*
* @param session_id Stable paired-client identity used by alloc().
*/
void terminate_gamepads(std::string_view session_id);
/**
* @brief Queue a raw input message for platform passthrough.
*/
void passthrough(std::shared_ptr<input_t> &input, std::vector<std::uint8_t> &&input_data);
@@ -50,9 +66,40 @@
* @brief Allocate and initialize platform input state for a stream.
*
* @param mail Mailbox used to exchange messages with worker threads.
* @param session_id Stable paired-client identity shared by launch and resume connections.
* @return Shared input state bound to the stream mailbox.
*/
std::shared_ptr<input_t> alloc(safe::mail_t mail, std::string session_id);
#ifdef SUNSHINE_TESTS
namespace testing {
/**
* @brief Replace the global platform input backend for a unit test.
*
* @param input Test-owned platform input backend.
std::shared_ptr<input_t> alloc(safe::mail_t mail);
*/
void set_platform_input(platf::input_t input);
/**
* @brief Allocate a gamepad directly in retained input state for a unit test.
*
* @param input Retained input state.
* @param client_index Client-relative controller index.
* @param metadata Client-reported controller metadata.
* @return Assigned global gamepad slot, or -1 on failure.
*/
int alloc_gamepad(std::shared_ptr<input_t> &input, std::uint8_t client_index, const platf::gamepad_arrival_t &metadata);
/**
* @brief Return the global gamepad slot stored for a test controller.
*
* @param input Retained input state.
* @param client_index Client-relative controller index.
* @return Assigned global gamepad slot, or -1 when unallocated.
*/
int gamepad_id(const std::shared_ptr<input_t> &input, std::uint8_t client_index);
} // namespace testing
#endif
/**
* @brief Touchscreen coordinate bounds used to scale absolute input.
▸
src/main.cpp
+1
−0
@@ -452,6 +452,7 @@
if (tray_is_enabled && config::sunshine.system_tray) {
BOOST_LOG(info) << "Starting system tray"sv;
#ifdef _WIN32
system_tray::prepare_tray_virtualhid_license();
// TODO: Windows has a weird bug where when running as a service and on the first Windows boot,
// the tray icon would not appear even though Sunshine is running correctly otherwise.
// Restarting the service would allow the icon to appear normally.
▸
src/platform/common.h
+33
−11
@@ -9,6 +9,7 @@
#include <filesystem>
#include <functional>
#include <mutex>
#include <optional>
#include <string>
// lib includes
@@ -267,14 +268,14 @@
/**
* @brief Moonlight speaker order for stereo audio.
*/
constexpr std::array<std::uint8_t, 2> map_stereo {
constexpr std::uint8_t map_stereo[] {
FRONT_LEFT,
FRONT_RIGHT
};
/**
* @brief Moonlight speaker order for 5.1 surround audio.
*/
constexpr std::uint8_t map_surround51[] {
constexpr std::array<std::uint8_t, 6> map_surround51 {
FRONT_LEFT,
FRONT_RIGHT,
FRONT_CENTER,
@@ -285,7 +286,7 @@
/**
* @brief Moonlight speaker order for 7.1 surround audio.
*/
constexpr std::array<std::uint8_t, 8> map_surround71 {
constexpr std::uint8_t map_surround71[] {
FRONT_LEFT,
FRONT_RIGHT,
FRONT_CENTER,
@@ -838,15 +839,20 @@
};
/**
* @brief Platform-specific input backend context.
*/
struct input_raw_t;
/**
* @brief Release a platform input backend created by input().
*
* @param input Platform input backend to release.
* @param p Pointer passed to the deleter or conversion helper.
*/
void freeInput(void *);
void freeInput(input_raw_t *input);
/**
* @brief Owning pointer for a platform input backend.
*/
using input_t = util::safe_ptr<input_raw_t, &freeInput>;
using input_t = util::safe_ptr<void, freeInput>;
std::filesystem::path appdata();
@@ -1102,14 +1108,21 @@
*/
input_t input();
/**
* @brief Get the current mouse position for platform input tests.
*
* @brief Get the current mouse position on screen
* @param input The input_t instance to use.
* @return Screen coordinates of the mouse.
* @return Screen coordinates of the mouse, or `std::nullopt` when the platform cannot observe the cursor.
*
* @note This helper exists only so tests can observe virtual mouse movement. Production input paths should submit
* mouse events through `move_mouse()` or `abs_mouse()` instead of reading the host cursor location.
*
* @examples
* if (auto location = get_mouse_loc(input)) {
* auto [x, y] = get_mouse_loc(input);
* auto [x, y] = *location;
* }
* @examples_end
*/
util::point_t get_mouse_loc(input_t &input);
std::optional<util::point_t> get_mouse_loc(input_t &input);
/**
* @brief Move mouse using the backend coordinate system.
*
@@ -1166,7 +1179,7 @@
* @param utf8 UTF-8 text submitted by the client.
* @param size Number of bytes or elements requested.
*/
void unicode(input_t &input, char *utf8, int size);
void unicode(input_t &input, const char *utf8, int size);
/**
* @brief Per-client input context allocated by a platform backend.
@@ -1226,6 +1239,15 @@
* @return 0 on success.
*/
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue);
/**
* @brief Rebind an existing virtual gamepad to a resumed client session.
*
* @param input Platform input backend that owns the virtual gamepad.
* @param id Global and client-relative identifiers for the resumed controller.
* @param feedback_queue Queue used to return gamepad feedback to the resumed client.
* @return 0 when the gamepad exists and was rebound; otherwise -1.
*/
int rebind_gamepad(input_t &input, const gamepad_id_t &id, feedback_queue_t feedback_queue);
/**
* @brief Release gamepad resources.
*
▸
src/platform/linux/audio.cpp
+3
−3
@@ -481,7 +481,7 @@
sink.host = sink_name;
if (index.stereo == PA_INVALID_INDEX) {
index.stereo = load_null(stereo, speaker::map_stereo, sizeof(speaker::map_stereo));
index.stereo = load_null(stereo, speaker::map_stereo.data(), static_cast<int>(speaker::map_stereo.size()));
if (index.stereo == PA_INVALID_INDEX) {
BOOST_LOG(warning) << "Couldn't create virtual sink for stereo: "sv << pa_strerror(pa_context_errno(ctx.get()));
} else {
@@ -490,7 +490,7 @@
}
if (index.surround51 == PA_INVALID_INDEX) {
index.surround51 = load_null(surround51, speaker::map_surround51, sizeof(speaker::map_surround51));
index.surround51 = load_null(surround51, speaker::map_surround51.data(), static_cast<int>(speaker::map_surround51.size()));
if (index.surround51 == PA_INVALID_INDEX) {
BOOST_LOG(warning) << "Couldn't create virtual sink for surround-51: "sv << pa_strerror(pa_context_errno(ctx.get()));
} else {
@@ -499,7 +499,7 @@
}
if (index.surround71 == PA_INVALID_INDEX) {
index.surround71 = load_null(surround71, speaker::map_surround71.data(), static_cast<int>(speaker::map_surround71.size()));
index.surround71 = load_null(surround71, speaker::map_surround71, sizeof(speaker::map_surround71));
if (index.surround71 == PA_INVALID_INDEX) {
BOOST_LOG(warning) << "Couldn't create virtual sink for surround-71: "sv << pa_strerror(pa_context_errno(ctx.get()));
} else {
▸
src/platform/linux/input/inputtino.cpp
+0
−163
@@ -1,163 +1,0 @@
/**
* @file src/platform/linux/input/inputtino.cpp
* @brief Definitions for the inputtino Linux input handling.
*/
// lib includes
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "inputtino_gamepad.h"
#include "inputtino_keyboard.h"
#include "inputtino_mouse.h"
#include "inputtino_pen.h"
#include "inputtino_touch.h"
#include "src/config.h"
#include "src/platform/common.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf {
/**
* @brief Create the platform input backend for a stream.
*/
input_t input() {
return {new input_raw_t()};
}
std::unique_ptr<client_input_t> allocate_client_input_context(input_t &input) {
return std::make_unique<client_input_raw_t>(input);
}
/**
* @brief Release a platform input backend created by input().
*/
void freeInput(void *p) {
auto *input = (input_raw_t *) p;
delete input;
}
/**
* @brief Move mouse using the backend coordinate system.
*/
void move_mouse(input_t &input, int deltaX, int deltaY) {
auto raw = (input_raw_t *) input.get();
platf::mouse::move(raw, deltaX, deltaY);
}
/**
* @brief Move the pointer to an absolute client-provided touch coordinate.
*/
void abs_mouse(input_t &input, const touch_port_t &touch_port, float x, float y) {
auto raw = (input_raw_t *) input.get();
platf::mouse::move_abs(raw, touch_port, x, y);
}
/**
* @brief Press or release a virtual mouse button.
*/
void button_mouse(input_t &input, int button, bool release) {
auto raw = (input_raw_t *) input.get();
platf::mouse::button(raw, button, release);
}
/**
* @brief Apply a vertical scroll event to the virtual mouse.
*/
void scroll(input_t &input, int high_res_distance) {
auto raw = (input_raw_t *) input.get();
platf::mouse::scroll(raw, high_res_distance);
}
/**
* @brief Apply a horizontal scroll event to the virtual mouse.
*/
void hscroll(input_t &input, int high_res_distance) {
auto raw = (input_raw_t *) input.get();
platf::mouse::hscroll(raw, high_res_distance);
}
/**
* @brief Press or release a virtual keyboard key.
*/
void keyboard_update(input_t &input, uint16_t modcode, bool release, uint8_t flags) {
auto raw = (input_raw_t *) input.get();
platf::keyboard::update(raw, modcode, release, flags);
}
/**
* @brief Submit UTF-8 text input to the keyboard backend.
*/
void unicode(input_t &input, char *utf8, int size) {
auto raw = (input_raw_t *) input.get();
platf::keyboard::unicode(raw, utf8, size);
}
void touch_update(client_input_t *input, const touch_port_t &touch_port, const touch_input_t &touch) {
auto raw = (client_input_raw_t *) input;
platf::touch::update(raw, touch_port, touch);
}
void pen_update(client_input_t *input, const touch_port_t &touch_port, const pen_input_t &pen) {
auto raw = (client_input_raw_t *) input;
platf::pen::update(raw, touch_port, pen);
}
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
auto raw = (input_raw_t *) input.get();
return platf::gamepad::alloc(raw, id, metadata, feedback_queue);
}
/**
* @brief Release gamepad resources.
*/
void free_gamepad(input_t &input, int nr) {
auto raw = (input_raw_t *) input.get();
platf::gamepad::free(raw, nr);
}
void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
auto raw = (input_raw_t *) input.get();
platf::gamepad::update(raw, nr, gamepad_state);
}
void gamepad_touch(input_t &input, const gamepad_touch_t &touch) {
auto raw = (input_raw_t *) input.get();
platf::gamepad::touch(raw, touch);
}
void gamepad_motion(input_t &input, const gamepad_motion_t &motion) {
auto raw = (input_raw_t *) input.get();
platf::gamepad::motion(raw, motion);
}
void gamepad_battery(input_t &input, const gamepad_battery_t &battery) {
auto raw = (input_raw_t *) input.get();
platf::gamepad::battery(raw, battery);
}
platform_caps::caps_t get_capabilities() {
platform_caps::caps_t caps = 0;
// TODO: if has_uinput
caps |= platform_caps::pen_touch;
// We support controller touchpad input only when emulating the PS5 controller
if (config::input.gamepad == "ds5"sv || config::input.gamepad == "auto"sv) {
caps |= platform_caps::controller_touch;
}
return caps;
}
util::point_t get_mouse_loc(input_t &input) {
auto raw = (input_raw_t *) input.get();
return platf::mouse::get_location(raw);
}
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
return platf::gamepad::supported_gamepads(input);
}
} // namespace platf
▸
src/platform/linux/input/inputtino_common.h
+0
−147
@@ -1,147 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_common.h
* @brief Declarations for inputtino common input handling.
*/
#pragma once
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/platform/linux/input/inputtino_seat.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf {
/**
* @brief Append the target seat name to an inputtino device name when needed.
*
* @param base_name Base uinput device name.
* @return Device name scoped to the target seat.
*/
inline std::string inputtino_name_for_seat(std::string_view base_name) {
auto seat_id = inputtino_seat::get_target_seat();
if (seat_id.empty() || seat_id == "seat0") {
return std::string(base_name);
}
std::string name;
name.reserve(base_name.size() + seat_id.size() + 3);
name.append(base_name);
name.append(" (");
name.append(seat_id);
name.push_back(')');
return name;
}
/**
* @brief Variant of inputtino virtual gamepad implementations Sunshine can create.
*/
using joypads_t = std::variant<inputtino::XboxOneJoypad, inputtino::SwitchJoypad, inputtino::PS5Joypad>;
/**
* @brief inputtino joypad collection and its ownership state.
*/
struct joypad_state {
std::unique_ptr<joypads_t> joypad; ///< Active virtual gamepad object for one connected client slot.
gamepad_feedback_msg_t last_rumble; ///< Last rumble.
gamepad_feedback_msg_t last_rgb_led; ///< Last RGB led.
};
/**
* @brief Global inputtino device handles shared by clients.
*/
struct input_raw_t {
input_raw_t():
mouse(inputtino::Mouse::create({
.name = inputtino_name_for_seat("Mouse passthrough"sv),
.vendor_id = 0xBEEF,
.product_id = 0xDEAD,
.version = 0x111,
})),
keyboard(inputtino::Keyboard::create({
.name = inputtino_name_for_seat("Keyboard passthrough"sv),
.vendor_id = 0xBEEF,
.product_id = 0xDEAD,
.version = 0x111,
})),
gamepads(MAX_GAMEPADS) {
if (!mouse) {
BOOST_LOG(warning) << "Unable to create virtual mouse: " << mouse.getErrorMessage();
}
if (!keyboard) {
BOOST_LOG(warning) << "Unable to create virtual keyboard: " << keyboard.getErrorMessage();
}
}
~input_raw_t() = default;
// All devices are wrapped in Result because it might be that we aren't able to create them (ex: udev permission denied)
inputtino::Result<inputtino::Mouse> mouse; ///< Shared inputtino virtual mouse device.
inputtino::Result<inputtino::Keyboard> keyboard; ///< inputtino virtual keyboard device.
/**
* A list of gamepads that are currently connected.
* The pointer is shared because that state will be shared with background threads that deal with rumble and LED
*/
std::vector<std::shared_ptr<joypad_state>> gamepads;
};
/**
* @brief Per-client inputtino devices for touch and pen input.
*/
struct client_input_raw_t: public client_input_t {
/**
* @brief Create per-client inputtino devices for touch and pen input.
*
* @param input Platform input backend that receives the event.
*/
client_input_raw_t(input_t &input):
touch(inputtino::TouchScreen::create({
.name = inputtino_name_for_seat("Touch passthrough"sv),
.vendor_id = 0xBEEF,
.product_id = 0xDEAD,
.version = 0x111,
})),
pen(inputtino::PenTablet::create({
.name = inputtino_name_for_seat("Pen passthrough"sv),
.vendor_id = 0xBEEF,
.product_id = 0xDEAD,
.version = 0x111,
})) {
global = (input_raw_t *) input.get();
if (!touch) {
BOOST_LOG(warning) << "Unable to create virtual touch screen: " << touch.getErrorMessage();
}
if (!pen) {
BOOST_LOG(warning) << "Unable to create virtual pen tablet: " << pen.getErrorMessage();
}
}
input_raw_t *global; ///< Shared inputtino device set owned by the global input context.
// Device state and handles for pen and touch input must be stored in the per-client
// input context, because each connected client may be sending their own independent
// pen/touch events. To maintain separation, we expose separate pen and touch devices
// for each client.
inputtino::Result<inputtino::TouchScreen> touch; ///< Per-client virtual touchscreen device.
inputtino::Result<inputtino::PenTablet> pen; ///< Per-client virtual pen tablet device.
};
/**
* @brief Convert degrees to radians for controller motion data.
*
* @param degree Angle in degrees to convert.
* @return Angle in radians.
*/
inline float deg2rad(float degree) {
return degree * (M_PI / 180.f);
}
} // namespace platf
▸
src/platform/linux/input/inputtino_gamepad.cpp
+0
−337
@@ -1,337 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_gamepad.cpp
* @brief Definitions for inputtino gamepad input handling.
*/
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "inputtino_gamepad.h"
#include "inputtino_seat.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf::gamepad {
/**
* @brief Enumerates supported gamepad status options.
*/
enum GamepadStatus {
UHID_NOT_AVAILABLE = 0, ///< UHID is not available
UINPUT_NOT_AVAILABLE, ///< UINPUT is not available
XINPUT_NOT_AVAILABLE, ///< XINPUT is not available
GAMEPAD_STATUS ///< Helper to indicate the number of status
};
/**
* @brief Create xbox one.
*
* @return Created xbox one object or status.
*/
auto create_xbox_one() {
return inputtino::XboxOneJoypad::create({.name = inputtino_name_for_seat("Sunshine X-Box One (virtual) pad"sv),
// https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c#L147
.vendor_id = 0x045E,
.product_id = 0x02EA,
.version = 0x0408});
}
/**
* @brief Create an inputtino Nintendo Switch Pro controller.
*
* @return Created switch object or status.
*/
auto create_switch() {
return inputtino::SwitchJoypad::create({.name = inputtino_name_for_seat("Sunshine Nintendo (virtual) pad"sv),
// https://github.com/torvalds/linux/blob/master/drivers/hid/hid-ids.h#L981
.vendor_id = 0x057e,
.product_id = 0x2009,
.version = 0x8111});
}
/**
* @brief Create an inputtino DualSense controller.
*
* @param globalIndex Global index.
* @return Created DS5 object or status.
*/
auto create_ds5(int globalIndex) {
std::string device_mac = ""; // Inputtino checks empty() to generate a random MAC
if (!config::input.ds5_inputtino_randomize_mac && globalIndex >= 0 && globalIndex <= 255) {
// Generate private virtual device MAC based on gamepad globalIndex between 0 (00) and 255 (ff)
device_mac = std::format("02:00:00:00:00:{:02x}", globalIndex);
}
return inputtino::PS5Joypad::create({.name = inputtino_name_for_seat("Sunshine PS5 (virtual) pad"sv), .vendor_id = 0x054C, .product_id = 0x0CE6, .version = 0x8111, .device_phys = device_mac, .device_uniq = device_mac});
}
/**
* @brief Allocate and initialize platform input state for a stream.
*/
int alloc(input_raw_t *raw, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
ControllerType selectedGamepadType;
if (config::input.gamepad == "xone"sv) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be Xbox One controller (manual selection)"sv;
selectedGamepadType = XboxOneWired;
} else if (config::input.gamepad == "ds5"sv) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be DualSense 5 controller (manual selection)"sv;
selectedGamepadType = DualSenseWired;
} else if (config::input.gamepad == "switch"sv) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be Nintendo Pro controller (manual selection)"sv;
selectedGamepadType = SwitchProWired;
} else if (metadata.type == LI_CTYPE_XBOX) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be Xbox One controller (auto-selected by client-reported type)"sv;
selectedGamepadType = XboxOneWired;
} else if (metadata.type == LI_CTYPE_PS) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be DualShock 5 controller (auto-selected by client-reported type)"sv;
selectedGamepadType = DualSenseWired;
} else if (metadata.type == LI_CTYPE_NINTENDO) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be Nintendo Pro controller (auto-selected by client-reported type)"sv;
selectedGamepadType = SwitchProWired;
} else if (config::input.motion_as_ds4 && (metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO))) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be DualShock 5 controller (auto-selected by motion sensor presence)"sv;
selectedGamepadType = DualSenseWired;
} else if (config::input.touchpad_as_ds4 && (metadata.capabilities & LI_CCAP_TOUCHPAD)) {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be DualShock 5 controller (auto-selected by touchpad presence)"sv;
selectedGamepadType = DualSenseWired;
} else {
BOOST_LOG(info) << "Gamepad " << id.globalIndex << " will be Xbox One controller (default)"sv;
selectedGamepadType = XboxOneWired;
}
if (selectedGamepadType == XboxOneWired || selectedGamepadType == SwitchProWired) {
if (metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO)) {
BOOST_LOG(warning) << "Gamepad " << id.globalIndex << " has motion sensors, but they are not usable when emulating a joypad different from DS5"sv;
}
if (metadata.capabilities & LI_CCAP_TOUCHPAD) {
BOOST_LOG(warning) << "Gamepad " << id.globalIndex << " has a touchpad, but it is not usable when emulating a joypad different from DS5"sv;
}
if (metadata.capabilities & LI_CCAP_RGB_LED) {
BOOST_LOG(warning) << "Gamepad " << id.globalIndex << " has an RGB LED, but it is not usable when emulating a joypad different from DS5"sv;
}
} else if (selectedGamepadType == DualSenseWired) {
if (!(metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO))) {
BOOST_LOG(warning) << "Gamepad " << id.globalIndex << " is emulating a DualShock 5 controller, but the client gamepad doesn't have motion sensors active"sv;
}
if (!(metadata.capabilities & LI_CCAP_TOUCHPAD)) {
BOOST_LOG(warning) << "Gamepad " << id.globalIndex << " is emulating a DualShock 5 controller, but the client gamepad doesn't have a touchpad"sv;
}
}
auto gamepad = std::make_shared<joypad_state>(joypad_state {});
auto on_rumble_fn = [feedback_queue, idx = id.clientRelativeIndex, gamepad](int low_freq, int high_freq) {
// Don't resend duplicate rumble data
if (gamepad->last_rumble.type == platf::gamepad_feedback_e::rumble && gamepad->last_rumble.data.rumble.lowfreq == low_freq && gamepad->last_rumble.data.rumble.highfreq == high_freq) {
return;
}
gamepad_feedback_msg_t msg = gamepad_feedback_msg_t::make_rumble(idx, low_freq, high_freq);
feedback_queue->raise(msg);
gamepad->last_rumble = msg;
};
switch (selectedGamepadType) {
case XboxOneWired:
{
auto xOne = create_xbox_one();
if (xOne) {
(*xOne).set_on_rumble(on_rumble_fn);
gamepad->joypad = std::make_unique<joypads_t>(std::move(*xOne));
raw->gamepads[id.globalIndex] = std::move(gamepad);
return 0;
} else {
BOOST_LOG(warning) << "Unable to create virtual Xbox One controller: " << xOne.getErrorMessage();
return -1;
}
}
case SwitchProWired:
{
auto switchPro = create_switch();
if (switchPro) {
(*switchPro).set_on_rumble(on_rumble_fn);
gamepad->joypad = std::make_unique<joypads_t>(std::move(*switchPro));
raw->gamepads[id.globalIndex] = std::move(gamepad);
return 0;
} else {
BOOST_LOG(warning) << "Unable to create virtual Switch Pro controller: " << switchPro.getErrorMessage();
return -1;
}
}
case DualSenseWired:
{
auto ds5 = create_ds5(id.globalIndex);
if (ds5) {
(*ds5).set_on_rumble(on_rumble_fn);
(*ds5).set_on_led([feedback_queue, idx = id.clientRelativeIndex, gamepad](int r, int g, int b) {
// Don't resend duplicate LED data
if (gamepad->last_rgb_led.type == platf::gamepad_feedback_e::set_rgb_led && gamepad->last_rgb_led.data.rgb_led.r == r && gamepad->last_rgb_led.data.rgb_led.g == g && gamepad->last_rgb_led.data.rgb_led.b == b) {
return;
}
auto msg = gamepad_feedback_msg_t::make_rgb_led(idx, r, g, b);
feedback_queue->raise(msg);
gamepad->last_rgb_led = msg;
});
(*ds5).set_on_trigger_effect([feedback_queue, idx = id.clientRelativeIndex](const inputtino::PS5Joypad::TriggerEffect &trigger_effect) {
feedback_queue->raise(gamepad_feedback_msg_t::make_adaptive_triggers(idx, trigger_effect.event_flags, trigger_effect.type_left, trigger_effect.type_right, trigger_effect.left, trigger_effect.right));
});
// Activate the motion sensors
feedback_queue->raise(gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_ACCEL, 100));
feedback_queue->raise(gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_GYRO, 100));
gamepad->joypad = std::make_unique<joypads_t>(std::move(*ds5));
raw->gamepads[id.globalIndex] = std::move(gamepad);
return 0;
} else {
BOOST_LOG(warning) << "Unable to create virtual DualShock 5 controller: " << ds5.getErrorMessage();
return -1;
}
}
}
return -1;
}
/**
* @brief Release backend resources for the indexed gamepad.
*/
void free(input_raw_t *raw, int nr) {
// This will call the destructor which in turn will stop the background threads for rumble and LED (and ultimately remove the joypad device)
raw->gamepads[nr]->joypad.reset();
raw->gamepads[nr].reset();
}
/**
* @brief Apply the supplied state update to the platform backend.
*/
void update(input_raw_t *raw, int nr, const gamepad_state_t &gamepad_state) {
auto gamepad = raw->gamepads[nr];
if (!gamepad) {
return;
}
std::visit([gamepad_state](inputtino::Joypad &gc) {
gc.set_pressed_buttons(gamepad_state.buttonFlags);
gc.set_stick(inputtino::Joypad::LS, gamepad_state.lsX, gamepad_state.lsY);
gc.set_stick(inputtino::Joypad::RS, gamepad_state.rsX, gamepad_state.rsY);
gc.set_triggers(gamepad_state.lt, gamepad_state.rt);
},
*gamepad->joypad);
}
/**
* @brief Apply controller touchpad data to the backend device.
*/
void touch(input_raw_t *raw, const gamepad_touch_t &touch) {
auto gamepad = raw->gamepads[touch.id.globalIndex];
if (!gamepad) {
return;
}
// Only the PS5 controller supports touch input
if (std::holds_alternative<inputtino::PS5Joypad>(*gamepad->joypad)) {
if (touch.pressure > 0.5) {
std::get<inputtino::PS5Joypad>(*gamepad->joypad).place_finger(touch.pointerId, touch.x * inputtino::PS5Joypad::touchpad_width, touch.y * inputtino::PS5Joypad::touchpad_height);
} else {
std::get<inputtino::PS5Joypad>(*gamepad->joypad).release_finger(touch.pointerId);
}
}
}
/**
* @brief Apply controller motion sensor data to the backend device.
*/
void motion(input_raw_t *raw, const gamepad_motion_t &motion) {
auto gamepad = raw->gamepads[motion.id.globalIndex];
if (!gamepad) {
return;
}
// Only the PS5 controller supports motion
if (std::holds_alternative<inputtino::PS5Joypad>(*gamepad->joypad)) {
switch (motion.motionType) {
case LI_MOTION_TYPE_ACCEL:
std::get<inputtino::PS5Joypad>(*gamepad->joypad).set_motion(inputtino::PS5Joypad::ACCELERATION, motion.x, motion.y, motion.z);
break;
case LI_MOTION_TYPE_GYRO:
std::get<inputtino::PS5Joypad>(*gamepad->joypad).set_motion(inputtino::PS5Joypad::GYROSCOPE, deg2rad(motion.x), deg2rad(motion.y), deg2rad(motion.z));
break;
}
}
}
/**
* @brief Apply controller battery status to the backend device.
*/
void battery(input_raw_t *raw, const gamepad_battery_t &battery) {
auto gamepad = raw->gamepads[battery.id.globalIndex];
if (!gamepad) {
return;
}
// Only the PS5 controller supports battery reports
if (std::holds_alternative<inputtino::PS5Joypad>(*gamepad->joypad)) {
inputtino::PS5Joypad::BATTERY_STATE state;
switch (battery.state) {
case LI_BATTERY_STATE_CHARGING:
state = inputtino::PS5Joypad::BATTERY_CHARGHING;
break;
case LI_BATTERY_STATE_DISCHARGING:
state = inputtino::PS5Joypad::BATTERY_DISCHARGING;
break;
case LI_BATTERY_STATE_FULL:
state = inputtino::PS5Joypad::BATTERY_FULL;
break;
case LI_BATTERY_STATE_UNKNOWN:
case LI_BATTERY_STATE_NOT_PRESENT:
default:
return;
}
if (battery.percentage != LI_BATTERY_PERCENTAGE_UNKNOWN) {
std::get<inputtino::PS5Joypad>(*gamepad->joypad).set_battery(state, battery.percentage);
}
}
}
/**
* @brief Return the virtual gamepad types supported by inputtino.
*/
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
if (!input) {
static std::vector gps {
supported_gamepad_t {"auto", true, ""},
supported_gamepad_t {"xone", false, ""},
supported_gamepad_t {"ds5", false, ""},
supported_gamepad_t {"switch", false, ""},
};
return gps;
}
auto ds5 = create_ds5(-1); // Index -1 will result in a random MAC virtual device, which is fine for probing
auto switchPro = create_switch();
auto xOne = create_xbox_one();
static std::vector gps {
supported_gamepad_t {"auto", true, ""},
supported_gamepad_t {"xone", static_cast<bool>(xOne), !xOne ? xOne.getErrorMessage() : ""},
supported_gamepad_t {"ds5", static_cast<bool>(ds5), !ds5 ? ds5.getErrorMessage() : ""},
supported_gamepad_t {"switch", static_cast<bool>(switchPro), !switchPro ? switchPro.getErrorMessage() : ""},
};
for (auto &[name, is_enabled, reason_disabled] : gps) {
if (!is_enabled) {
BOOST_LOG(warning) << "Gamepad " << name << " is disabled due to " << reason_disabled;
}
}
return gps;
}
} // namespace platf::gamepad
▸
src/platform/linux/input/inputtino_gamepad.h
+0
−88
@@ -1,88 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_gamepad.h
* @brief Declarations for inputtino gamepad input handling.
*/
#pragma once
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "src/platform/common.h"
using namespace std::literals;
namespace platf::gamepad {
/**
* @brief Enumerates supported controller type options.
*/
enum ControllerType {
XboxOneWired, ///< Xbox One Wired Controller
DualSenseWired, ///< DualSense Wired Controller
SwitchProWired ///< Switch Pro Wired Controller
};
/**
* @brief Allocate and initialize platform input state for a stream.
*
* @param raw Platform-specific input backend state.
* @param id Identifier for the controller, session, display, or resource.
* @param metadata Output structure populated with HDR metadata.
* @param feedback_queue Feedback queue.
* @return Allocated object or identifier, or an error value on failure.
*/
int alloc(input_raw_t *raw, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue);
/**
* @brief Release backend resources for the indexed gamepad.
*
* @param raw Platform-specific input backend state.
* @param nr Controller index assigned by the client.
*/
void free(input_raw_t *raw, int nr);
/**
* @brief Apply the supplied state update to the platform backend.
*
* @param raw Platform-specific input backend state.
* @param nr Controller index assigned by the client.
* @param gamepad_state Gamepad state.
*/
void update(input_raw_t *raw, int nr, const gamepad_state_t &gamepad_state);
/**
* @brief Apply controller touchpad data to the backend device.
*
* @param raw Platform-specific input backend state.
* @param touch Touch event data to apply to the virtual device.
*/
void touch(input_raw_t *raw, const gamepad_touch_t &touch);
/**
* @brief Apply controller motion sensor data to the backend device.
*
* @param raw Platform-specific input backend state.
* @param motion Motion sensor data to apply to the virtual device.
*/
void motion(input_raw_t *raw, const gamepad_motion_t &motion);
/**
* @brief Apply controller battery status to the backend device.
*
* @param raw Platform-specific input backend state.
* @param battery Battery status data reported by the virtual device.
*/
void battery(input_raw_t *raw, const gamepad_battery_t &battery);
/**
* @brief Return gamepad slots supported by the inputtino backend.
*
* @param input Platform input backend that receives the event.
* @return Mutable list of supported virtual gamepads for the input backend.
*/
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input);
} // namespace platf::gamepad
▸
src/platform/linux/input/inputtino_keyboard.cpp
+0
−215
@@ -1,215 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_keyboard.cpp
* @brief Definitions for inputtino keyboard input handling.
*/
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "inputtino_keyboard.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf::keyboard {
/**
* Takes an UTF-32 encoded string and returns a hex string representation of the bytes (uppercase)
*
* ex: ['👱'] = "1F471" // see UTF encoding at https://www.compart.com/en/unicode/U+1F471
*
* adapted from: https://stackoverflow.com/a/7639754
* @param str UTF-8 text to encode as hexadecimal.
* @return Value converted to hex.
*/
std::string to_hex(const std::basic_string<char32_t> &str) {
std::stringstream ss;
ss << std::hex << std::setfill('0');
for (const auto &ch : str) {
ss << static_cast<uint32_t>(ch);
}
std::string hex_unicode(ss.str());
std::ranges::transform(hex_unicode, hex_unicode.begin(), ::toupper);
return hex_unicode;
}
/**
* A map of linux scan code -> Moonlight keyboard code
*/
static const std::map<short, short> key_mappings = {
{KEY_BACKSPACE, 0x08},
{KEY_TAB, 0x09},
{KEY_ENTER, 0x0D},
{KEY_LEFTSHIFT, 0x10},
{KEY_LEFTCTRL, 0x11},
{KEY_CAPSLOCK, 0x14},
{KEY_ESC, 0x1B},
{KEY_SPACE, 0x20},
{KEY_PAGEUP, 0x21},
{KEY_PAGEDOWN, 0x22},
{KEY_END, 0x23},
{KEY_HOME, 0x24},
{KEY_LEFT, 0x25},
{KEY_UP, 0x26},
{KEY_RIGHT, 0x27},
{KEY_DOWN, 0x28},
{KEY_SYSRQ, 0x2C},
{KEY_INSERT, 0x2D},
{KEY_DELETE, 0x2E},
{KEY_0, 0x30},
{KEY_1, 0x31},
{KEY_2, 0x32},
{KEY_3, 0x33},
{KEY_4, 0x34},
{KEY_5, 0x35},
{KEY_6, 0x36},
{KEY_7, 0x37},
{KEY_8, 0x38},
{KEY_9, 0x39},
{KEY_A, 0x41},
{KEY_B, 0x42},
{KEY_C, 0x43},
{KEY_D, 0x44},
{KEY_E, 0x45},
{KEY_F, 0x46},
{KEY_G, 0x47},
{KEY_H, 0x48},
{KEY_I, 0x49},
{KEY_J, 0x4A},
{KEY_K, 0x4B},
{KEY_L, 0x4C},
{KEY_M, 0x4D},
{KEY_N, 0x4E},
{KEY_O, 0x4F},
{KEY_P, 0x50},
{KEY_Q, 0x51},
{KEY_R, 0x52},
{KEY_S, 0x53},
{KEY_T, 0x54},
{KEY_U, 0x55},
{KEY_V, 0x56},
{KEY_W, 0x57},
{KEY_X, 0x58},
{KEY_Y, 0x59},
{KEY_Z, 0x5A},
{KEY_LEFTMETA, 0x5B},
{KEY_RIGHTMETA, 0x5C},
{KEY_KP0, 0x60},
{KEY_KP1, 0x61},
{KEY_KP2, 0x62},
{KEY_KP3, 0x63},
{KEY_KP4, 0x64},
{KEY_KP5, 0x65},
{KEY_KP6, 0x66},
{KEY_KP7, 0x67},
{KEY_KP8, 0x68},
{KEY_KP9, 0x69},
{KEY_KPASTERISK, 0x6A},
{KEY_KPPLUS, 0x6B},
{KEY_KPMINUS, 0x6D},
{KEY_KPDOT, 0x6E},
{KEY_KPSLASH, 0x6F},
{KEY_F1, 0x70},
{KEY_F2, 0x71},
{KEY_F3, 0x72},
{KEY_F4, 0x73},
{KEY_F5, 0x74},
{KEY_F6, 0x75},
{KEY_F7, 0x76},
{KEY_F8, 0x77},
{KEY_F9, 0x78},
{KEY_F10, 0x79},
{KEY_F11, 0x7A},
{KEY_F12, 0x7B},
{KEY_F13, 0x7C},
{KEY_F14, 0x7D},
{KEY_F15, 0x7E},
{KEY_F16, 0x7F},
{KEY_F17, 0x80},
{KEY_F18, 0x81},
{KEY_F19, 0x82},
{KEY_F20, 0x83},
{KEY_F21, 0x84},
{KEY_F22, 0x85},
{KEY_F23, 0x86},
{KEY_F24, 0x87},
{KEY_NUMLOCK, 0x90},
{KEY_SCROLLLOCK, 0x91},
{KEY_LEFTSHIFT, 0xA0},
{KEY_RIGHTSHIFT, 0xA1},
{KEY_LEFTCTRL, 0xA2},
{KEY_RIGHTCTRL, 0xA3},
{KEY_LEFTALT, 0xA4},
{KEY_RIGHTALT, 0xA5},
{KEY_SEMICOLON, 0xBA},
{KEY_EQUAL, 0xBB},
{KEY_COMMA, 0xBC},
{KEY_MINUS, 0xBD},
{KEY_DOT, 0xBE},
{KEY_SLASH, 0xBF},
{KEY_GRAVE, 0xC0},
{KEY_LEFTBRACE, 0xDB},
{KEY_BACKSLASH, 0xDC},
{KEY_RIGHTBRACE, 0xDD},
{KEY_APOSTROPHE, 0xDE},
{KEY_102ND, 0xE2}
};
/**
* @brief Apply the supplied state update to the platform backend.
*/
void update(input_raw_t *raw, uint16_t modcode, bool release, uint8_t flags) {
if (raw->keyboard) {
if (release) {
(*raw->keyboard).release(modcode);
} else {
(*raw->keyboard).press(modcode);
}
}
}
/**
* @brief Submit UTF-8 text input to the keyboard backend.
*/
void unicode(input_raw_t *raw, char *utf8, int size) {
if (raw->keyboard) {
/* Reading input text as UTF-8 */
auto utf8_str = boost::locale::conv::to_utf<wchar_t>(utf8, utf8 + size, "UTF-8");
/* Converting to UTF-32 */
auto utf32_str = boost::locale::conv::utf_to_utf<char32_t>(utf8_str);
/* To HEX string */
auto hex_unicode = to_hex(utf32_str);
BOOST_LOG(debug) << "Unicode, typing U+"sv << hex_unicode;
/* pressing <CTRL> + <SHIFT> + U */
(*raw->keyboard).press(0xA2); // LEFTCTRL
(*raw->keyboard).press(0xA0); // LEFTSHIFT
(*raw->keyboard).press(0x55); // U
(*raw->keyboard).release(0x55); // U
/* input each HEX character */
for (auto &ch : hex_unicode) {
auto key_str = "KEY_"s + ch;
auto keycode = libevdev_event_code_from_name(EV_KEY, key_str.c_str());
auto wincode = key_mappings.find(keycode);
if (keycode == -1 || wincode == key_mappings.end()) {
BOOST_LOG(warning) << "Unicode, unable to find keycode for: "sv << ch;
} else {
(*raw->keyboard).press(wincode->second);
(*raw->keyboard).release(wincode->second);
}
}
/* releasing <SHIFT> and <CTRL> */
(*raw->keyboard).release(0xA0); // LEFTSHIFT
(*raw->keyboard).release(0xA2); // LEFTCTRL
}
}
} // namespace platf::keyboard
▸
src/platform/linux/input/inputtino_keyboard.h
+0
−36
@@ -1,36 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_keyboard.h
* @brief Declarations for inputtino keyboard input handling.
*/
#pragma once
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
using namespace std::literals;
namespace platf::keyboard {
/**
* @brief Apply the supplied state update to the platform backend.
*
* @param raw Platform-specific input backend state.
* @param modcode Modifier key code to update.
* @param release Whether the key or button event is a release.
* @param flags Bit flags that modify the requested operation.
*/
void update(input_raw_t *raw, uint16_t modcode, bool release, uint8_t flags);
/**
* @brief Submit UTF-8 text input to the keyboard backend.
*
* @param raw Platform-specific input backend state.
* @param utf8 UTF-8 text submitted by the client.
* @param size Number of bytes or elements requested.
*/
void unicode(input_raw_t *raw, char *utf8, int size);
} // namespace platf::keyboard
▸
src/platform/linux/input/inputtino_mouse.cpp
+0
−104
@@ -1,104 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_mouse.cpp
* @brief Definitions for inputtino mouse input handling.
*/
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "inputtino_mouse.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf::mouse {
/**
* @brief Apply a relative pointer movement to the virtual mouse.
*/
void move(input_raw_t *raw, int deltaX, int deltaY) {
if (raw->mouse) {
(*raw->mouse).move(deltaX, deltaY);
}
}
/**
* @brief Move abs using the backend coordinate system.
*/
void move_abs(input_raw_t *raw, const touch_port_t &touch_port, float x, float y) {
if (raw->mouse) {
(*raw->mouse).move_abs(x, y, touch_port.width, touch_port.height);
}
}
/**
* @brief Press or release a virtual mouse button.
*/
void button(input_raw_t *raw, int button, bool release) {
if (raw->mouse) {
inputtino::Mouse::MOUSE_BUTTON btn_type;
switch (button) {
case BUTTON_LEFT:
btn_type = inputtino::Mouse::LEFT;
break;
case BUTTON_MIDDLE:
btn_type = inputtino::Mouse::MIDDLE;
break;
case BUTTON_RIGHT:
btn_type = inputtino::Mouse::RIGHT;
break;
case BUTTON_X1:
btn_type = inputtino::Mouse::SIDE;
break;
case BUTTON_X2:
btn_type = inputtino::Mouse::EXTRA;
break;
default:
BOOST_LOG(warning) << "Unknown mouse button: " << button;
return;
}
if (release) {
(*raw->mouse).release(btn_type);
} else {
(*raw->mouse).press(btn_type);
}
}
}
/**
* @brief Apply a vertical scroll event to the virtual mouse.
*/
void scroll(input_raw_t *raw, int high_res_distance) {
if (raw->mouse) {
(*raw->mouse).vertical_scroll(high_res_distance);
}
}
/**
* @brief Apply a horizontal scroll event to the virtual mouse.
*/
void hscroll(input_raw_t *raw, int high_res_distance) {
if (raw->mouse) {
(*raw->mouse).horizontal_scroll(high_res_distance);
}
}
/**
* @brief Return the current virtual pointer location.
*/
util::point_t get_location(input_raw_t *raw) {
if (raw->mouse) {
// TODO: decide what to do after https://github.com/games-on-whales/inputtino/issues/6 is resolved.
// TODO: auto x = (*raw->mouse).get_absolute_x();
// TODO: auto y = (*raw->mouse).get_absolute_y();
return {0, 0};
}
return {0, 0};
}
} // namespace platf::mouse
▸
src/platform/linux/input/inputtino_mouse.h
+0
−69
@@ -1,69 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_mouse.h
* @brief Declarations for inputtino mouse input handling.
*/
#pragma once
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "src/platform/common.h"
using namespace std::literals;
namespace platf::mouse {
/**
* @brief Apply a relative pointer movement to the virtual mouse.
*
* @param raw Platform-specific input backend state.
* @param deltaX Horizontal relative movement in client coordinates.
* @param deltaY Vertical relative movement in client coordinates.
*/
void move(input_raw_t *raw, int deltaX, int deltaY);
/**
* @brief Move the pointer to an absolute client-provided touch coordinate.
*
* @param raw Platform-specific input backend state.
* @param touch_port Touch coordinate bounds used for scaling.
* @param x Horizontal absolute coordinate from the client.
* @param y Vertical absolute coordinate from the client.
*/
void move_abs(input_raw_t *raw, const touch_port_t &touch_port, float x, float y);
/**
* @brief Press or release a virtual mouse button.
*
* @param raw Platform-specific input backend state.
* @param button Mouse button identifier to press or release.
* @param release Whether the key or button event is a release.
*/
void button(input_raw_t *raw, int button, bool release);
/**
* @brief Apply a vertical scroll event to the virtual mouse.
*
* @param raw Platform-specific input backend state.
* @param high_res_distance High-resolution scroll distance reported by the client.
*/
void scroll(input_raw_t *raw, int high_res_distance);
/**
* @brief Apply a horizontal scroll event to the virtual mouse.
*
* @param raw Platform-specific input backend state.
* @param high_res_distance High-resolution scroll distance reported by the client.
*/
void hscroll(input_raw_t *raw, int high_res_distance);
/**
* @brief Return the current virtual pointer location.
*
* @param raw Platform-specific input backend state.
* @return Current virtual pointer location in screen coordinates.
*/
util::point_t get_location(input_raw_t *raw);
} // namespace platf::mouse
▸
src/platform/linux/input/inputtino_pen.cpp
+0
−72
@@ -1,72 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_pen.cpp
* @brief Definitions for inputtino pen input handling.
*/
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "inputtino_pen.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf::pen {
/**
* @brief Apply the supplied state update to the platform backend.
*/
void update(client_input_raw_t *raw, const touch_port_t &touch_port, const pen_input_t &pen) {
if (raw->pen) {
// First set the buttons
(*raw->pen).set_btn(inputtino::PenTablet::PRIMARY, pen.penButtons & LI_PEN_BUTTON_PRIMARY);
(*raw->pen).set_btn(inputtino::PenTablet::SECONDARY, pen.penButtons & LI_PEN_BUTTON_SECONDARY);
(*raw->pen).set_btn(inputtino::PenTablet::TERTIARY, pen.penButtons & LI_PEN_BUTTON_TERTIARY);
// Set the tool
inputtino::PenTablet::TOOL_TYPE tool;
switch (pen.toolType) {
case LI_TOOL_TYPE_PEN:
tool = inputtino::PenTablet::PEN;
break;
case LI_TOOL_TYPE_ERASER:
tool = inputtino::PenTablet::ERASER;
break;
default:
tool = inputtino::PenTablet::SAME_AS_BEFORE;
break;
}
// Normalize rotation value to 0-359 degree range
auto rotation = pen.rotation;
if (rotation != LI_ROT_UNKNOWN) {
rotation %= 360;
}
// Here we receive:
// - Rotation: degrees from vertical in Y dimension (parallel to screen, 0..360)
// - Tilt: degrees from vertical in Z dimension (perpendicular to screen, 0..90)
float tilt_x = 0;
float tilt_y = 0;
// Convert polar coordinates into Y tilt angles
if (pen.tilt != LI_TILT_UNKNOWN && rotation != LI_ROT_UNKNOWN) {
auto rotation_rads = deg2rad(rotation);
auto tilt_rads = deg2rad(pen.tilt);
auto r = std::sin(tilt_rads);
auto z = std::cos(tilt_rads);
tilt_x = std::atan2(std::sin(-rotation_rads) * r, z) * 180.f / M_PI;
tilt_y = std::atan2(std::cos(-rotation_rads) * r, z) * 180.f / M_PI;
}
bool is_touching = pen.eventType == LI_TOUCH_EVENT_DOWN || pen.eventType == LI_TOUCH_EVENT_MOVE;
(*raw->pen).place_tool(tool, pen.x, pen.y, is_touching ? pen.pressureOrDistance : -1, is_touching ? -1 : pen.pressureOrDistance, tilt_x, tilt_y);
}
}
} // namespace platf::pen
▸
src/platform/linux/input/inputtino_pen.h
+0
−27
@@ -1,27 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_pen.h
* @brief Declarations for inputtino pen input handling.
*/
#pragma once
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "src/platform/common.h"
using namespace std::literals;
namespace platf::pen {
/**
* @brief Apply the supplied state update to the platform backend.
*
* @param raw Platform-specific input backend state.
* @param touch_port Touch coordinate bounds used for scaling.
* @param pen Pen event data to inject.
*/
void update(client_input_raw_t *raw, const touch_port_t &touch_port, const pen_input_t &pen);
} // namespace platf::pen
▸
src/platform/linux/input/inputtino_seat.cpp
+0
−21
@@ -1,21 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_seat.cpp
* @brief Implementation for multi-seat naming (udev-only).
*/
// lib includes
#include <lizardbyte/common/env.h>
// local includes
#include "inputtino_seat.h"
namespace platf::inputtino_seat {
std::string get_target_seat() {
if (std::string seat; lizardbyte::common::get_env("XDG_SEAT", seat) && !seat.empty()) {
return seat;
}
return {};
}
} // namespace platf::inputtino_seat
▸
src/platform/linux/input/inputtino_seat.h
+0
−19
@@ -1,19 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_seat.h
* @brief Helpers for multi-seat naming (udev-only).
*/
#pragma once
#include <string>
namespace platf::inputtino_seat {
/**
* Determine the target seat for the current Sunshine instance.
* Returns empty string if no seat could be determined.
*
* @return Seat name used for virtual input devices, or an empty string when unknown.
*/
std::string get_target_seat();
} // namespace platf::inputtino_seat
▸
src/platform/linux/input/inputtino_touch.cpp
+0
−59
@@ -1,59 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_touch.cpp
* @brief Definitions for inputtino touch input handling.
*/
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "inputtino_touch.h"
#include "src/config.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/utility.h"
using namespace std::literals;
namespace platf::touch {
/**
* @brief Apply the supplied state update to the platform backend.
*/
void update(client_input_raw_t *raw, const touch_port_t &touch_port, const touch_input_t &touch) {
if (raw->touch) {
switch (touch.eventType) {
case LI_TOUCH_EVENT_HOVER:
case LI_TOUCH_EVENT_DOWN:
case LI_TOUCH_EVENT_MOVE:
{
// Convert our 0..360 range to -90..90 relative to Y axis
int adjusted_angle = touch.rotation;
if (adjusted_angle > 90 && adjusted_angle < 270) {
// Lower hemisphere
adjusted_angle = 180 - adjusted_angle;
}
// Wrap the value if it's out of range
if (adjusted_angle > 90) {
adjusted_angle -= 360;
} else if (adjusted_angle < -90) {
adjusted_angle += 360;
}
(*raw->touch).place_finger(touch.pointerId, touch.x, touch.y, touch.pressureOrDistance, adjusted_angle);
break;
}
case LI_TOUCH_EVENT_CANCEL:
case LI_TOUCH_EVENT_UP:
case LI_TOUCH_EVENT_HOVER_LEAVE:
{
(*raw->touch).release_finger(touch.pointerId);
break;
}
// TODO: LI_TOUCH_EVENT_CANCEL_ALL
}
}
}
} // namespace platf::touch
▸
src/platform/linux/input/inputtino_touch.h
+0
−27
@@ -1,27 +1,0 @@
/**
* @file src/platform/linux/input/inputtino_touch.h
* @brief Declarations for inputtino touch input handling.
*/
#pragma once
// lib includes
#include <boost/locale.hpp>
#include <inputtino/input.hpp>
#include <libevdev/libevdev.h>
// local includes
#include "inputtino_common.h"
#include "src/platform/common.h"
using namespace std::literals;
namespace platf::touch {
/**
* @brief Apply the supplied state update to the platform backend.
*
* @param raw Platform-specific input backend state.
* @param touch_port Touch coordinate bounds used for scaling.
* @param touch Touch event data to apply to the virtual device.
*/
void update(client_input_raw_t *raw, const touch_port_t &touch_port, const touch_input_t &touch);
} // namespace platf::touch
▸
src/platform/linux/input/virtualhid.cpp
+79
−0
@@ -1,0 +1,79 @@
/**
* @file src/platform/linux/input/virtualhid.cpp
* @brief Definitions for libvirtualhid Unix input handling.
*/
// standard includes
#include <memory>
#include <utility>
// platform includes
#ifdef SUNSHINE_BUILD_X11
#include <X11/Xlib.h>
#endif
// local includes
#include "src/platform/virtualhid_input.h"
using namespace std::literals;
namespace platf {
platform_caps::caps_t get_capabilities() {
platform_caps::caps_t caps = 0;
const auto runtime = virtualhid::create_runtime();
if (!runtime) {
return caps;
}
if (const auto &capabilities = runtime->capabilities(); config::input.native_pen_touch && (capabilities.supports_touchscreen || capabilities.supports_pen_tablet)) {
caps |= platform_caps::pen_touch;
}
if (virtualhid::configured_gamepad_supports_touchpad()) {
caps |= platform_caps::controller_touch;
}
return caps;
}
std::optional<util::point_t> get_mouse_loc(input_t & /*input*/) {
#ifdef SUNSHINE_BUILD_X11
auto *display = XOpenDisplay(nullptr);
if (!display) {
return std::nullopt;
}
const auto root = DefaultRootWindow(display);
Window root_return {};
Window child_return {};
int root_x = 0;
int root_y = 0;
int window_x = 0;
int window_y = 0;
unsigned int mask = 0;
const auto queried = XQueryPointer(display, root, &root_return, &child_return, &root_x, &root_y, &window_x, &window_y, &mask);
XCloseDisplay(display);
if (!queried) {
return std::nullopt;
}
return util::point_t {
static_cast<double>(root_x),
static_cast<double>(root_y)
};
#else
return std::nullopt;
#endif
}
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
static std::vector<supported_gamepad_t> gamepads;
if (!input || !input->get()) {
gamepads = virtualhid::static_supported_gamepads();
return gamepads;
}
gamepads = virtualhid::supported_gamepads(virtualhid::get_input_context(*input).runtime.get());
return gamepads;
}
} // namespace platf
▸
src/platform/macos/input.cpp
+32
−727
@@ -1,756 +1,61 @@
/**
* @file src/platform/macos/input.cpp
* @brief Definitions for macOS input handling.
* @brief Definitions for libvirtualhid-backed macOS input handling.
*/
// standard includes
#include <algorithm>
#include <chrono>
#include <cmath>
#include <cstdint>
#include <iostream>
#include <thread>
// platform includes
#include <ApplicationServices/ApplicationServices.h>
#import <Carbon/Carbon.h>
#include <CoreFoundation/CoreFoundation.h>
#include <IOKit/hidsystem/IOLLEvent.h>
#include <mach/mach.h>
// local includes
#include "src/display_device.h"
#include "src/input.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/utility.h"
// standard includes
#include <cstdint>
#include <memory>
#include <utility>
#include <vector>
constexpr auto MULTICLICK_DELAY_MS = std::chrono::milliseconds(500); ///< Maximum gap between clicks that macOS should treat as a double click.
// local includes
#include "src/config.h"
#include "src/platform/virtualhid_input.h"
namespace platf {
using namespace std::literals;
constexpr int WHEEL_DELTA = 120; ///< Protocol or platform constant for wheel delta.
constexpr double DEFAULT_SCROLLWHEEL_SCALING = 0.3125; ///< Protocol or platform constant for default scrollwheel scaling.
constexpr int DEFAULT_SCROLL_LINES_PER_DETENT = 5; ///< Protocol or platform constant for default scroll lines per detent.
/**
* @brief macOS input source and target display state.
*/
struct macos_input_t {
public:
CGDirectDisplayID display {}; ///< CoreGraphics identifier for the display receiving injected input.
CGFloat displayScaling {}; ///< Scale factor used to translate client coordinates to display pixels.
CGEventSourceRef source {}; ///< CoreGraphics event source used for mouse and scroll events.
// keyboard related stuff
CGEventSourceRef keyboard_source {}; ///< CoreGraphics event source used for keyboard injection.
CGEventFlags kb_flags {}; ///< Active keyboard modifier flags currently held down by the client.
// mouse related stuff
CGEventRef mouse_event {}; ///< Reusable CoreGraphics mouse event updated before posting.
double scrollwheel_scaling {DEFAULT_SCROLLWHEEL_SCALING}; ///< Multiplier applied to incoming scroll-wheel deltas.
int scroll_lines_per_detent {DEFAULT_SCROLL_LINES_PER_DETENT}; ///< Number of logical scroll lines represented by one wheel detent.
/**
* @brief Tracks whether the mouse button is currently pressed.
*/
bool mouse_down[3] {}; // mouse button status
/**
* @brief Last mouse event.
*/
std::chrono::steady_clock::steady_clock::time_point last_mouse_event[3][2]; // timestamp of last mouse events
};
// A struct to hold a Windows keycode to Mac virtual keycode mapping.
/**
* @brief Mapping from Sunshine key symbols to macOS virtual key codes.
*/
struct KeyCodeMap {
int win_keycode; ///< Sunshine/Windows virtual key code received from the client.
int mac_keycode; ///< macOS virtual key code sent through CoreGraphics.
};
// Customized less operator for using std::lower_bound() on a KeyCodeMap array.
/**
* @brief Order key mappings by Sunshine key code for binary search.
*
* @param a Left-hand mapping being compared.
* @param b Right-hand mapping being compared.
* @return True when `a` has a lower Sunshine key code than `b`.
*/
bool operator<(const KeyCodeMap &a, const KeyCodeMap &b) {
return a.win_keycode < b.win_keycode;
}
// clang-format off
/**
* @brief Key codes map.
*/
const KeyCodeMap kKeyCodesMap[] = {
{ 0x08 /* VKEY_BACK */, kVK_Delete },
{ 0x09 /* VKEY_TAB */, kVK_Tab },
{ 0x0A /* VKEY_BACKTAB */, 0x21E4 },
{ 0x0C /* VKEY_CLEAR */, kVK_ANSI_KeypadClear },
{ 0x0D /* VKEY_RETURN */, kVK_Return },
{ 0x10 /* VKEY_SHIFT */, kVK_Shift },
{ 0x11 /* VKEY_CONTROL */, kVK_Control },
{ 0x12 /* VKEY_MENU */, kVK_Option },
{ 0x13 /* VKEY_PAUSE */, -1 },
{ 0x14 /* VKEY_CAPITAL */, kVK_CapsLock },
{ 0x15 /* VKEY_KANA */, kVK_JIS_Kana },
{ 0x15 /* VKEY_HANGUL */, -1 },
{ 0x17 /* VKEY_JUNJA */, -1 },
{ 0x18 /* VKEY_FINAL */, -1 },
{ 0x19 /* VKEY_HANJA */, -1 },
{ 0x19 /* VKEY_KANJI */, -1 },
{ 0x1B /* VKEY_ESCAPE */, kVK_Escape },
{ 0x1C /* VKEY_CONVERT */, -1 },
{ 0x1D /* VKEY_NONCONVERT */, -1 },
{ 0x1E /* VKEY_ACCEPT */, -1 },
{ 0x1F /* VKEY_MODECHANGE */, -1 },
{ 0x20 /* VKEY_SPACE */, kVK_Space },
{ 0x21 /* VKEY_PRIOR */, kVK_PageUp },
{ 0x22 /* VKEY_NEXT */, kVK_PageDown },
{ 0x23 /* VKEY_END */, kVK_End },
{ 0x24 /* VKEY_HOME */, kVK_Home },
{ 0x25 /* VKEY_LEFT */, kVK_LeftArrow },
{ 0x26 /* VKEY_UP */, kVK_UpArrow },
{ 0x27 /* VKEY_RIGHT */, kVK_RightArrow },
{ 0x28 /* VKEY_DOWN */, kVK_DownArrow },
{ 0x29 /* VKEY_SELECT */, -1 },
{ 0x2A /* VKEY_PRINT */, -1 },
{ 0x2B /* VKEY_EXECUTE */, -1 },
{ 0x2C /* VKEY_SNAPSHOT */, -1 },
{ 0x2D /* VKEY_INSERT */, kVK_Help },
{ 0x2E /* VKEY_DELETE */, kVK_ForwardDelete },
{ 0x2F /* VKEY_HELP */, kVK_Help },
{ 0x30 /* VKEY_0 */, kVK_ANSI_0 },
{ 0x31 /* VKEY_1 */, kVK_ANSI_1 },
{ 0x32 /* VKEY_2 */, kVK_ANSI_2 },
{ 0x33 /* VKEY_3 */, kVK_ANSI_3 },
{ 0x34 /* VKEY_4 */, kVK_ANSI_4 },
{ 0x35 /* VKEY_5 */, kVK_ANSI_5 },
{ 0x36 /* VKEY_6 */, kVK_ANSI_6 },
{ 0x37 /* VKEY_7 */, kVK_ANSI_7 },
{ 0x38 /* VKEY_8 */, kVK_ANSI_8 },
{ 0x39 /* VKEY_9 */, kVK_ANSI_9 },
{ 0x41 /* VKEY_A */, kVK_ANSI_A },
{ 0x42 /* VKEY_B */, kVK_ANSI_B },
{ 0x43 /* VKEY_C */, kVK_ANSI_C },
{ 0x44 /* VKEY_D */, kVK_ANSI_D },
{ 0x45 /* VKEY_E */, kVK_ANSI_E },
{ 0x46 /* VKEY_F */, kVK_ANSI_F },
{ 0x47 /* VKEY_G */, kVK_ANSI_G },
{ 0x48 /* VKEY_H */, kVK_ANSI_H },
{ 0x49 /* VKEY_I */, kVK_ANSI_I },
{ 0x4A /* VKEY_J */, kVK_ANSI_J },
{ 0x4B /* VKEY_K */, kVK_ANSI_K },
{ 0x4C /* VKEY_L */, kVK_ANSI_L },
{ 0x4D /* VKEY_M */, kVK_ANSI_M },
{ 0x4E /* VKEY_N */, kVK_ANSI_N },
{ 0x4F /* VKEY_O */, kVK_ANSI_O },
{ 0x50 /* VKEY_P */, kVK_ANSI_P },
{ 0x51 /* VKEY_Q */, kVK_ANSI_Q },
{ 0x52 /* VKEY_R */, kVK_ANSI_R },
{ 0x53 /* VKEY_S */, kVK_ANSI_S },
{ 0x54 /* VKEY_T */, kVK_ANSI_T },
{ 0x55 /* VKEY_U */, kVK_ANSI_U },
{ 0x56 /* VKEY_V */, kVK_ANSI_V },
{ 0x57 /* VKEY_W */, kVK_ANSI_W },
{ 0x58 /* VKEY_X */, kVK_ANSI_X },
{ 0x59 /* VKEY_Y */, kVK_ANSI_Y },
{ 0x5A /* VKEY_Z */, kVK_ANSI_Z },
{ 0x5B /* VKEY_LWIN */, kVK_Command },
{ 0x5C /* VKEY_RWIN */, kVK_RightCommand },
{ 0x5D /* VKEY_APPS */, kVK_RightCommand },
{ 0x5F /* VKEY_SLEEP */, -1 },
{ 0x60 /* VKEY_NUMPAD0 */, kVK_ANSI_Keypad0 },
{ 0x61 /* VKEY_NUMPAD1 */, kVK_ANSI_Keypad1 },
{ 0x62 /* VKEY_NUMPAD2 */, kVK_ANSI_Keypad2 },
{ 0x63 /* VKEY_NUMPAD3 */, kVK_ANSI_Keypad3 },
{ 0x64 /* VKEY_NUMPAD4 */, kVK_ANSI_Keypad4 },
{ 0x65 /* VKEY_NUMPAD5 */, kVK_ANSI_Keypad5 },
{ 0x66 /* VKEY_NUMPAD6 */, kVK_ANSI_Keypad6 },
{ 0x67 /* VKEY_NUMPAD7 */, kVK_ANSI_Keypad7 },
{ 0x68 /* VKEY_NUMPAD8 */, kVK_ANSI_Keypad8 },
{ 0x69 /* VKEY_NUMPAD9 */, kVK_ANSI_Keypad9 },
{ 0x6A /* VKEY_MULTIPLY */, kVK_ANSI_KeypadMultiply },
{ 0x6B /* VKEY_ADD */, kVK_ANSI_KeypadPlus },
{ 0x6C /* VKEY_SEPARATOR */, -1 },
{ 0x6D /* VKEY_SUBTRACT */, kVK_ANSI_KeypadMinus },
{ 0x6E /* VKEY_DECIMAL */, kVK_ANSI_KeypadDecimal },
{ 0x6F /* VKEY_DIVIDE */, kVK_ANSI_KeypadDivide },
{ 0x70 /* VKEY_F1 */, kVK_F1 },
{ 0x71 /* VKEY_F2 */, kVK_F2 },
{ 0x72 /* VKEY_F3 */, kVK_F3 },
{ 0x73 /* VKEY_F4 */, kVK_F4 },
{ 0x74 /* VKEY_F5 */, kVK_F5 },
{ 0x75 /* VKEY_F6 */, kVK_F6 },
{ 0x76 /* VKEY_F7 */, kVK_F7 },
{ 0x77 /* VKEY_F8 */, kVK_F8 },
{ 0x78 /* VKEY_F9 */, kVK_F9 },
{ 0x79 /* VKEY_F10 */, kVK_F10 },
{ 0x7A /* VKEY_F11 */, kVK_F11 },
{ 0x7B /* VKEY_F12 */, kVK_F12 },
{ 0x7C /* VKEY_F13 */, kVK_F13 },
{ 0x7D /* VKEY_F14 */, kVK_F14 },
{ 0x7E /* VKEY_F15 */, kVK_F15 },
{ 0x7F /* VKEY_F16 */, kVK_F16 },
{ 0x80 /* VKEY_F17 */, kVK_F17 },
{ 0x81 /* VKEY_F18 */, kVK_F18 },
{ 0x82 /* VKEY_F19 */, kVK_F19 },
{ 0x83 /* VKEY_F20 */, kVK_F20 },
{ 0x84 /* VKEY_F21 */, -1 },
{ 0x85 /* VKEY_F22 */, -1 },
{ 0x86 /* VKEY_F23 */, -1 },
{ 0x87 /* VKEY_F24 */, -1 },
{ 0x90 /* VKEY_NUMLOCK */, -1 },
{ 0x91 /* VKEY_SCROLL */, -1 },
{ 0xA0 /* VKEY_LSHIFT */, kVK_Shift },
{ 0xA1 /* VKEY_RSHIFT */, kVK_RightShift },
{ 0xA2 /* VKEY_LCONTROL */, kVK_Control },
{ 0xA3 /* VKEY_RCONTROL */, kVK_RightControl },
{ 0xA4 /* VKEY_LMENU */, kVK_Option },
{ 0xA5 /* VKEY_RMENU */, kVK_RightOption },
{ 0xA6 /* VKEY_BROWSER_BACK */, -1 },
{ 0xA7 /* VKEY_BROWSER_FORWARD */, -1 },
{ 0xA8 /* VKEY_BROWSER_REFRESH */, -1 },
{ 0xA9 /* VKEY_BROWSER_STOP */, -1 },
{ 0xAA /* VKEY_BROWSER_SEARCH */, -1 },
{ 0xAB /* VKEY_BROWSER_FAVORITES */, -1 },
{ 0xAC /* VKEY_BROWSER_HOME */, -1 },
{ 0xAD /* VKEY_VOLUME_MUTE */, -1 },
{ 0xAE /* VKEY_VOLUME_DOWN */, -1 },
{ 0xAF /* VKEY_VOLUME_UP */, -1 },
{ 0xB0 /* VKEY_MEDIA_NEXT_TRACK */, -1 },
{ 0xB1 /* VKEY_MEDIA_PREV_TRACK */, -1 },
{ 0xB2 /* VKEY_MEDIA_STOP */, -1 },
{ 0xB3 /* VKEY_MEDIA_PLAY_PAUSE */, -1 },
{ 0xB4 /* VKEY_MEDIA_LAUNCH_MAIL */, -1 },
{ 0xB5 /* VKEY_MEDIA_LAUNCH_MEDIA_SELECT */, -1 },
{ 0xB6 /* VKEY_MEDIA_LAUNCH_APP1 */, -1 },
{ 0xB7 /* VKEY_MEDIA_LAUNCH_APP2 */, -1 },
{ 0xBA /* VKEY_OEM_1 */, kVK_ANSI_Semicolon },
{ 0xBB /* VKEY_OEM_PLUS */, kVK_ANSI_Equal },
{ 0xBC /* VKEY_OEM_COMMA */, kVK_ANSI_Comma },
{ 0xBD /* VKEY_OEM_MINUS */, kVK_ANSI_Minus },
{ 0xBE /* VKEY_OEM_PERIOD */, kVK_ANSI_Period },
{ 0xBF /* VKEY_OEM_2 */, kVK_ANSI_Slash },
{ 0xC0 /* VKEY_OEM_3 */, kVK_ANSI_Grave },
{ 0xDB /* VKEY_OEM_4 */, kVK_ANSI_LeftBracket },
{ 0xDC /* VKEY_OEM_5 */, kVK_ANSI_Backslash },
{ 0xDD /* VKEY_OEM_6 */, kVK_ANSI_RightBracket },
{ 0xDE /* VKEY_OEM_7 */, kVK_ANSI_Quote },
{ 0xDF /* VKEY_OEM_8 */, -1 },
{ 0xE2 /* VKEY_OEM_102 */, -1 },
{ 0xE5 /* VKEY_PROCESSKEY */, -1 },
{ 0xE7 /* VKEY_PACKET */, -1 },
{ 0xF6 /* VKEY_ATTN */, -1 },
{ 0xF7 /* VKEY_CRSEL */, -1 },
{ 0xF8 /* VKEY_EXSEL */, -1 },
{ 0xF9 /* VKEY_EREOF */, -1 },
{ 0xFA /* VKEY_PLAY */, -1 },
{ 0xFB /* VKEY_ZOOM */, -1 },
{ 0xFC /* VKEY_NONAME */, -1 },
{ 0xFD /* VKEY_PA1 */, -1 },
{ 0xFE /* VKEY_OEM_CLEAR */, kVK_ANSI_KeypadClear }
};
// clang-format on
/**
* @brief Translate a platform keycode to a Sunshine key symbol.
*
* @param keycode Platform keycode being translated or emitted.
* @return Sunshine key symbol, or 0 when the keycode is unmapped.
*/
int keysym(int keycode) {
KeyCodeMap key_map {};
key_map.win_keycode = keycode;
const KeyCodeMap *temp_map = std::lower_bound(
kKeyCodesMap,
kKeyCodesMap + sizeof(kKeyCodesMap) / sizeof(kKeyCodesMap[0]),
key_map
);
if (temp_map >= kKeyCodesMap + sizeof(kKeyCodesMap) / sizeof(kKeyCodesMap[0]) || temp_map->win_keycode != keycode || temp_map->mac_keycode == -1) {
return -1;
}
return temp_map->mac_keycode;
}
/**
* @brief macOS modifier flags split into generic and device-specific bits.
*/
struct modifier_flags_t {
CGEventFlags generic {}; ///< Modifier bits represented by device-independent CoreGraphics flags.
CGEventFlags device {}; ///< Modifier bits represented by left/right device-specific flags.
CGEventFlags all_devices {}; ///< Mask covering all device-specific variants for this modifier.
};
/**
* @brief Resolve the CoreGraphics modifier masks associated with a key code.
*
* @param key Sunshine key code that may represent a modifier key.
* @param flags Output masks for the matching CoreGraphics modifier.
* @return True when modifier flags were found for the key.
*/
bool modifier_flags_for_key(int key, modifier_flags_t &flags) {
switch (key) {
case kVK_Shift:
flags = {kCGEventFlagMaskShift, NX_DEVICELSHIFTKEYMASK, NX_DEVICELSHIFTKEYMASK | NX_DEVICERSHIFTKEYMASK};
return true;
case kVK_RightShift:
flags = {kCGEventFlagMaskShift, NX_DEVICERSHIFTKEYMASK, NX_DEVICELSHIFTKEYMASK | NX_DEVICERSHIFTKEYMASK};
return true;
case kVK_Command:
flags = {kCGEventFlagMaskCommand, NX_DEVICELCMDKEYMASK, NX_DEVICELCMDKEYMASK | NX_DEVICERCMDKEYMASK};
return true;
case kVK_RightCommand:
flags = {kCGEventFlagMaskCommand, NX_DEVICERCMDKEYMASK, NX_DEVICELCMDKEYMASK | NX_DEVICERCMDKEYMASK};
return true;
case kVK_Option:
flags = {kCGEventFlagMaskAlternate, NX_DEVICELALTKEYMASK, NX_DEVICELALTKEYMASK | NX_DEVICERALTKEYMASK};
return true;
case kVK_RightOption:
flags = {kCGEventFlagMaskAlternate, NX_DEVICERALTKEYMASK, NX_DEVICELALTKEYMASK | NX_DEVICERALTKEYMASK};
return true;
case kVK_Control:
flags = {kCGEventFlagMaskControl, NX_DEVICELCTLKEYMASK, NX_DEVICELCTLKEYMASK | NX_DEVICERCTLKEYMASK};
return true;
case kVK_RightControl:
flags = {kCGEventFlagMaskControl, NX_DEVICERCTLKEYMASK, NX_DEVICELCTLKEYMASK | NX_DEVICERCTLKEYMASK};
return true;
default:
return false;
std::optional<util::point_t> get_mouse_loc(input_t & /*input*/) {
const auto event = CGEventCreate(nullptr);
if (!event) {
return std::nullopt;
}
}
void keyboard_update(input_t &input, uint16_t modcode, bool release, uint8_t flags) {
auto key = keysym(modcode);
BOOST_LOG(debug) << "got keycode: 0x"sv << std::hex << modcode << ", translated to: 0x" << std::hex << key << ", release:" << release;
const auto current = CGEventGetLocation(event);
if (key < 0) {
return;
}
auto macos_input = ((macos_input_t *) input.get());
CGEventRef event = nullptr;
modifier_flags_t modifier_flags;
if (modifier_flags_for_key(key, modifier_flags)) {
event = CGEventCreateKeyboardEvent(macos_input->keyboard_source, key, !release);
if (!event) {
return;
}
CGEventSetIntegerValueField(event, kCGKeyboardEventKeycode, key);
if (release) {
macos_input->kb_flags &= ~modifier_flags.device;
if ((macos_input->kb_flags & modifier_flags.all_devices) == 0) {
macos_input->kb_flags &= ~modifier_flags.generic;
}
} else {
macos_input->kb_flags |= modifier_flags.generic | modifier_flags.device;
}
CGEventSetType(event, kCGEventFlagsChanged);
} else {
event = CGEventCreateKeyboardEvent(macos_input->keyboard_source, key, !release);
if (!event) {
return;
}
CGEventSetType(event, release ? kCGEventKeyUp : kCGEventKeyDown);
}
CGEventSetFlags(event, macos_input->kb_flags);
CGEventPost(kCGSessionEventTap, event);
CFRelease(event);
return util::point_t {current.x, current.y};
}
platform_caps::caps_t get_capabilities() {
platform_caps::caps_t caps = 0;
const auto runtime = virtualhid::create_runtime();
void unicode(input_t &input, char *utf8, int size) {
BOOST_LOG(info) << "unicode: Unicode input not yet implemented for MacOS."sv;
}
if (!runtime) {
return caps;
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
BOOST_LOG(info) << "alloc_gamepad: Gamepad not yet implemented for MacOS."sv;
return -1;
}
void free_gamepad(input_t &input, int nr) {
BOOST_LOG(info) << "free_gamepad: Gamepad not yet implemented for MacOS."sv;
}
void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
BOOST_LOG(info) << "gamepad: Gamepad not yet implemented for MacOS."sv;
}
// returns current mouse location:
util::point_t get_mouse_loc(input_t &input) {
// Creating a new event every time to avoid any reuse risk
const auto macos_input = static_cast<macos_input_t *>(input.get());
const auto snapshot_event = CGEventCreate(macos_input->source);
const auto current = CGEventGetLocation(snapshot_event);
CFRelease(snapshot_event);
return util::point_t {
current.x,
current.y
};
}
/**
* @brief Post a mouse event at the clamped display location.
*
* @param input Platform input context.
* @param button Mouse button.
* @param type CoreGraphics mouse event type.
* @param raw_location Requested mouse location.
* @param previous_location Previous mouse location.
* @param click_count Click count for the event.
*/
void post_mouse(
input_t &input,
const CGMouseButton button,
const CGEventType type,
const util::point_t raw_location,
const util::point_t previous_location,
const int click_count
) {
BOOST_LOG(debug) << "mouse_event: "sv << button << ", type: "sv << type << ", location:"sv << raw_location.x << ":"sv << raw_location.y << " click_count: "sv << click_count;
const auto macos_input = static_cast<macos_input_t *>(input.get());
const auto display = macos_input->display;
const auto event = macos_input->mouse_event;
// get display bounds for current display
const CGRect display_bounds = CGDisplayBounds(display);
// limit mouse to current display bounds
const auto location = CGPoint {
std::clamp(raw_location.x, display_bounds.origin.x, display_bounds.origin.x + display_bounds.size.width - 1),
std::clamp(raw_location.y, display_bounds.origin.y, display_bounds.origin.y + display_bounds.size.height - 1)
};
CGEventSetType(event, type);
CGEventSetLocation(event, location);
CGEventSetIntegerValueField(event, kCGMouseEventButtonNumber, button);
CGEventSetIntegerValueField(event, kCGMouseEventClickState, click_count);
// Include deltas so some 3D applications can consume changes (game cameras, etc)
const double deltaX = raw_location.x - previous_location.x;
const double deltaY = raw_location.y - previous_location.y;
CGEventSetDoubleValueField(event, kCGMouseEventDeltaX, deltaX);
CGEventSetDoubleValueField(event, kCGMouseEventDeltaY, deltaY);
// Inject modifier flags into mouse events so that shift+click and similar combinations work correctly.
CGEventSetFlags(event, macos_input->kb_flags);
CGEventPost(kCGHIDEventTap, event);
// For why this is here, see:
// https://stackoverflow.com/questions/15194409/simulated-mouseevent-not-working-properly-osx
CGWarpMouseCursorPosition(location);
}
/**
* @brief Choose the CoreGraphics mouse event type for the current button action.
*
* @param input Platform input backend that receives the event.
* @return CoreGraphics mouse event type for button press or release.
*/
inline CGEventType event_type_mouse(input_t &input) {
const auto macos_input = static_cast<macos_input_t *>(input.get());
if (macos_input->mouse_down[0]) {
return kCGEventLeftMouseDragged;
}
if (macos_input->mouse_down[1]) {
return kCGEventOtherMouseDragged;
}
if (macos_input->mouse_down[2]) {
return kCGEventRightMouseDragged;
}
return kCGEventMouseMoved;
}
void move_mouse(
input_t &input,
const int deltaX,
const int deltaY
) {
const auto current = get_mouse_loc(input);
const auto location = util::point_t {current.x + deltaX, current.y + deltaY};
post_mouse(input, kCGMouseButtonLeft, event_type_mouse(input), location, current, 0);
}
void abs_mouse(
input_t &input,
const touch_port_t &touch_port,
const float x,
const float y
) {
const auto macos_input = static_cast<macos_input_t *>(input.get());
const auto scaling = macos_input->displayScaling;
const auto display = macos_input->display;
auto location = util::point_t {x * scaling, y * scaling};
CGRect display_bounds = CGDisplayBounds(display);
// in order to get the correct mouse location for capturing display , we need to add the display bounds to the location
location.x += display_bounds.origin.x;
location.y += display_bounds.origin.y;
post_mouse(input, kCGMouseButtonLeft, event_type_mouse(input), location, get_mouse_loc(input), 0);
}
void button_mouse(input_t &input, const int button, const bool release) {
CGMouseButton mac_button;
CGEventType event;
const auto macos_input = static_cast<macos_input_t *>(input.get());
switch (button) {
case 1:
mac_button = kCGMouseButtonLeft;
event = release ? kCGEventLeftMouseUp : kCGEventLeftMouseDown;
break;
case 2:
const auto &capabilities = runtime->capabilities();
if (capabilities.supports_gamepad && virtualhid::configured_gamepad_supports_touchpad()) {
caps |= platform_caps::controller_touch;
mac_button = kCGMouseButtonCenter;
event = release ? kCGEventOtherMouseUp : kCGEventOtherMouseDown;
break;
case 3:
mac_button = kCGMouseButtonRight;
event = release ? kCGEventRightMouseUp : kCGEventRightMouseDown;
break;
default:
BOOST_LOG(warning) << "Unsupported mouse button for MacOS: "sv << button;
return;
}
macos_input->mouse_down[mac_button] = !release;
// if the last mouse down was less than MULTICLICK_DELAY_MS, we send a double click event
const auto now = std::chrono::steady_clock::now();
const auto mouse_position = get_mouse_loc(input);
if (now < macos_input->last_mouse_event[mac_button][release] + MULTICLICK_DELAY_MS) {
post_mouse(input, mac_button, event, mouse_position, mouse_position, 2);
} else {
post_mouse(input, mac_button, event, mouse_position, mouse_position, 1);
if (config::input.native_pen_touch && (capabilities.supports_touchscreen || capabilities.supports_pen_tablet)) {
caps |= platform_caps::pen_touch;
}
return caps;
macos_input->last_mouse_event[mac_button][release] = now;
}
/**
* @brief Get scroll lines per detent.
*
* @param scrollwheel_scaling Scrollwheel scaling.
* @return Number of logical lines represented by one wheel detent.
*/
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
static std::vector<supported_gamepad_t> gamepads;
if (!input || !input->get()) {
gamepads = virtualhid::static_supported_gamepads();
return gamepads;
int get_scroll_lines_per_detent(double &scrollwheel_scaling) {
double scale = DEFAULT_SCROLLWHEEL_SCALING;
const auto value = CFPreferencesCopyValue(CFSTR("com.apple.scrollwheel.scaling"), kCFPreferencesAnyApplication, kCFPreferencesCurrentUser, kCFPreferencesAnyHost);
if (value) {
if (CFGetTypeID(value) == CFNumberGetTypeID()) {
CFNumberGetValue(static_cast<CFNumberRef>(value), kCFNumberDoubleType, &scale);
} else if (CFGetTypeID(value) == CFStringGetTypeID()) {
scale = CFStringGetDoubleValue(static_cast<CFStringRef>(value));
}
CFRelease(value);
}
gamepads = virtualhid::supported_gamepads(virtualhid::get_input_context(*input).runtime.get());
if (!std::isfinite(scale)) {
scale = DEFAULT_SCROLLWHEEL_SCALING;
}
scrollwheel_scaling = scale;
// com.apple.scrollwheel.scaling stores the Mouse scroll speed slider position, not
// the scroll multiplier itself. The slider is 0..1 and Apple's default is 0.3125,
// so anchor 0 at one line per wheel detent and 0.3125 at five lines.
const auto scroll_scale = std::clamp(scale, 0.0, 1.0);
constexpr double lines_per_scroll_scale = (DEFAULT_SCROLL_LINES_PER_DETENT - 1.0) / DEFAULT_SCROLLWHEEL_SCALING;
return std::max(1, static_cast<int>(std::ceil(1.0 + scroll_scale * lines_per_scroll_scale)));
}
/**
* @brief Convert a high-resolution wheel delta to CoreGraphics scroll pixels.
*
* @param macos_input macOS input state containing scroll scaling settings.
* @param high_res_distance Wheel delta in Windows high-resolution units.
* @return Pixel distance to send to CoreGraphics.
*/
int scroll_pixels(const macos_input_t *macos_input, const int high_res_distance) {
const auto source_pixels_per_line = CGEventSourceGetPixelsPerLine(macos_input->source);
const auto pixels_per_line = source_pixels_per_line > 0 ? static_cast<int>(source_pixels_per_line + 0.5) : 10;
const auto scaled_pixels = static_cast<int64_t>(high_res_distance) * std::max(1, pixels_per_line) * std::max(1, macos_input->scroll_lines_per_detent);
return static_cast<int>(scaled_pixels / WHEEL_DELTA);
}
/**
* @brief Post a macOS scroll event to the target display.
*
* @param input Platform input backend that receives the event.
* @param wheelY Wheel y.
* @param wheelX Wheel x.
*/
void post_scroll(input_t &input, const int wheelY, const int wheelX) {
if (wheelY == 0 && wheelX == 0) {
return;
}
const auto macos_input = static_cast<macos_input_t *>(input.get());
CGEventRef event = CGEventCreateScrollWheelEvent(macos_input->source, kCGScrollEventUnitPixel, 2, wheelY, wheelX);
if (!event) {
return;
}
CGEventSetIntegerValueField(event, kCGScrollWheelEventIsContinuous, 1);
CGEventPost(kCGHIDEventTap, event);
CFRelease(event);
}
void scroll(input_t &input, const int high_res_distance) {
post_scroll(input, scroll_pixels(static_cast<macos_input_t *>(input.get()), high_res_distance), 0);
}
void hscroll(input_t &input, int high_res_distance) {
post_scroll(input, 0, scroll_pixels(static_cast<macos_input_t *>(input.get()), high_res_distance));
}
/**
* @brief Allocates a context to store per-client input data.
* @param input The global input context.
* @return A unique pointer to a per-client input data context.
*/
std::unique_ptr<client_input_t> allocate_client_input_context(input_t &input) {
// Unused
return nullptr;
}
/**
* @brief Sends a touch event to the OS.
* @param input The client-specific input context.
* @param touch_port The current viewport for translating to screen coordinates.
* @param touch The touch event.
*/
void touch_update(client_input_t *input, const touch_port_t &touch_port, const touch_input_t &touch) {
// Unimplemented feature - platform_caps::pen_touch
}
/**
* @brief Sends a pen event to the OS.
* @param input The client-specific input context.
* @param touch_port The current viewport for translating to screen coordinates.
* @param pen The pen event.
*/
void pen_update(client_input_t *input, const touch_port_t &touch_port, const pen_input_t &pen) {
// Unimplemented feature - platform_caps::pen_touch
}
/**
* @brief Sends a gamepad touch event to the OS.
* @param input The global input context.
* @param touch The touch event.
*/
void gamepad_touch(input_t &input, const gamepad_touch_t &touch) {
// Unimplemented feature - platform_caps::controller_touch
}
/**
* @brief Sends a gamepad motion event to the OS.
* @param input The global input context.
* @param motion The motion event.
*/
void gamepad_motion(input_t &input, const gamepad_motion_t &motion) {
// Unimplemented
}
/**
* @brief Sends a gamepad battery event to the OS.
* @param input The global input context.
* @param battery The battery event.
*/
void gamepad_battery(input_t &input, const gamepad_battery_t &battery) {
// Unimplemented
}
input_t input() {
input_t result {new macos_input_t()};
const auto macos_input = static_cast<macos_input_t *>(result.get());
// Default to main display
macos_input->display = CGMainDisplayID();
auto output_name = display_device::map_output_name(config::video.output_name);
// If output_name is set, try to find the display with that display id
if (!output_name.empty()) {
const int MAX_DISPLAYS = 32;
uint32_t max_display = MAX_DISPLAYS;
uint32_t display_count;
CGDirectDisplayID displays[MAX_DISPLAYS];
if (CGGetActiveDisplayList(max_display, displays, &display_count) != kCGErrorSuccess) {
BOOST_LOG(error) << "Unable to get active display list , error: "sv << std::endl;
} else {
for (int i = 0; i < display_count; i++) {
CGDirectDisplayID display_id = displays[i];
if (display_id == std::atoi(output_name.c_str())) {
macos_input->display = display_id;
}
}
}
}
// Input coordinates are based on the virtual resolution not the physical, so we need the scaling factor
const CGDisplayModeRef mode = CGDisplayCopyDisplayMode(macos_input->display);
macos_input->displayScaling = ((CGFloat) CGDisplayPixelsWide(macos_input->display)) / ((CGFloat) CGDisplayModeGetPixelWidth(mode));
CFRelease(mode);
macos_input->source = CGEventSourceCreate(kCGEventSourceStateHIDSystemState);
macos_input->keyboard_source = CGEventSourceCreate(kCGEventSourceStatePrivate);
macos_input->scroll_lines_per_detent = get_scroll_lines_per_detent(macos_input->scrollwheel_scaling);
macos_input->kb_flags = 0;
macos_input->mouse_event = CGEventCreate(macos_input->source);
macos_input->mouse_down[0] = false;
macos_input->mouse_down[1] = false;
macos_input->mouse_down[2] = false;
BOOST_LOG(debug) << "macOS scroll speed: com.apple.scrollwheel.scaling="sv << macos_input->scrollwheel_scaling << ", lines per detent="sv << macos_input->scroll_lines_per_detent << ", pixels per line="sv << CGEventSourceGetPixelsPerLine(macos_input->source);
BOOST_LOG(debug) << "Display "sv << macos_input->display << ", pixel dimension: " << CGDisplayPixelsWide(macos_input->display) << "x"sv << CGDisplayPixelsHigh(macos_input->display);
return result;
}
void freeInput(void *p) {
const auto *input = static_cast<macos_input_t *>(p);
CFRelease(input->source);
CFRelease(input->keyboard_source);
CFRelease(input->mouse_event);
delete input;
}
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
static std::vector gamepads {
supported_gamepad_t {"", false, "gamepads.macos_not_implemented"}
};
return gamepads;
}
/**
* @brief Returns the supported platform capabilities to advertise to the client.
* @return Capability flags.
*/
platform_caps::caps_t get_capabilities() {
return 0;
}
} // namespace platf
▸
src/platform/virtualhid_input.cpp
+1015
−0
@@ -1,0 +1,1015 @@
/**
* @file src/platform/virtualhid_input.cpp
* @brief Definitions for libvirtualhid-backed input helpers.
*/
// standard includes
#include <algorithm>
#include <array>
#include <cmath>
#include <cstddef>
#include <cstdint>
#include <format>
#include <limits>
#include <mutex>
#include <numbers>
#include <optional>
#include <random>
#include <string_view>
#include <utility>
// local includes
#include "src/config.h"
#include "src/logging.h"
#include "virtualhid_input.h"
using namespace std::literals;
namespace platf::virtualhid {
/**
* @brief Runtime state for one virtual gamepad.
*/
struct gamepad_context_t {
std::unique_ptr<lvh::GamepadStateAdapter> adapter; ///< State adapter for the virtual gamepad.
std::mutex feedback_mutex; ///< Synchronizes feedback routing with session rebinding.
feedback_queue_t feedback_queue; ///< Feedback queue for client output events.
std::array<std::optional<std::uint32_t>, 2> touch_ids; ///< Client touch IDs assigned to libvirtualhid slots.
std::uint8_t client_relative_index = 0; ///< Client-relative controller index.
bool has_last_rumble = false; ///< Whether last rumble values are valid.
std::uint16_t last_low_frequency_rumble = 0; ///< Last low-frequency rumble value.
std::uint16_t last_high_frequency_rumble = 0; ///< Last high-frequency rumble value.
bool has_last_trigger_rumble = false; ///< Whether last trigger rumble values are valid.
std::uint16_t last_left_trigger_rumble = 0; ///< Last left trigger rumble value.
std::uint16_t last_right_trigger_rumble = 0; ///< Last right trigger rumble value.
bool has_last_rgb = false; ///< Whether last RGB values are valid.
std::uint8_t last_red = 0; ///< Last red LED value.
std::uint8_t last_green = 0; ///< Last green LED value.
std::uint8_t last_blue = 0; ///< Last blue LED value.
};
namespace {
/**
* @brief Gamepad profile exposed through Sunshine config.
*/
struct gamepad_profile_t {
std::string_view name; ///< Sunshine config value.
lvh::GamepadProfileKind kind; ///< libvirtualhid profile kind.
lvh::DeviceProfile (*profile)(); ///< Profile factory.
};
/**
* @brief Supported libvirtualhid gamepad profiles.
*/
constexpr std::array gamepad_profiles {
gamepad_profile_t {"generic", lvh::GamepadProfileKind::generic, lvh::profiles::generic_gamepad},
gamepad_profile_t {"x360", lvh::GamepadProfileKind::xbox_360, lvh::profiles::xbox_360},
gamepad_profile_t {"xone", lvh::GamepadProfileKind::xbox_one, lvh::profiles::xbox_one},
gamepad_profile_t {"xseries", lvh::GamepadProfileKind::xbox_series, lvh::profiles::xbox_series},
gamepad_profile_t {"ds4", lvh::GamepadProfileKind::dualshock4, lvh::profiles::dualshock4},
gamepad_profile_t {"ds5", lvh::GamepadProfileKind::dualsense, lvh::profiles::dualsense},
gamepad_profile_t {"switch", lvh::GamepadProfileKind::switch_pro, lvh::profiles::switch_pro},
};
void log_failure(std::string_view operation, const lvh::OperationStatus &status) {
if (!status.ok()) {
BOOST_LOG(warning) << operation << ": "sv << status.message();
}
}
float normalize_axis(std::int16_t value) {
if (value < 0) {
return std::max(-1.0F, static_cast<float>(value) / 32768.0F);
}
return std::min(1.0F, static_cast<float>(value) / 32767.0F);
}
float normalize_trigger(std::uint8_t value) {
return static_cast<float>(value) / static_cast<float>(std::numeric_limits<std::uint8_t>::max());
}
lvh::ClientControllerType client_controller_type(std::uint8_t type) {
using enum lvh::ClientControllerType;
switch (type) {
case LI_CTYPE_XBOX:
return xbox;
case LI_CTYPE_PS:
return playstation;
case LI_CTYPE_NINTENDO:
return nintendo;
case LI_CTYPE_UNKNOWN:
default:
return unknown;
}
}
const gamepad_profile_t &profile_for_name(std::string_view name) {
if (const auto iter = std::ranges::find(gamepad_profiles, name, &gamepad_profile_t::name); iter != gamepad_profiles.end()) {
return *iter;
}
return gamepad_profiles[3]; // Xbox Series.
}
const gamepad_profile_t &profile_for_metadata(const gamepad_arrival_t &metadata) {
if (config::input.gamepad != "auto"sv) {
return profile_for_name(config::input.gamepad);
}
if (metadata.type == LI_CTYPE_PS) {
BOOST_LOG(info) << "Gamepad will be DualSense controller (auto-selected by client-reported type)"sv;
return profile_for_name("ds5"sv);
}
if (metadata.type == LI_CTYPE_NINTENDO) {
BOOST_LOG(info) << "Gamepad will be Nintendo Switch Pro controller (auto-selected by client-reported type)"sv;
return profile_for_name("switch"sv);
}
if (metadata.type == LI_CTYPE_XBOX) {
BOOST_LOG(info) << "Gamepad will be Xbox Series controller (auto-selected by client-reported type)"sv;
return profile_for_name("xseries"sv);
}
if (config::input.motion_as_ds4 && (metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO))) {
BOOST_LOG(info) << "Gamepad will be DualSense controller (auto-selected by motion sensor presence)"sv;
return profile_for_name("ds5"sv);
}
if (config::input.touchpad_as_ds4 && (metadata.capabilities & LI_CCAP_TOUCHPAD)) {
BOOST_LOG(info) << "Gamepad will be DualSense controller (auto-selected by touchpad presence)"sv;
return profile_for_name("ds5"sv);
}
BOOST_LOG(info) << "Gamepad will be Xbox Series controller (default)"sv;
return profile_for_name("xseries"sv);
}
std::string random_private_mac() {
std::random_device random;
return std::format(
"02:00:{:02x}:{:02x}:{:02x}:{:02x}",
random() & 0xFF,
random() & 0xFF,
random() & 0xFF,
random() & 0xFF
);
}
std::string gamepad_stable_id(const gamepad_id_t &id, const lvh::DeviceProfile &profile) {
if (profile.gamepad_kind != lvh::GamepadProfileKind::dualshock4 && profile.gamepad_kind != lvh::GamepadProfileKind::dualsense) {
return std::format("sunshine-gamepad-{}", id.globalIndex);
}
if (config::input.virtualhid_randomize_mac || id.globalIndex < 0 || id.globalIndex > 255) {
return random_private_mac();
}
return std::format("02:00:00:00:00:{:02x}", id.globalIndex);
}
lvh::GamepadMetadata gamepad_metadata(const gamepad_id_t &id, const gamepad_arrival_t &metadata, const lvh::DeviceProfile &profile) {
lvh::GamepadMetadata result;
result.global_index = id.globalIndex;
result.client_relative_index = id.clientRelativeIndex;
result.client_type = client_controller_type(metadata.type);
result.has_motion_sensors = metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO);
result.has_touchpad = metadata.capabilities & LI_CCAP_TOUCHPAD;
result.has_rgb_led = metadata.capabilities & LI_CCAP_RGB_LED;
result.has_battery = metadata.capabilities & LI_CCAP_BATTERY_STATE;
result.stable_id = gamepad_stable_id(id, profile);
return result;
}
void warn_unsupported_client_features(int global_index, const gamepad_arrival_t &metadata, const lvh::GamepadProfileSupport &support) {
if ((metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO)) && !support.supports_motion) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " has motion sensors, but the selected virtual profile cannot expose them"sv;
}
if ((metadata.capabilities & LI_CCAP_TOUCHPAD) && !support.supports_touchpad) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " has a touchpad, but the selected virtual profile cannot expose it"sv;
}
if ((metadata.capabilities & LI_CCAP_RGB_LED) && !support.supports_rgb_led) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " has an RGB LED, but the selected virtual profile cannot expose it"sv;
}
}
void warn_missing_client_features(int global_index, const gamepad_arrival_t &metadata, const lvh::GamepadProfileSupport &support) {
if (support.supports_motion && !(metadata.capabilities & (LI_CCAP_ACCEL | LI_CCAP_GYRO))) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " is emulating a motion-capable controller, but the client gamepad does not have motion sensors active"sv;
}
if (support.supports_touchpad && !(metadata.capabilities & LI_CCAP_TOUCHPAD)) {
BOOST_LOG(warning) << "Gamepad "sv << global_index << " is emulating a touchpad-capable controller, but the client gamepad does not have a touchpad"sv;
}
}
lvh::GamepadState make_gamepad_state(const gamepad_state_t &state, const lvh::GamepadProfileSupport &support) {
using enum lvh::GamepadButton;
lvh::GamepadState result;
const auto flags = state.buttonFlags;
result.buttons.set(dpad_up, flags & DPAD_UP);
result.buttons.set(dpad_down, flags & DPAD_DOWN);
result.buttons.set(dpad_left, flags & DPAD_LEFT);
result.buttons.set(dpad_right, flags & DPAD_RIGHT);
result.buttons.set(start, flags & START);
result.buttons.set(back, flags & BACK);
result.buttons.set(left_stick, flags & LEFT_STICK);
result.buttons.set(right_stick, flags & RIGHT_STICK);
result.buttons.set(left_shoulder, flags & LEFT_BUTTON);
result.buttons.set(right_shoulder, flags & RIGHT_BUTTON);
result.buttons.set(guide, flags & HOME);
result.buttons.set(a, flags & A);
result.buttons.set(b, flags & B);
result.buttons.set(x, flags & X);
result.buttons.set(y, flags & Y);
result.buttons.set(misc1, support.supports_misc1_button && (flags & MISC_BUTTON));
result.buttons.set(touchpad, support.supports_touchpad_button && (flags & TOUCHPAD_BUTTON));
if (support.supports_touchpad_button && config::input.ds4_back_as_touchpad_click && configured_gamepad_supports_touchpad() && (flags & BACK)) {
result.buttons.set(touchpad);
}
result.left_stick = {.x = normalize_axis(state.lsX), .y = normalize_axis(state.lsY)};
result.right_stick = {.x = normalize_axis(state.rsX), .y = normalize_axis(state.rsY)};
result.left_trigger = normalize_trigger(state.lt);
result.right_trigger = normalize_trigger(state.rt);
return result;
}
std::optional<lvh::MouseButton> mouse_button(int button) {
using enum lvh::MouseButton;
switch (button) {
case BUTTON_LEFT:
return left;
case BUTTON_MIDDLE:
return middle;
case BUTTON_RIGHT:
return right;
case BUTTON_X1:
return side;
case BUTTON_X2:
return extra;
default:
BOOST_LOG(warning) << "Unknown mouse button: "sv << button;
return std::nullopt;
}
}
lvh::GamepadBatteryState battery_state(std::uint8_t state) {
using enum lvh::GamepadBatteryState;
switch (state) {
case LI_BATTERY_STATE_DISCHARGING:
return discharging;
case LI_BATTERY_STATE_CHARGING:
return charging;
case LI_BATTERY_STATE_FULL:
return full;
case LI_BATTERY_STATE_NOT_PRESENT:
case LI_BATTERY_STATE_NOT_CHARGING:
return charging_error;
case LI_BATTERY_STATE_UNKNOWN:
default:
return unknown;
}
}
std::int32_t touch_orientation(std::uint16_t rotation) {
if (rotation == LI_ROT_UNKNOWN) {
return 0;
}
auto adjusted = static_cast<int>(rotation);
if (adjusted > 90 && adjusted < 270) {
adjusted = 180 - adjusted;
}
if (adjusted > 90) {
adjusted -= 360;
} else if (adjusted < -90) {
adjusted += 360;
}
return adjusted;
}
lvh::PointerViewport pointer_viewport(const touch_port_t &touch_port) {
return {
.offset_x = touch_port.offset_x,
.offset_y = touch_port.offset_y,
.width = touch_port.width,
.height = touch_port.height,
};
}
lvh::KeyboardEvent keyboard_event(std::uint16_t modcode, bool release, std::uint8_t flags) {
lvh::KeyboardEvent event {
.key_code = modcode,
.pressed = !release,
};
#ifdef _WIN32
event.uses_normalized_key_code = (static_cast<std::byte>(flags) & static_cast<std::byte>(SS_KBE_FLAG_NON_NORMALIZED)) == std::byte {};
event.prefer_native_scan_code = config::input.always_send_scancodes;
#else
(void) flags;
#endif
return event;
}
lvh::PenToolType pen_tool(std::uint8_t tool) {
using enum lvh::PenToolType;
switch (tool) {
case LI_TOOL_TYPE_PEN:
return pen;
case LI_TOOL_TYPE_ERASER:
return eraser;
case LI_TOOL_TYPE_UNKNOWN:
default:
return unchanged;
}
}
/**
* @brief Raise gamepad feedback while the caller owns the feedback lock.
*
* @param gamepad Gamepad receiving output from the virtual device.
* @param message Feedback message to deliver to the client.
*/
void raise_feedback_unlocked(const std::shared_ptr<gamepad_context_t> &gamepad, const gamepad_feedback_msg_t &message) {
if (gamepad->feedback_queue) {
gamepad->feedback_queue->raise(message);
}
}
/**
* @brief Raise gamepad feedback using the current client routing.
*
* @param gamepad Gamepad receiving output from the virtual device.
* @param message Feedback message to deliver to the client.
*/
void raise_feedback(const std::shared_ptr<gamepad_context_t> &gamepad, const gamepad_feedback_msg_t &message) {
std::lock_guard lock {gamepad->feedback_mutex};
raise_feedback_unlocked(gamepad, message);
}
/**
* @brief Translate a libvirtualhid output event into Moonlight feedback.
*
* @param gamepad Gamepad that emitted the output event.
* @param output Output event reported by libvirtualhid.
*/
void handle_output(const std::shared_ptr<gamepad_context_t> &gamepad, const lvh::GamepadOutput &output) {
std::lock_guard lock {gamepad->feedback_mutex};
switch (output.kind) {
case lvh::GamepadOutputKind::rumble:
if (gamepad->has_last_rumble && gamepad->last_low_frequency_rumble == output.low_frequency_rumble && gamepad->last_high_frequency_rumble == output.high_frequency_rumble) {
return;
}
gamepad->has_last_rumble = true;
gamepad->last_low_frequency_rumble = output.low_frequency_rumble;
gamepad->last_high_frequency_rumble = output.high_frequency_rumble;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_rumble(gamepad->client_relative_index, output.low_frequency_rumble, output.high_frequency_rumble));
break;
case lvh::GamepadOutputKind::trigger_rumble:
if (gamepad->has_last_trigger_rumble && gamepad->last_left_trigger_rumble == output.left_trigger_rumble && gamepad->last_right_trigger_rumble == output.right_trigger_rumble) {
return;
}
gamepad->has_last_trigger_rumble = true;
gamepad->last_left_trigger_rumble = output.left_trigger_rumble;
gamepad->last_right_trigger_rumble = output.right_trigger_rumble;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_rumble_triggers(gamepad->client_relative_index, output.left_trigger_rumble, output.right_trigger_rumble));
break;
case lvh::GamepadOutputKind::rgb_led:
if (gamepad->has_last_rgb && gamepad->last_red == output.red && gamepad->last_green == output.green && gamepad->last_blue == output.blue) {
return;
}
gamepad->has_last_rgb = true;
gamepad->last_red = output.red;
gamepad->last_green = output.green;
gamepad->last_blue = output.blue;
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_rgb_led(gamepad->client_relative_index, output.red, output.green, output.blue));
break;
case lvh::GamepadOutputKind::adaptive_triggers:
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_adaptive_triggers(gamepad->client_relative_index, output.adaptive_trigger_flags, output.left_trigger_effect_type, output.right_trigger_effect_type, output.left_trigger_effect, output.right_trigger_effect));
break;
case lvh::GamepadOutputKind::raw_report:
break;
}
}
void cancel_all_touches(client_context_t &context) {
if (!context.touch) {
return;
}
for (const auto id : context.active_touches) {
log_failure("cancel libvirtualhid touch contact"sv, context.touch->cancel_contact(id));
}
context.active_touches.clear();
}
} // namespace
input_context_t::input_context_t():
input_context_t {lvh::BackendKind::platform_default} {}
input_context_t::input_context_t(lvh::BackendKind backend):
runtime {create_runtime(backend)} {
if (!runtime) {
BOOST_LOG(warning) << "Unable to create libvirtualhid runtime"sv;
return;
}
const auto &capabilities = runtime->capabilities();
if (capabilities.supports_keyboard) {
lvh::CreateKeyboardOptions options;
options.profile = lvh::profiles::keyboard();
options.stable_id = "sunshine-keyboard";
auto created = runtime->create_keyboard(options);
if (created) {
keyboard = std::move(created.keyboard);
} else {
log_failure("create libvirtualhid keyboard"sv, created.status);
}
}
if (capabilities.supports_mouse) {
lvh::CreateMouseOptions options;
options.profile = lvh::profiles::mouse();
options.stable_id = "sunshine-mouse";
auto created = runtime->create_mouse(options);
if (created) {
mouse = std::move(created.mouse);
} else {
log_failure("create libvirtualhid mouse"sv, created.status);
}
}
}
client_context_t::client_context_t(input_context_t &input):
global {&input} {
if (!global->runtime) {
return;
}
const auto &capabilities = global->runtime->capabilities();
if (capabilities.supports_touchscreen) {
lvh::CreateTouchscreenOptions options;
options.profile = lvh::profiles::touchscreen();
options.stable_id = "sunshine-touchscreen";
auto created = global->runtime->create_touchscreen(options);
if (created) {
touch = std::move(created.touchscreen);
} else {
log_failure("create libvirtualhid touchscreen"sv, created.status);
}
}
if (capabilities.supports_pen_tablet) {
lvh::CreatePenTabletOptions options;
options.profile = lvh::profiles::pen_tablet();
options.stable_id = "sunshine-pen-tablet";
auto created = global->runtime->create_pen_tablet(options);
if (created) {
pen = std::move(created.pen_tablet);
} else {
log_failure("create libvirtualhid pen tablet"sv, created.status);
}
}
}
std::unique_ptr<lvh::Runtime> create_runtime(lvh::BackendKind backend) {
lvh::RuntimeOptions options;
options.backend = backend;
return lvh::Runtime::create(options);
}
std::vector<supported_gamepad_t> static_supported_gamepads() {
std::vector gamepads {
supported_gamepad_t {"auto", true, ""},
};
for (const auto &profile : gamepad_profiles) {
gamepads.emplace_back(std::string {profile.name}, false, "");
}
return gamepads;
}
std::vector<supported_gamepad_t> supported_gamepads(lvh::Runtime *runtime, bool fallback_vigem_available) {
if (!runtime) {
return static_supported_gamepads();
}
const auto libvirtualhid_available = runtime->capabilities().supports_gamepad;
const auto reason = libvirtualhid_available ? "" : "gamepads.virtualhid-not-available";
const auto auto_enabled = libvirtualhid_available || fallback_vigem_available;
std::vector gamepads {
supported_gamepad_t {"auto", auto_enabled, auto_enabled ? "" : reason},
};
for (const auto &profile : gamepad_profiles) {
const auto fallback_supported = fallback_vigem_available && (profile.name == "x360"sv || profile.name == "ds4"sv);
const auto enabled = libvirtualhid_available || fallback_supported;
gamepads.emplace_back(std::string {profile.name}, enabled, enabled ? "" : reason);
}
for (auto &[name, is_enabled, reason_disabled] : gamepads) {
if (!is_enabled) {
BOOST_LOG(warning) << "Gamepad "sv << name << " is disabled due to "sv << reason_disabled;
}
}
return gamepads;
}
int alloc_gamepad(input_context_t &context, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
if (!context.runtime || !context.runtime->capabilities().supports_gamepad) {
return -1;
}
if (id.globalIndex < 0 || id.globalIndex >= static_cast<int>(context.gamepads.size())) {
BOOST_LOG(warning) << "Invalid libvirtualhid gamepad index: "sv << id.globalIndex;
return -1;
}
const auto &selection = profile_for_metadata(metadata);
auto profile = selection.profile();
profile.name = std::format("Sunshine {}", profile.name);
if (config::input.gamepad != "auto"sv) {
BOOST_LOG(info) << "Gamepad "sv << id.globalIndex << " will be "sv << profile.name << " (manual selection)"sv;
} else {
BOOST_LOG(info) << "Gamepad "sv << id.globalIndex << " will be "sv << profile.name;
}
lvh::CreateGamepadOptions options;
options.profile = profile;
options.metadata = gamepad_metadata(id, metadata, profile);
auto created = lvh::GamepadStateAdapter::create(*context.runtime, options);
if (!created) {
log_failure("create libvirtualhid gamepad"sv, created.status);
return -1;
}
auto gamepad = std::make_shared<gamepad_context_t>();
gamepad->adapter = std::move(created.adapter);
gamepad->feedback_queue = std::move(feedback_queue);
gamepad->client_relative_index = id.clientRelativeIndex;
gamepad->adapter->set_output_callback([weak_gamepad = std::weak_ptr {gamepad}](const lvh::GamepadOutput &output) {
if (const auto gamepad = weak_gamepad.lock()) {
handle_output(gamepad, output);
}
});
const auto &support = gamepad->adapter->support();
warn_unsupported_client_features(id.globalIndex, metadata, support);
warn_missing_client_features(id.globalIndex, metadata, support);
if (support.supports_motion) {
raise_feedback(gamepad, gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_ACCEL, 100));
raise_feedback(gamepad, gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_GYRO, 100));
}
context.gamepads[id.globalIndex] = std::move(gamepad);
return 0;
}
int rebind_gamepad(input_context_t &context, const gamepad_id_t &id, feedback_queue_t feedback_queue) {
if (!has_gamepad(context, id.globalIndex)) {
return -1;
}
const auto &gamepad = context.gamepads[id.globalIndex];
std::lock_guard lock {gamepad->feedback_mutex};
gamepad->feedback_queue = std::move(feedback_queue);
gamepad->client_relative_index = id.clientRelativeIndex;
gamepad->has_last_rumble = false;
gamepad->has_last_trigger_rumble = false;
gamepad->has_last_rgb = false;
if (gamepad->adapter->support().supports_motion) {
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_ACCEL, 100));
raise_feedback_unlocked(gamepad, gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_GYRO, 100));
}
return 0;
}
bool has_gamepad(const input_context_t &context, int nr) {
return nr >= 0 && nr < context.gamepads.size() && context.gamepads[nr] && context.gamepads[nr]->adapter;
}
#ifdef SUNSHINE_TESTS
lvh::GamepadStateAdapter *gamepad_adapter_for_testing(input_context_t &context, int nr) {
return has_gamepad(context, nr) ? context.gamepads[nr]->adapter.get() : nullptr;
}
#endif
void free_gamepad(input_context_t &context, int nr) {
if (has_gamepad(context, nr)) {
context.gamepads[nr].reset();
}
}
void gamepad_update(input_context_t &context, int nr, const gamepad_state_t &state) {
if (!has_gamepad(context, nr)) {
return;
}
auto &gamepad = context.gamepads[nr];
log_failure("submit libvirtualhid gamepad state"sv, gamepad->adapter->set_state(make_gamepad_state(state, gamepad->adapter->support())));
}
void gamepad_touch(input_context_t &context, const gamepad_touch_t &touch) {
if (!has_gamepad(context, touch.id.globalIndex)) {
return;
}
auto &gamepad = context.gamepads[touch.id.globalIndex];
if (!gamepad->adapter->support().supports_touchpad) {
return;
}
if (touch.eventType == LI_TOUCH_EVENT_CANCEL_ALL) {
for (std::size_t index = 0; index < gamepad->touch_ids.size(); ++index) {
if (gamepad->touch_ids[index].has_value()) {
log_failure("release libvirtualhid gamepad touch"sv, gamepad->adapter->clear_touchpad_contact(index));
gamepad->touch_ids[index].reset();
}
}
return;
}
auto slot = std::ranges::find(gamepad->touch_ids, touch.pointerId);
if (touch.eventType == LI_TOUCH_EVENT_DOWN && slot == gamepad->touch_ids.end()) {
slot = std::ranges::find_if(gamepad->touch_ids, [](const auto &id) {
return !id.has_value();
});
if (slot == gamepad->touch_ids.end()) {
BOOST_LOG(warning) << "No free libvirtualhid gamepad touch slots"sv;
return;
}
*slot = touch.pointerId;
}
if (slot == gamepad->touch_ids.end()) {
return;
}
const auto index = static_cast<std::size_t>(std::distance(gamepad->touch_ids.begin(), slot));
if (touch.eventType == LI_TOUCH_EVENT_UP || touch.eventType == LI_TOUCH_EVENT_CANCEL) {
log_failure("release libvirtualhid gamepad touch"sv, gamepad->adapter->clear_touchpad_contact(index));
slot->reset();
return;
}
if (touch.eventType != LI_TOUCH_EVENT_DOWN && touch.eventType != LI_TOUCH_EVENT_MOVE) {
return;
}
lvh::GamepadTouchContact contact;
contact.id = static_cast<std::uint8_t>(index);
contact.active = touch.pressure > 0.5F;
contact.x = std::clamp(touch.x, 0.0F, 1.0F);
contact.y = std::clamp(touch.y, 0.0F, 1.0F);
log_failure("submit libvirtualhid gamepad touch"sv, gamepad->adapter->set_touchpad_contact(index, contact));
}
void gamepad_motion(input_context_t &context, const gamepad_motion_t &motion) {
if (!has_gamepad(context, motion.id.globalIndex)) {
return;
}
auto &gamepad = context.gamepads[motion.id.globalIndex];
switch (motion.motionType) {
case LI_MOTION_TYPE_ACCEL:
log_failure("submit libvirtualhid gamepad acceleration"sv, gamepad->adapter->set_acceleration(lvh::Vector3 {motion.x, motion.y, motion.z}));
break;
case LI_MOTION_TYPE_GYRO:
log_failure("submit libvirtualhid gamepad gyroscope"sv, gamepad->adapter->set_gyroscope(lvh::Vector3 {motion.x, motion.y, motion.z}));
break;
default:
break;
}
}
void gamepad_battery(input_context_t &context, const gamepad_battery_t &battery) {
if (!has_gamepad(context, battery.id.globalIndex)) {
return;
}
auto &gamepad = context.gamepads[battery.id.globalIndex];
if (battery.state == LI_BATTERY_STATE_UNKNOWN || battery.state == LI_BATTERY_STATE_NOT_PRESENT) {
log_failure("clear libvirtualhid gamepad battery"sv, gamepad->adapter->clear_battery());
return;
}
lvh::GamepadBattery value;
value.state = battery_state(battery.state);
value.percentage = battery.percentage == LI_BATTERY_PERCENTAGE_UNKNOWN ? 100 : std::min<std::uint8_t>(battery.percentage, 100);
log_failure("submit libvirtualhid gamepad battery"sv, gamepad->adapter->set_battery(value));
}
void move_mouse(input_context_t &context, int delta_x, int delta_y) {
if (context.mouse) {
log_failure("submit libvirtualhid mouse movement"sv, context.mouse->move_relative(delta_x, delta_y));
}
}
void abs_mouse(input_context_t &context, const touch_port_t &touch_port, float x, float y) {
if (context.mouse) {
log_failure(
"submit libvirtualhid absolute mouse movement"sv,
context.mouse->move_absolute(
static_cast<std::int32_t>(std::lround(x)),
static_cast<std::int32_t>(std::lround(y)),
touch_port.width,
touch_port.height
)
);
}
}
void button_mouse(input_context_t &context, int button, bool release) {
if (context.mouse) {
const auto converted = mouse_button(button);
if (!converted) {
return;
}
log_failure("submit libvirtualhid mouse button"sv, context.mouse->button(*converted, !release));
}
}
void scroll(input_context_t &context, int high_res_distance) {
if (context.mouse) {
log_failure("submit libvirtualhid vertical scroll"sv, context.mouse->vertical_scroll(high_res_distance));
}
}
void hscroll(input_context_t &context, int high_res_distance) {
if (context.mouse) {
log_failure("submit libvirtualhid horizontal scroll"sv, context.mouse->horizontal_scroll(high_res_distance));
}
}
void keyboard_update(input_context_t &context, std::uint16_t modcode, bool release, std::uint8_t flags) {
if (context.keyboard) {
log_failure("submit libvirtualhid keyboard input"sv, context.keyboard->submit(keyboard_event(modcode, release, flags)));
}
}
void unicode(input_context_t &context, const char *utf8, int size) {
if (context.keyboard && utf8 && size > 0) {
log_failure("submit libvirtualhid text input"sv, context.keyboard->type_text({.text = std::string {utf8, static_cast<std::size_t>(size)}}));
}
}
void touch_update(client_context_t &context, const touch_port_t &touch_port, const touch_input_t &touch) {
if (!context.touch) {
return;
}
switch (touch.eventType) {
case LI_TOUCH_EVENT_CANCEL_ALL:
cancel_all_touches(context);
return;
case LI_TOUCH_EVENT_UP:
log_failure("release libvirtualhid touch contact"sv, context.touch->release_contact(static_cast<std::int32_t>(touch.pointerId)));
context.active_touches.erase(static_cast<std::int32_t>(touch.pointerId));
return;
case LI_TOUCH_EVENT_CANCEL:
log_failure("cancel libvirtualhid touch contact"sv, context.touch->cancel_contact(static_cast<std::int32_t>(touch.pointerId)));
context.active_touches.erase(static_cast<std::int32_t>(touch.pointerId));
return;
case LI_TOUCH_EVENT_HOVER_LEAVE:
log_failure("leave libvirtualhid touch contact"sv, context.touch->leave_contact(static_cast<std::int32_t>(touch.pointerId)));
context.active_touches.erase(static_cast<std::int32_t>(touch.pointerId));
return;
case LI_TOUCH_EVENT_HOVER:
case LI_TOUCH_EVENT_DOWN:
case LI_TOUCH_EVENT_MOVE:
{
lvh::TouchContact contact;
contact.id = static_cast<std::int32_t>(touch.pointerId);
contact.x = std::clamp(touch.x, 0.0F, 1.0F);
contact.y = std::clamp(touch.y, 0.0F, 1.0F);
contact.pressure = std::clamp(touch.pressureOrDistance, 0.0F, 1.0F);
contact.orientation = touch_orientation(touch.rotation);
contact.touching = touch.eventType != LI_TOUCH_EVENT_HOVER;
contact.viewport = pointer_viewport(touch_port);
contact.contact_major_axis = touch.contactAreaMajor;
contact.contact_minor_axis = touch.contactAreaMinor;
log_failure("submit libvirtualhid touch contact"sv, context.touch->place_contact(contact));
context.active_touches.insert(contact.id);
return;
}
default:
return;
}
}
void pen_update(client_context_t &context, const touch_port_t &touch_port, const pen_input_t &pen) {
if (!context.pen) {
return;
}
const auto pen_buttons = static_cast<std::byte>(pen.penButtons);
const std::array button_states {
std::pair {lvh::PenButton::primary, (pen_buttons & static_cast<std::byte>(LI_PEN_BUTTON_PRIMARY)) != std::byte {}},
std::pair {lvh::PenButton::secondary, (pen_buttons & static_cast<std::byte>(LI_PEN_BUTTON_SECONDARY)) != std::byte {}},
std::pair {lvh::PenButton::tertiary, (pen_buttons & static_cast<std::byte>(LI_PEN_BUTTON_TERTIARY)) != std::byte {}},
};
for (const auto &[button, pressed] : button_states) {
const auto was_pressed = context.pressed_pen_buttons.contains(button);
if (pressed == was_pressed) {
continue;
}
log_failure("submit libvirtualhid pen button"sv, context.pen->button(button, pressed));
if (pressed) {
context.pressed_pen_buttons.insert(button);
} else {
context.pressed_pen_buttons.erase(button);
}
}
if (pen.eventType == LI_TOUCH_EVENT_CANCEL_ALL) {
for (const auto button : context.pressed_pen_buttons) {
log_failure("release libvirtualhid pen button"sv, context.pen->button(button, false));
}
context.pressed_pen_buttons.clear();
}
using enum lvh::PointerTransition;
auto transition = update;
switch (pen.eventType) {
case LI_TOUCH_EVENT_CANCEL:
case LI_TOUCH_EVENT_CANCEL_ALL:
transition = cancel;
break;
case LI_TOUCH_EVENT_UP:
transition = release;
break;
case LI_TOUCH_EVENT_HOVER_LEAVE:
transition = leave;
break;
default:
break;
}
auto rotation = pen.rotation;
if (rotation != LI_ROT_UNKNOWN) {
rotation %= 360;
}
float tilt_x = 0.0F;
float tilt_y = 0.0F;
if (pen.tilt != LI_TILT_UNKNOWN && rotation != LI_ROT_UNKNOWN) {
const auto rotation_rads = static_cast<float>(rotation) * std::numbers::pi_v<float> / 180.0F;
const auto tilt_rads = static_cast<float>(pen.tilt) * std::numbers::pi_v<float> / 180.0F;
const auto r = std::sin(tilt_rads);
const auto z = std::cos(tilt_rads);
tilt_x = std::atan2(std::sin(-rotation_rads) * r, z) * 180.0F / std::numbers::pi_v<float>;
tilt_y = std::atan2(std::cos(-rotation_rads) * r, z) * 180.0F / std::numbers::pi_v<float>;
}
const auto is_touching = transition == update &&
(pen.eventType == LI_TOUCH_EVENT_DOWN || pen.eventType == LI_TOUCH_EVENT_MOVE);
lvh::PenToolState state;
state.tool = pen_tool(pen.toolType);
state.x = std::clamp(pen.x, 0.0F, 1.0F);
state.y = std::clamp(pen.y, 0.0F, 1.0F);
state.pressure = is_touching ? std::clamp(pen.pressureOrDistance, 0.0F, 1.0F) : -1.0F;
state.distance = is_touching ? -1.0F : std::clamp(pen.pressureOrDistance, 0.0F, 1.0F);
state.tilt_x = tilt_x;
state.tilt_y = tilt_y;
state.transition = transition;
state.viewport = pointer_viewport(touch_port);
log_failure("submit libvirtualhid pen state"sv, context.pen->place_tool(state));
}
bool configured_gamepad_supports_touchpad() {
if (config::input.gamepad == "auto"sv) {
return true;
}
const auto profile = profile_for_name(config::input.gamepad).profile();
return lvh::gamepad_profile_support(profile).supports_touchpad;
}
} // namespace platf::virtualhid
namespace platf {
#ifndef _WIN32
/**
* @brief Global libvirtualhid devices shared by clients.
*/
struct input_raw_t {
virtualhid::input_context_t virtualhid; ///< libvirtualhid input context.
};
namespace {
/**
* @brief Per-client libvirtualhid devices.
*/
struct client_input_raw_t: client_input_t {
/**
* @brief Create per-client libvirtualhid devices.
*
* @param input Platform input backend that receives the event.
*/
explicit client_input_raw_t(input_t &input):
virtualhid {input->virtualhid} {}
virtualhid::client_context_t virtualhid; ///< libvirtualhid client context.
};
} // namespace
input_t input() {
return {new input_raw_t {}};
}
std::unique_ptr<client_input_t> allocate_client_input_context(input_t &input) {
return std::make_unique<client_input_raw_t>(input);
}
void freeInput(input_raw_t *input) {
std::default_delete<input_raw_t> {}(input);
}
virtualhid::input_context_t &virtualhid::get_input_context(input_t &input) {
return input->virtualhid;
}
virtualhid::client_context_t &virtualhid::get_client_context(client_input_t *input) {
return static_cast<client_input_raw_t *>(input)->virtualhid;
}
#endif
void move_mouse(input_t &input, int deltaX, int deltaY) {
virtualhid::move_mouse(virtualhid::get_input_context(input), deltaX, deltaY);
}
void abs_mouse(input_t &input, const touch_port_t &touch_port, float x, float y) {
virtualhid::abs_mouse(virtualhid::get_input_context(input), touch_port, x, y);
}
void button_mouse(input_t &input, int button, bool release) {
virtualhid::button_mouse(virtualhid::get_input_context(input), button, release);
}
void scroll(input_t &input, int high_res_distance) {
virtualhid::scroll(virtualhid::get_input_context(input), high_res_distance);
}
void hscroll(input_t &input, int high_res_distance) {
virtualhid::hscroll(virtualhid::get_input_context(input), high_res_distance);
}
void keyboard_update(input_t &input, uint16_t modcode, bool release, uint8_t flags) {
virtualhid::keyboard_update(virtualhid::get_input_context(input), modcode, release, flags);
}
void unicode(input_t &input, const char *utf8, int size) {
virtualhid::unicode(virtualhid::get_input_context(input), utf8, size);
}
void touch_update(client_input_t *input, const touch_port_t &touch_port, const touch_input_t &touch) {
virtualhid::touch_update(virtualhid::get_client_context(input), touch_port, touch);
}
void pen_update(client_input_t *input, const touch_port_t &touch_port, const pen_input_t &pen) {
virtualhid::pen_update(virtualhid::get_client_context(input), touch_port, pen);
}
#ifndef _WIN32
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
return virtualhid::alloc_gamepad(virtualhid::get_input_context(input), id, metadata, std::move(feedback_queue));
}
int rebind_gamepad(input_t &input, const gamepad_id_t &id, feedback_queue_t feedback_queue) {
return virtualhid::rebind_gamepad(virtualhid::get_input_context(input), id, std::move(feedback_queue));
}
void free_gamepad(input_t &input, int nr) {
virtualhid::free_gamepad(virtualhid::get_input_context(input), nr);
}
void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
virtualhid::gamepad_update(virtualhid::get_input_context(input), nr, gamepad_state);
}
void gamepad_touch(input_t &input, const gamepad_touch_t &touch) {
virtualhid::gamepad_touch(virtualhid::get_input_context(input), touch);
}
void gamepad_motion(input_t &input, const gamepad_motion_t &motion) {
virtualhid::gamepad_motion(virtualhid::get_input_context(input), motion);
}
void gamepad_battery(input_t &input, const gamepad_battery_t &battery) {
virtualhid::gamepad_battery(virtualhid::get_input_context(input), battery);
}
#endif
} // namespace platf
▸
src/platform/virtualhid_input.h
+272
−0
@@ -1,0 +1,272 @@
/**
* @file src/platform/virtualhid_input.h
* @brief Declarations for libvirtualhid-backed input helpers.
*/
#pragma once
// standard includes
#include <memory>
#include <optional>
#include <set>
#include <string>
#include <vector>
// lib includes
#include <libvirtualhid/libvirtualhid.hpp>
// local includes
#include "src/platform/common.h"
namespace platf::virtualhid {
/**
* @brief Runtime and virtual devices owned by one platform input context.
*/
struct input_context_t {
/**
* @brief Construct the libvirtualhid input context using the platform-default backend.
*/
input_context_t();
/**
* @brief Construct the libvirtualhid input context using a selected backend.
*
* @param backend Backend used to create the libvirtualhid runtime.
*/
explicit input_context_t(lvh::BackendKind backend);
std::unique_ptr<lvh::Runtime> runtime; ///< libvirtualhid runtime.
std::unique_ptr<lvh::Keyboard> keyboard; ///< Shared virtual keyboard.
std::unique_ptr<lvh::Mouse> mouse; ///< Shared virtual mouse.
std::vector<std::shared_ptr<struct gamepad_context_t>> gamepads {static_cast<std::size_t>(MAX_GAMEPADS)}; ///< Virtual gamepad slots.
};
/**
* @brief Per-client virtual touch and pen state.
*/
struct client_context_t {
/**
* @brief Create per-client libvirtualhid devices.
*
* @param input Global input context.
*/
explicit client_context_t(input_context_t &input);
input_context_t *global = nullptr; ///< Shared global input context.
std::unique_ptr<lvh::Touchscreen> touch; ///< Per-client touchscreen.
std::unique_ptr<lvh::PenTablet> pen; ///< Per-client pen tablet.
std::set<std::int32_t> active_touches; ///< Active touchscreen contacts.
std::set<lvh::PenButton> pressed_pen_buttons; ///< Active pen tablet buttons.
};
/**
* @brief Return the shared libvirtualhid context from a platform input backend.
*
* @param input Platform input backend.
* @return Shared libvirtualhid input context.
*/
input_context_t &get_input_context(input_t &input);
/**
* @brief Return the per-client libvirtualhid context from a platform input backend.
*
* @param input Per-client platform input backend.
* @return Per-client libvirtualhid input context.
*/
client_context_t &get_client_context(client_input_t *input);
/**
* @brief Create a platform-default libvirtualhid runtime.
*
* @param backend Backend used to create the runtime.
* @return Runtime instance.
*/
std::unique_ptr<lvh::Runtime> create_runtime(lvh::BackendKind backend = lvh::BackendKind::platform_default);
/**
* @brief Return static gamepad choices for config validation.
*
* @return Supported gamepad choices.
*/
std::vector<supported_gamepad_t> static_supported_gamepads();
/**
* @brief Return gamepad choices and runtime availability.
*
* @param runtime Runtime to probe.
* @param fallback_vigem_available Whether Windows ViGEm fallback can create gamepads.
* @return Supported gamepad choices.
*/
std::vector<supported_gamepad_t> supported_gamepads(lvh::Runtime *runtime, bool fallback_vigem_available = false);
/**
* @brief Allocate a libvirtualhid gamepad.
*
* @param context Input context.
* @param id Sunshine gamepad identifiers.
* @param metadata Client-reported controller metadata.
* @param feedback_queue Queue used to return gamepad feedback to the client.
* @return 0 on success, otherwise -1.
*/
int alloc_gamepad(input_context_t &context, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue);
/**
* @brief Rebind an existing libvirtualhid gamepad to a resumed client session.
*
* @param context Input context.
* @param id Sunshine gamepad identifiers for the resumed client.
* @param feedback_queue Queue used to return gamepad feedback to the resumed client.
* @return 0 when the gamepad exists and was rebound; otherwise -1.
*/
int rebind_gamepad(input_context_t &context, const gamepad_id_t &id, feedback_queue_t feedback_queue);
/**
* @brief Check whether a libvirtualhid gamepad exists in a slot.
*
* @param context Input context.
* @param nr Gamepad slot index.
* @return True when a virtual gamepad is active.
*/
bool has_gamepad(const input_context_t &context, int nr);
#ifdef SUNSHINE_TESTS
/**
* @brief Return the adapter allocated in a gamepad slot for unit testing.
*
* @param context Input context.
* @param nr Gamepad slot index.
* @return Allocated adapter, or `nullptr` when the slot is inactive.
*/
lvh::GamepadStateAdapter *gamepad_adapter_for_testing(input_context_t &context, int nr);
#endif
/**
* @brief Release a libvirtualhid gamepad slot.
*
* @param context Input context.
* @param nr Gamepad slot index.
*/
void free_gamepad(input_context_t &context, int nr);
/**
* @brief Submit a full gamepad state.
*
* @param context Input context.
* @param nr Gamepad slot index.
* @param state Sunshine gamepad state.
*/
void gamepad_update(input_context_t &context, int nr, const gamepad_state_t &state);
/**
* @brief Submit a gamepad touch event.
*
* @param context Input context.
* @param touch Sunshine touch event.
*/
void gamepad_touch(input_context_t &context, const gamepad_touch_t &touch);
/**
* @brief Submit a gamepad motion event.
*
* @param context Input context.
* @param motion Sunshine motion event.
*/
void gamepad_motion(input_context_t &context, const gamepad_motion_t &motion);
/**
* @brief Submit gamepad battery metadata.
*
* @param context Input context.
* @param battery Sunshine battery event.
*/
void gamepad_battery(input_context_t &context, const gamepad_battery_t &battery);
/**
* @brief Move the virtual mouse relatively.
*
* @param context Input context.
* @param delta_x Horizontal delta.
* @param delta_y Vertical delta.
*/
void move_mouse(input_context_t &context, int delta_x, int delta_y);
/**
* @brief Move the virtual mouse absolutely inside a target touch port.
*
* @param context Input context.
* @param touch_port Target coordinate space.
* @param x Absolute X coordinate.
* @param y Absolute Y coordinate.
*/
void abs_mouse(input_context_t &context, const touch_port_t &touch_port, float x, float y);
/**
* @brief Submit a mouse button event.
*
* @param context Input context.
* @param button Moonlight mouse button.
* @param release Whether the button was released.
*/
void button_mouse(input_context_t &context, int button, bool release);
/**
* @brief Submit vertical scroll input.
*
* @param context Input context.
* @param high_res_distance High-resolution scroll distance.
*/
void scroll(input_context_t &context, int high_res_distance);
/**
* @brief Submit horizontal scroll input.
*
* @param context Input context.
* @param high_res_distance High-resolution scroll distance.
*/
void hscroll(input_context_t &context, int high_res_distance);
/**
* @brief Submit a keyboard key transition.
*
* @param context Input context.
* @param modcode Portable key code.
* @param release Whether the key was released.
* @param flags Bit flags that modify the requested operation.
*/
void keyboard_update(input_context_t &context, std::uint16_t modcode, bool release, std::uint8_t flags);
/**
* @brief Submit UTF-8 text input.
*
* @param context Input context.
* @param utf8 UTF-8 text buffer.
* @param size Text buffer size.
*/
void unicode(input_context_t &context, const char *utf8, int size);
/**
* @brief Submit a touchscreen event.
*
* @param context Client context.
* @param touch_port Touch coordinate bounds used for scaling.
* @param touch Touch event.
*/
void touch_update(client_context_t &context, const touch_port_t &touch_port, const touch_input_t &touch);
/**
* @brief Submit a pen event.
*
* @param context Client context.
* @param touch_port Touch coordinate bounds used for scaling.
* @param pen Pen event.
*/
void pen_update(client_context_t &context, const touch_port_t &touch_port, const pen_input_t &pen);
/**
* @brief Return whether the configured gamepad profile can expose touchpad input.
*
* @return True when controller touchpad input should be advertised.
*/
bool configured_gamepad_supports_touchpad();
} // namespace platf::virtualhid
▸
src/platform/windows/input.cpp
+147
−739
@@ -17,6 +17,6 @@
#include <Windows.h>
// standard includes
#include <cmath>
#include <memory>
#include <thread>
#include <vector>
@@ -25,29 +25,17 @@
#include <ViGEm/Client.h>
// local includes
#include "keylayout.h"
#include "misc.h"
#include "src/config.h"
#include "src/globals.h"
#include "src/logging.h"
#include "src/platform/common.h"
#include "src/platform/virtualhid_input.h"
namespace platf {
using namespace std::literals;
thread_local HDESK _lastKnownInputDesktop = nullptr; ///< Last known input desktop.
/**
* @brief Target touch port.
*/
constexpr touch_port_t target_touch_port {
0,
0,
65535,
65535
};
/**
* @brief ViGEm client pointer released with `vigem_free`.
*/
using client_t = util::safe_ptr<_VIGEM_CLIENT_T, vigem_free>;
@@ -269,7 +257,8 @@
VIGEM_ERROR status = vigem_connect(client.get());
if (!VIGEM_SUCCESS(status)) {
// Log a special fatal message for this case to show the error in the web UI
BOOST_LOG(fatal) << "libvirtualhid gamepad support is unavailable and ViGEmBus fallback is not installed or running"sv;
return -1;
BOOST_LOG(fatal) << "ViGEmBus is not installed or running. You must install ViGEmBus for gamepad support!"sv;
} else {
vigem_disconnect(client.get());
}
@@ -350,6 +339,36 @@
}
/**
* @brief Route an existing ViGEm gamepad's feedback to a resumed client.
*
* @param id Global and client-relative identifiers for the resumed controller.
* @param feedback_queue Queue used to return gamepad feedback to the resumed client.
* @return 0 when the target exists and was rebound; otherwise -1.
*/
int rebind_gamepad(const gamepad_id_t &id, feedback_queue_t feedback_queue) {
if (id.globalIndex < 0 || id.globalIndex >= static_cast<int>(gamepads.size())) {
return -1;
}
auto &gamepad = gamepads[id.globalIndex];
if (!gamepad.gp || !vigem_target_is_attached(gamepad.gp.get())) {
return -1;
}
gamepad.client_relative_index = id.clientRelativeIndex;
gamepad.feedback_queue = std::move(feedback_queue);
gamepad.last_rumble = {};
gamepad.last_rgb_led = {};
if (gamepad.feedback_queue && vigem_target_get_type(gamepad.gp.get()) == DualShock4Wired) {
gamepad.feedback_queue->raise(gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_ACCEL, 100));
gamepad.feedback_queue->raise(gamepad_feedback_msg_t::make_motion_event_state(id.clientRelativeIndex, LI_MOTION_TYPE_GYRO, 100));
}
return 0;
}
/**
* @brief Detaches the specified gamepad
* @param nr The gamepad.
*/
@@ -498,120 +517,65 @@
}
/**
* @brief Global inputtino device handles shared by clients.
* @brief Global virtual input device handles shared by clients.
*/
struct input_raw_t {
virtualhid::input_context_t virtualhid; ///< libvirtualhid input context.
std::unique_ptr<vigem_t> vigem; ///< ViGEm fallback context.
~input_raw_t() {
delete vigem;
}
vigem_t *vigem; ///< Vigem.
decltype(CreateSyntheticPointerDevice) *fnCreateSyntheticPointerDevice; ///< Fn create synthetic pointer device.
decltype(InjectSyntheticPointerInput) *fnInjectSyntheticPointerInput; ///< Fn inject synthetic pointer input.
decltype(DestroySyntheticPointerDevice) *fnDestroySyntheticPointerDevice; ///< Fn destroy synthetic pointer device.
};
input_t input() {
input_t result {new input_raw_t {}};
auto &raw = *(input_raw_t *) result.get();
raw.vigem = new vigem_t {};
if (raw.vigem->init()) {
delete raw.vigem;
raw.vigem = nullptr;
if (auto &raw = *result; !raw.virtualhid.runtime || !raw.virtualhid.runtime->capabilities().supports_gamepad) {
auto vigem = std::make_unique<vigem_t>();
if (!vigem->init()) {
raw.vigem = std::move(vigem);
}
}
// Get pointers to virtual touch/pen input functions (Win10 1809+)
raw.fnCreateSyntheticPointerDevice = (decltype(CreateSyntheticPointerDevice) *) GetProcAddress(GetModuleHandleA("user32.dll"), "CreateSyntheticPointerDevice");
raw.fnInjectSyntheticPointerInput = (decltype(InjectSyntheticPointerInput) *) GetProcAddress(GetModuleHandleA("user32.dll"), "InjectSyntheticPointerInput");
raw.fnDestroySyntheticPointerDevice = (decltype(DestroySyntheticPointerDevice) *) GetProcAddress(GetModuleHandleA("user32.dll"), "DestroySyntheticPointerDevice");
return result;
}
/**
* @brief Calls SendInput() and switches input desktops if required.
* @brief Check whether the configured virtual gamepad can fall back to ViGEm.
*
* @return True when the ViGEm fallback can satisfy the configured profile.
* @param i The `INPUT` struct to send.
*/
bool vigem_fallback_allowed() {
return config::input.gamepad == "auto"sv ||
void send_input(INPUT &i) {
retry:
auto send = SendInput(1, &i, sizeof(INPUT));
if (send != 1) {
auto hDesk = syncThreadDesktop();
if (_lastKnownInputDesktop != hDesk) {
_lastKnownInputDesktop = hDesk;
goto retry;
}
config::input.gamepad == "x360"sv ||
config::input.gamepad == "ds4"sv;
BOOST_LOG(error) << "Couldn't send input"sv;
}
}
/**
* @brief Create the ViGEm fallback context if it is not already available.
* @brief Calls InjectSyntheticPointerInput() and switches input desktops if required.
* @details Must only be called if InjectSyntheticPointerInput() is available.
* @param input The global input context.
* @param device The synthetic pointer device handle.
*
* @param raw Platform input context.
* @return True when ViGEm fallback is available.
* @param pointerInfo An array of `POINTER_TYPE_INFO` structs.
* @param count The number of elements in `pointerInfo`.
* @return true if input was successfully injected.
*/
bool inject_synthetic_pointer_input(input_raw_t *input, HSYNTHETICPOINTERDEVICE device, const POINTER_TYPE_INFO *pointerInfo, UINT32 count) {
retry:
if (!input->fnInjectSyntheticPointerInput(device, pointerInfo, count)) {
auto hDesk = syncThreadDesktop();
if (_lastKnownInputDesktop != hDesk) {
_lastKnownInputDesktop = hDesk;
goto retry;
}
return false;
bool ensure_vigem(input_raw_t *raw) {
if (raw->vigem) {
return true;
}
return true;
}
void abs_mouse(input_t &input, const touch_port_t &touch_port, float x, float y) {
INPUT i {};
i.type = INPUT_MOUSE;
auto &mi = i.mi;
mi.dwFlags =
MOUSEEVENTF_MOVE |
MOUSEEVENTF_ABSOLUTE |
// MOUSEEVENTF_VIRTUALDESK maps to the entirety of the desktop rather than the primary desktop
MOUSEEVENTF_VIRTUALDESK;
// Note: x and y already include the display offset (offset_x/offset_y) from client_to_touchport(),
// so we must not add offset_x/offset_y again here to avoid double-offsetting on multi-monitor setups.
auto scaled_x = std::lround(x * ((float) target_touch_port.width / (float) touch_port.width));
auto scaled_y = std::lround(y * ((float) target_touch_port.height / (float) touch_port.height));
mi.dx = scaled_x;
mi.dy = scaled_y;
auto vigem = std::make_unique<vigem_t>();
if (vigem->init()) {
return false;
}
send_input(i);
raw->vigem = std::move(vigem);
return true;
}
void move_mouse(input_t &input, int deltaX, int deltaY) {
INPUT i {};
i.type = INPUT_MOUSE;
auto &mi = i.mi;
mi.dwFlags = MOUSEEVENTF_MOVE;
mi.dx = deltaX;
mi.dy = deltaY;
send_input(i);
virtualhid::input_context_t &virtualhid::get_input_context(input_t &input) {
return input->virtualhid;
}
util::point_t get_mouse_loc(input_t &input) {
std::optional<util::point_t> get_mouse_loc(input_t & /*input*/) {
throw std::runtime_error("not implemented yet, has to pass tests");
// TODO: Tests are failing, something wrong here?
POINT p;
if (!GetCursorPos(&p)) {
return std::nullopt;
return util::point_t {0.0, 0.0};
}
return util::point_t {
@@ -619,111 +583,9 @@
(double) p.y
};
}
void button_mouse(input_t &input, int button, bool release) {
INPUT i {};
i.type = INPUT_MOUSE;
auto &mi = i.mi;
if (button == 1) {
mi.dwFlags = release ? MOUSEEVENTF_LEFTUP : MOUSEEVENTF_LEFTDOWN;
} else if (button == 2) {
mi.dwFlags = release ? MOUSEEVENTF_MIDDLEUP : MOUSEEVENTF_MIDDLEDOWN;
} else if (button == 3) {
mi.dwFlags = release ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_RIGHTDOWN;
} else if (button == 4) {
mi.dwFlags = release ? MOUSEEVENTF_XUP : MOUSEEVENTF_XDOWN;
mi.mouseData = XBUTTON1;
} else {
mi.dwFlags = release ? MOUSEEVENTF_XUP : MOUSEEVENTF_XDOWN;
mi.mouseData = XBUTTON2;
}
send_input(i);
}
void scroll(input_t &input, int distance) {
INPUT i {};
i.type = INPUT_MOUSE;
auto &mi = i.mi;
mi.dwFlags = MOUSEEVENTF_WHEEL;
mi.mouseData = distance;
send_input(i);
}
void hscroll(input_t &input, int distance) {
INPUT i {};
i.type = INPUT_MOUSE;
auto &mi = i.mi;
mi.dwFlags = MOUSEEVENTF_HWHEEL;
mi.mouseData = distance;
send_input(i);
}
void keyboard_update(input_t &input, uint16_t modcode, bool release, uint8_t flags) {
INPUT i {};
i.type = INPUT_KEYBOARD;
auto &ki = i.ki;
// If the client did not normalize this VK code to a US English layout, we can't accurately convert it to a scancode.
// If we're set to always send scancodes, we will use the current keyboard layout to convert to a scancode. This will
// assume the client and host have the same keyboard layout, but it's probably better than always using US English.
if (!(flags & SS_KBE_FLAG_NON_NORMALIZED)) {
// Mask off the extended key byte
ki.wScan = VK_TO_SCANCODE_MAP[modcode & 0xFF];
} else if (config::input.always_send_scancodes && modcode != VK_LWIN && modcode != VK_RWIN && modcode != VK_PAUSE) {
// For some reason, MapVirtualKey(VK_LWIN, MAPVK_VK_TO_VSC) doesn't seem to work :/
ki.wScan = MapVirtualKey(modcode, MAPVK_VK_TO_VSC);
}
// If we can map this to a scancode, send it as a scancode for maximum game compatibility.
if (ki.wScan) {
ki.dwFlags = KEYEVENTF_SCANCODE;
} else {
// If there is no scancode mapping or it's non-normalized, send it as a regular VK event.
ki.wVk = modcode;
}
// https://docs.microsoft.com/en-us/windows/win32/inputdev/about-keyboard-input#keystroke-message-flags
switch (modcode) {
case VK_LWIN:
case VK_RWIN:
case VK_RMENU:
case VK_RCONTROL:
case VK_INSERT:
case VK_DELETE:
case VK_HOME:
case VK_END:
case VK_PRIOR:
case VK_NEXT:
case VK_UP:
case VK_DOWN:
case VK_LEFT:
case VK_RIGHT:
case VK_DIVIDE:
case VK_APPS:
ki.dwFlags |= KEYEVENTF_EXTENDEDKEY;
break;
default:
break;
}
if (release) {
ki.dwFlags |= KEYEVENTF_KEYUP;
}
send_input(i);
}
/**
* @brief Per-client inputtino devices for touch and pen input.
* @brief Per-client virtual devices for touch and pen input.
*/
struct client_input_raw_t: public client_input_t {
/**
@@ -731,41 +593,10 @@
*
* @param input Platform input backend that receives the event.
*/
client_input_raw_t(input_t &input) {
explicit client_input_raw_t(input_t &input):
virtualhid {input->virtualhid} {}
global = (input_raw_t *) input.get();
}
virtualhid::client_context_t virtualhid; ///< libvirtualhid client context.
~client_input_raw_t() override {
if (penRepeatTask) {
task_pool.cancel(penRepeatTask);
}
if (touchRepeatTask) {
task_pool.cancel(touchRepeatTask);
}
if (pen) {
global->fnDestroySyntheticPointerDevice(pen);
}
if (touch) {
global->fnDestroySyntheticPointerDevice(touch);
}
}
input_raw_t *global;
// Device state and handles for pen and touch input must be stored in the per-client
// input context, because each connected client may be sending their own independent
// pen/touch events. To maintain separation, we expose separate pen and touch devices
// for each client.
HSYNTHETICPOINTERDEVICE pen {}; ///< Windows synthetic pointer device used for pen events.
POINTER_TYPE_INFO penInfo {}; ///< Pen info.
thread_pool_util::ThreadPool::task_id_t penRepeatTask {}; ///< Pen repeat task.
HSYNTHETICPOINTERDEVICE touch {}; ///< Windows synthetic pointer device used for touch events.
POINTER_TYPE_INFO touchInfo[10] {}; ///< Touch info.
UINT32 activeTouchSlots {}; ///< Active touch slots.
thread_pool_util::ThreadPool::task_id_t touchRepeatTask {}; ///< Touch repeat task.
};
/**
@@ -777,473 +608,26 @@
return std::make_unique<client_input_raw_t>(input);
}
/**
* @brief Compacts the touch slots into a contiguous block and updates the active count.
* @details Since this swaps entries around, all slot pointers/references are invalid after compaction.
* @param raw The client-specific input context.
*/
void perform_touch_compaction(client_input_raw_t *raw) {
// Windows requires all active touches be contiguous when fed into InjectSyntheticPointerInput().
UINT32 i;
for (i = 0; i < ARRAYSIZE(raw->touchInfo); i++) {
if (raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags == POINTER_FLAG_NONE) {
// This is an empty slot. Look for a later entry to move into this slot.
for (UINT32 j = i + 1; j < ARRAYSIZE(raw->touchInfo); j++) {
if (raw->touchInfo[j].touchInfo.pointerInfo.pointerFlags != POINTER_FLAG_NONE) {
std::swap(raw->touchInfo[i], raw->touchInfo[j]);
break;
}
}
// If we didn't find anything, we've reached the end of active slots.
if (raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags == POINTER_FLAG_NONE) {
break;
}
}
}
// Update the number of active touch slots
raw->activeTouchSlots = i;
}
/**
* @brief Gets a pointer slot by client-relative pointer ID, claiming a new one if necessary.
* @param raw The raw client-specific input context.
* @param pointerId The client's pointer ID.
* @param eventType The LI_TOUCH_EVENT value from the client.
* @return A pointer to the slot entry.
*/
POINTER_TYPE_INFO *pointer_by_id(client_input_raw_t *raw, uint32_t pointerId, uint8_t eventType) {
// Compact active touches into a single contiguous block
perform_touch_compaction(raw);
// Try to find a matching pointer ID
for (UINT32 i = 0; i < ARRAYSIZE(raw->touchInfo); i++) {
if (raw->touchInfo[i].touchInfo.pointerInfo.pointerId == pointerId && raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags != POINTER_FLAG_NONE) {
if (eventType == LI_TOUCH_EVENT_DOWN && (raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags & POINTER_FLAG_INCONTACT)) {
BOOST_LOG(warning) << "Pointer "sv << pointerId << " already down. Did the client drop an up/cancel event?"sv;
}
return &raw->touchInfo[i];
}
}
if (eventType != LI_TOUCH_EVENT_HOVER && eventType != LI_TOUCH_EVENT_DOWN) {
BOOST_LOG(warning) << "Unexpected new pointer "sv << pointerId << " for event "sv << (uint32_t) eventType << ". Did the client drop a down/hover event?"sv;
}
// If there was none, grab an unused entry and increment the active slot count
for (UINT32 i = 0; i < ARRAYSIZE(raw->touchInfo); i++) {
if (raw->touchInfo[i].touchInfo.pointerInfo.pointerFlags == POINTER_FLAG_NONE) {
raw->touchInfo[i].touchInfo.pointerInfo.pointerId = pointerId;
raw->activeTouchSlots = i + 1;
return &raw->touchInfo[i];
}
}
return nullptr;
}
/**
* @brief Populate common `POINTER_INFO` members shared between pen and touch events.
* @param pointerInfo The pointer info to populate.
* @param touchPort The current viewport for translating to screen coordinates.
* @param eventType The type of touch/pen event.
* @param x The normalized 0.0-1.0 X coordinate.
* @param y The normalized 0.0-1.0 Y coordinate.
*/
void populate_common_pointer_info(POINTER_INFO &pointerInfo, const touch_port_t &touchPort, uint8_t eventType, float x, float y) {
switch (eventType) {
case LI_TOUCH_EVENT_HOVER:
pointerInfo.pointerFlags &= ~POINTER_FLAG_INCONTACT;
pointerInfo.pointerFlags |= POINTER_FLAG_INRANGE | POINTER_FLAG_UPDATE;
pointerInfo.ptPixelLocation.x = x * touchPort.width + touchPort.offset_x;
pointerInfo.ptPixelLocation.y = y * touchPort.height + touchPort.offset_y;
break;
case LI_TOUCH_EVENT_DOWN:
pointerInfo.pointerFlags |= POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_DOWN;
pointerInfo.ptPixelLocation.x = x * touchPort.width + touchPort.offset_x;
pointerInfo.ptPixelLocation.y = y * touchPort.height + touchPort.offset_y;
break;
case LI_TOUCH_EVENT_UP:
// We expect to get another LI_TOUCH_EVENT_HOVER if the pointer remains in range
pointerInfo.pointerFlags &= ~(POINTER_FLAG_INCONTACT | POINTER_FLAG_INRANGE);
pointerInfo.pointerFlags |= POINTER_FLAG_UP;
break;
case LI_TOUCH_EVENT_MOVE:
pointerInfo.pointerFlags |= POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT | POINTER_FLAG_UPDATE;
pointerInfo.ptPixelLocation.x = x * touchPort.width + touchPort.offset_x;
pointerInfo.ptPixelLocation.y = y * touchPort.height + touchPort.offset_y;
break;
case LI_TOUCH_EVENT_CANCEL:
case LI_TOUCH_EVENT_CANCEL_ALL:
// If we were in contact with the touch surface at the time of the cancellation,
// we'll set POINTER_FLAG_UP, otherwise set POINTER_FLAG_UPDATE.
if (pointerInfo.pointerFlags & POINTER_FLAG_INCONTACT) {
pointerInfo.pointerFlags |= POINTER_FLAG_UP;
} else {
pointerInfo.pointerFlags |= POINTER_FLAG_UPDATE;
}
pointerInfo.pointerFlags &= ~(POINTER_FLAG_INCONTACT | POINTER_FLAG_INRANGE);
pointerInfo.pointerFlags |= POINTER_FLAG_CANCELED;
break;
case LI_TOUCH_EVENT_HOVER_LEAVE:
pointerInfo.pointerFlags &= ~(POINTER_FLAG_INCONTACT | POINTER_FLAG_INRANGE);
pointerInfo.pointerFlags |= POINTER_FLAG_UPDATE;
break;
case LI_TOUCH_EVENT_BUTTON_ONLY:
// On Windows, we can only pass buttons if we have an active pointer
if (pointerInfo.pointerFlags != POINTER_FLAG_NONE) {
pointerInfo.pointerFlags |= POINTER_FLAG_UPDATE;
}
break;
default:
BOOST_LOG(warning) << "Unknown touch event: "sv << (uint32_t) eventType;
break;
}
}
// Active pointer interactions sent via InjectSyntheticPointerInput() seem to be automatically
// cancelled by Windows if not repeated/updated within about a second. To avoid this, refresh
// the injected input periodically.
constexpr auto ISPI_REPEAT_INTERVAL = 50ms; ///< Protocol or platform constant for ispi repeat interval.
/**
* @brief Repeats the current touch state to avoid the interactions timing out.
* @param raw The raw client-specific input context.
*/
void repeat_touch(client_input_raw_t *raw) {
if (!inject_synthetic_pointer_input(raw->global, raw->touch, raw->touchInfo, raw->activeTouchSlots)) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to refresh virtual touch input: "sv << err;
}
raw->touchRepeatTask = task_pool.pushDelayed(repeat_touch, ISPI_REPEAT_INTERVAL, raw).task_id;
}
/**
* @brief Repeats the current pen state to avoid the interactions timing out.
* @param raw The raw client-specific input context.
*/
void repeat_pen(client_input_raw_t *raw) {
if (!inject_synthetic_pointer_input(raw->global, raw->pen, &raw->penInfo, 1)) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to refresh virtual pen input: "sv << err;
}
raw->penRepeatTask = task_pool.pushDelayed(repeat_pen, ISPI_REPEAT_INTERVAL, raw).task_id;
}
/**
* @brief Cancels all active touches.
* @param raw The raw client-specific input context.
*/
void cancel_all_active_touches(client_input_raw_t *raw) {
// Cancel touch repeat callbacks
if (raw->touchRepeatTask) {
task_pool.cancel(raw->touchRepeatTask);
raw->touchRepeatTask = nullptr;
}
// Compact touches to update activeTouchSlots
perform_touch_compaction(raw);
// If we have active slots, cancel them all
if (raw->activeTouchSlots > 0) {
for (UINT32 i = 0; i < raw->activeTouchSlots; i++) {
populate_common_pointer_info(raw->touchInfo[i].touchInfo.pointerInfo, {}, LI_TOUCH_EVENT_CANCEL_ALL, 0.0f, 0.0f);
raw->touchInfo[i].touchInfo.touchMask = TOUCH_MASK_NONE;
}
if (!inject_synthetic_pointer_input(raw->global, raw->touch, raw->touchInfo, raw->activeTouchSlots)) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to cancel all virtual touch input: "sv << err;
}
}
// Zero all touch state
std::memset(raw->touchInfo, 0, sizeof(raw->touchInfo));
raw->activeTouchSlots = 0;
}
// These are edge-triggered pointer state flags that should always be cleared next frame
constexpr auto EDGE_TRIGGERED_POINTER_FLAGS = POINTER_FLAG_DOWN | POINTER_FLAG_UP | POINTER_FLAG_CANCELED | POINTER_FLAG_UPDATE; ///< Protocol or platform constant for edge triggered pointer flags.
/**
* @brief Sends a touch event to the OS.
* @param input The client-specific input context.
* @param touch_port The current viewport for translating to screen coordinates.
* @param touch The touch event.
*/
void touch_update(client_input_t *input, const touch_port_t &touch_port, const touch_input_t &touch) {
auto raw = (client_input_raw_t *) input;
// Bail if we're not running on an OS that supports virtual touch input
if (!raw->global->fnCreateSyntheticPointerDevice || !raw->global->fnInjectSyntheticPointerInput || !raw->global->fnDestroySyntheticPointerDevice) {
BOOST_LOG(warning) << "Touch input requires Windows 10 1809 or later"sv;
return;
}
// If there's not already a virtual touch device, create one now
if (!raw->touch) {
if (touch.eventType != LI_TOUCH_EVENT_CANCEL_ALL) {
BOOST_LOG(info) << "Creating virtual touch input device"sv;
raw->touch = raw->global->fnCreateSyntheticPointerDevice(PT_TOUCH, ARRAYSIZE(raw->touchInfo), POINTER_FEEDBACK_DEFAULT);
if (!raw->touch) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to create virtual touch device: "sv << err;
return;
}
} else {
// No need to cancel anything if we had no touch input device
return;
}
}
// Cancel touch repeat callbacks
if (raw->touchRepeatTask) {
task_pool.cancel(raw->touchRepeatTask);
raw->touchRepeatTask = nullptr;
}
// If this is a special request to cancel all touches, do that and return
if (touch.eventType == LI_TOUCH_EVENT_CANCEL_ALL) {
cancel_all_active_touches(raw);
return;
}
// Find or allocate an entry for this touch pointer ID
auto pointer = pointer_by_id(raw, touch.pointerId, touch.eventType);
if (!pointer) {
BOOST_LOG(error) << "No unused pointer entries! Cancelling all active touches!"sv;
cancel_all_active_touches(raw);
pointer = pointer_by_id(raw, touch.pointerId, touch.eventType);
}
pointer->type = PT_TOUCH;
auto &touchInfo = pointer->touchInfo;
touchInfo.pointerInfo.pointerType = PT_TOUCH;
// Populate shared pointer info fields
populate_common_pointer_info(touchInfo.pointerInfo, touch_port, touch.eventType, touch.x, touch.y);
touchInfo.touchMask = TOUCH_MASK_NONE;
// Pressure and contact area only apply to in-contact pointers.
//
// The clients also pass distance and tool size for hovers, but Windows doesn't
// provide APIs to receive that data.
if (touchInfo.pointerInfo.pointerFlags & POINTER_FLAG_INCONTACT) {
if (touch.pressureOrDistance != 0.0f) {
touchInfo.touchMask |= TOUCH_MASK_PRESSURE;
// Convert the 0.0f..1.0f float to the 0..1024 range that Windows uses
touchInfo.pressure = (UINT32) (touch.pressureOrDistance * 1024);
} else {
// The default touch pressure is 512
touchInfo.pressure = 512;
}
if (touch.contactAreaMajor != 0.0f && touch.contactAreaMinor != 0.0f) {
// For the purposes of contact area calculation, we will assume the touches
// are at a 45 degree angle if rotation is unknown. This will scale the major
// axis value by width and height equally.
float rotationAngleDegs = touch.rotation == LI_ROT_UNKNOWN ? 45 : touch.rotation;
float majorAxisAngle = rotationAngleDegs * (M_PI / 180);
float minorAxisAngle = majorAxisAngle + (M_PI / 2);
// Estimate the contact rectangle
float contactWidth = (std::cos(majorAxisAngle) * touch.contactAreaMajor) + (std::cos(minorAxisAngle) * touch.contactAreaMinor);
float contactHeight = (std::sin(majorAxisAngle) * touch.contactAreaMajor) + (std::sin(minorAxisAngle) * touch.contactAreaMinor);
// Convert into screen coordinates centered at the touch location and constrained by screen dimensions
touchInfo.rcContact.left = std::max<LONG>(touch_port.offset_x, touchInfo.pointerInfo.ptPixelLocation.x - std::floor(contactWidth / 2));
touchInfo.rcContact.right = std::min<LONG>(touch_port.offset_x + touch_port.width, touchInfo.pointerInfo.ptPixelLocation.x + std::ceil(contactWidth / 2));
touchInfo.rcContact.top = std::max<LONG>(touch_port.offset_y, touchInfo.pointerInfo.ptPixelLocation.y - std::floor(contactHeight / 2));
touchInfo.rcContact.bottom = std::min<LONG>(touch_port.offset_y + touch_port.height, touchInfo.pointerInfo.ptPixelLocation.y + std::ceil(contactHeight / 2));
touchInfo.touchMask |= TOUCH_MASK_CONTACTAREA;
}
} else {
touchInfo.pressure = 0;
touchInfo.rcContact = {};
}
if (touch.rotation != LI_ROT_UNKNOWN) {
touchInfo.touchMask |= TOUCH_MASK_ORIENTATION;
touchInfo.orientation = touch.rotation;
} else {
touchInfo.orientation = 0;
}
virtualhid::client_context_t &virtualhid::get_client_context(client_input_t *input) {
return static_cast<client_input_raw_t *>(input)->virtualhid;
if (!inject_synthetic_pointer_input(raw->global, raw->touch, raw->touchInfo, raw->activeTouchSlots)) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to inject virtual touch input: "sv << err;
return;
}
// Clear pointer flags that should only remain set for one frame
touchInfo.pointerInfo.pointerFlags &= ~EDGE_TRIGGERED_POINTER_FLAGS;
// If we still have an active touch, refresh the touch state periodically
if (raw->activeTouchSlots > 1 || touchInfo.pointerInfo.pointerFlags != POINTER_FLAG_NONE) {
raw->touchRepeatTask = task_pool.pushDelayed(repeat_touch, ISPI_REPEAT_INTERVAL, raw).task_id;
}
}
/**
* @brief Sends a pen event to the OS.
* @param input The client-specific input context.
* @param touch_port The current viewport for translating to screen coordinates.
* @param pen The pen event.
*/
void pen_update(client_input_t *input, const touch_port_t &touch_port, const pen_input_t &pen) {
auto raw = (client_input_raw_t *) input;
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
auto raw = (input_raw_t *) input.get();
if (virtualhid::alloc_gamepad(raw->virtualhid, id, metadata, feedback_queue) == 0) {
// Bail if we're not running on an OS that supports virtual pen input
if (!raw->global->fnCreateSyntheticPointerDevice || !raw->global->fnInjectSyntheticPointerInput || !raw->global->fnDestroySyntheticPointerDevice) {
return 0;
BOOST_LOG(warning) << "Pen input requires Windows 10 1809 or later"sv;
return;
}
// If there's not already a virtual pen device, create one now
if (!raw->pen) {
if (pen.eventType != LI_TOUCH_EVENT_CANCEL_ALL) {
BOOST_LOG(info) << "Creating virtual pen input device"sv;
raw->pen = raw->global->fnCreateSyntheticPointerDevice(PT_PEN, 1, POINTER_FEEDBACK_DEFAULT);
if (!raw->pen) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to create virtual pen device: "sv << err;
return;
if (!vigem_fallback_allowed()) {
BOOST_LOG(warning) << "libvirtualhid could not create the requested gamepad profile, and ViGEm fallback cannot emulate "sv << config::input.gamepad;
return -1;
}
} else {
// No need to cancel anything if we had no pen input device
return;
}
}
if (!ensure_vigem(raw)) {
return -1;
// Cancel pen repeat callbacks
if (raw->penRepeatTask) {
task_pool.cancel(raw->penRepeatTask);
raw->penRepeatTask = nullptr;
}
raw->penInfo.type = PT_PEN;
auto &penInfo = raw->penInfo.penInfo;
penInfo.pointerInfo.pointerType = PT_PEN;
penInfo.pointerInfo.pointerId = 0;
// Populate shared pointer info fields
populate_common_pointer_info(penInfo.pointerInfo, touch_port, pen.eventType, pen.x, pen.y);
// Windows only supports a single pen button, so send all buttons as the barrel button
if (pen.penButtons) {
penInfo.penFlags |= PEN_FLAG_BARREL;
} else {
penInfo.penFlags &= ~PEN_FLAG_BARREL;
}
switch (pen.toolType) {
default:
case LI_TOOL_TYPE_PEN:
penInfo.penFlags &= ~PEN_FLAG_ERASER;
break;
case LI_TOOL_TYPE_ERASER:
penInfo.penFlags |= PEN_FLAG_ERASER;
break;
case LI_TOOL_TYPE_UNKNOWN:
// Leave tool flags alone
break;
}
penInfo.penMask = PEN_MASK_NONE;
// Windows doesn't support hover distance, so only pass pressure/distance when the pointer is in contact
if ((penInfo.pointerInfo.pointerFlags & POINTER_FLAG_INCONTACT) && pen.pressureOrDistance != 0.0f) {
penInfo.penMask |= PEN_MASK_PRESSURE;
// Convert the 0.0f..1.0f float to the 0..1024 range that Windows uses
penInfo.pressure = (UINT32) (pen.pressureOrDistance * 1024);
} else {
// The default pen pressure is 0
penInfo.pressure = 0;
}
if (pen.rotation != LI_ROT_UNKNOWN) {
penInfo.penMask |= PEN_MASK_ROTATION;
penInfo.rotation = pen.rotation;
} else {
penInfo.rotation = 0;
}
// We require rotation and tilt to perform the conversion to X and Y tilt angles
if (pen.tilt != LI_TILT_UNKNOWN && pen.rotation != LI_ROT_UNKNOWN) {
auto rotationRads = pen.rotation * (M_PI / 180.f);
auto tiltRads = pen.tilt * (M_PI / 180.f);
auto r = std::sin(tiltRads);
auto z = std::cos(tiltRads);
// Convert polar coordinates into X and Y tilt angles
penInfo.penMask |= PEN_MASK_TILT_X | PEN_MASK_TILT_Y;
penInfo.tiltX = (INT32) (std::atan2(std::sin(-rotationRads) * r, z) * 180.f / M_PI);
penInfo.tiltY = (INT32) (std::atan2(std::cos(-rotationRads) * r, z) * 180.f / M_PI);
} else {
penInfo.tiltX = 0;
penInfo.tiltY = 0;
}
if (!inject_synthetic_pointer_input(raw->global, raw->pen, &raw->penInfo, 1)) {
auto err = GetLastError();
BOOST_LOG(warning) << "Failed to inject virtual pen input: "sv << err;
return;
}
// Clear pointer flags that should only remain set for one frame
penInfo.pointerInfo.pointerFlags &= ~EDGE_TRIGGERED_POINTER_FLAGS;
// If we still have an active pen interaction, refresh the pen state periodically
if (penInfo.pointerInfo.pointerFlags != POINTER_FLAG_NONE) {
raw->penRepeatTask = task_pool.pushDelayed(repeat_pen, ISPI_REPEAT_INTERVAL, raw).task_id;
}
}
void unicode(input_t &input, char *utf8, int size) {
// We can do no worse than one UTF-16 character per byte of UTF-8
std::vector<WCHAR> wide(size);
int chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, size, wide.data(), size);
if (chars <= 0) {
return;
}
// Send all key down events
for (int i = 0; i < chars; i++) {
INPUT input {};
input.type = INPUT_KEYBOARD;
input.ki.wScan = wide[i];
input.ki.dwFlags = KEYEVENTF_UNICODE;
send_input(input);
}
// Send all key up events
for (int i = 0; i < chars; i++) {
INPUT input {};
input.type = INPUT_KEYBOARD;
input.ki.wScan = wide[i];
input.ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
send_input(input);
}
}
int alloc_gamepad(input_t &input, const gamepad_id_t &id, const gamepad_arrival_t &metadata, feedback_queue_t feedback_queue) {
auto raw = (input_raw_t *) input.get();
if (!raw->vigem) {
return 0;
}
VIGEM_TARGET_TYPE selectedGamepadType;
if (config::input.gamepad == "x360"sv) {
@@ -1291,8 +675,27 @@
return raw->vigem->alloc_gamepad_internal(id, feedback_queue, selectedGamepadType);
}
int rebind_gamepad(input_t &input, const gamepad_id_t &id, feedback_queue_t feedback_queue) {
auto raw = (input_raw_t *) input.get();
if (virtualhid::has_gamepad(raw->virtualhid, id.globalIndex)) {
return virtualhid::rebind_gamepad(raw->virtualhid, id, std::move(feedback_queue));
}
if (!raw->vigem) {
return -1;
}
return raw->vigem->rebind_gamepad(id, std::move(feedback_queue));
}
void free_gamepad(input_t &input, int nr) {
auto raw = (input_raw_t *) input.get();
if (virtualhid::has_gamepad(raw->virtualhid, nr)) {
virtualhid::free_gamepad(raw->virtualhid, nr);
return;
}
if (!raw->vigem) {
return;
@@ -1471,7 +874,7 @@
buttons |= DS4_SPECIAL_BUTTON_TOUCHPAD;
}
// Manual DS4 emulation: check if BACK button should also trigger DS4 touchpad click
if (config::input.gamepad == "ds4"sv && config::input.ds4_back_as_touchpad_click && (gamepad_state.buttonFlags & BACK)) {
// PlayStation-style emulation: check if BACK should also trigger touchpad click
if (config::input.ds4_back_as_touchpad_click && virtualhid::configured_gamepad_supports_touchpad() && (gamepad_state.buttonFlags & BACK)) {
buttons |= DS4_SPECIAL_BUTTON_TOUCHPAD;
}
@@ -1552,7 +955,13 @@
* @param gamepad_state The gamepad button/axis state sent from the client.
*/
void gamepad_update(input_t &input, int nr, const gamepad_state_t &gamepad_state) {
auto raw = (input_raw_t *) input.get();
if (virtualhid::has_gamepad(raw->virtualhid, nr)) {
virtualhid::gamepad_update(raw->virtualhid, nr, gamepad_state);
return;
}
auto *vigem = raw->vigem.get();
auto vigem = ((input_raw_t *) input.get())->vigem;
// If there is no gamepad support
if (!vigem) {
@@ -1584,8 +993,14 @@
* @param touch The touch event.
*/
void gamepad_touch(input_t &input, const gamepad_touch_t &touch) {
auto raw = (input_raw_t *) input.get();
auto vigem = ((input_raw_t *) input.get())->vigem;
if (virtualhid::has_gamepad(raw->virtualhid, touch.id.globalIndex)) {
virtualhid::gamepad_touch(raw->virtualhid, touch);
return;
}
auto *vigem = raw->vigem.get();
// If there is no gamepad support
if (!vigem) {
return;
@@ -1690,7 +1105,13 @@
* @param motion The motion event.
*/
void gamepad_motion(input_t &input, const gamepad_motion_t &motion) {
auto raw = (input_raw_t *) input.get();
auto vigem = ((input_raw_t *) input.get())->vigem;
if (virtualhid::has_gamepad(raw->virtualhid, motion.id.globalIndex)) {
virtualhid::gamepad_motion(raw->virtualhid, motion);
return;
}
auto *vigem = raw->vigem.get();
// If there is no gamepad support
if (!vigem) {
@@ -1717,8 +1138,14 @@
* @param battery The battery event.
*/
void gamepad_battery(input_t &input, const gamepad_battery_t &battery) {
auto vigem = ((input_raw_t *) input.get())->vigem;
auto raw = (input_raw_t *) input.get();
if (virtualhid::has_gamepad(raw->virtualhid, battery.id.globalIndex)) {
virtualhid::gamepad_battery(raw->virtualhid, battery);
return;
}
auto *vigem = raw->vigem.get();
// If there is no gamepad support
if (!vigem) {
return;
@@ -1784,39 +1211,18 @@
ds4_update_ts_and_send(vigem, battery.id.globalIndex);
}
void freeInput(void *p) {
void freeInput(input_raw_t *input) {
auto input = (input_raw_t *) p;
delete input;
std::default_delete<input_raw_t> {}(input);
}
std::vector<supported_gamepad_t> &supported_gamepads(input_t *input) {
if (!input) {
static std::vector gps {
supported_gamepad_t {"auto", true, ""},
supported_gamepad_t {"x360", false, ""},
supported_gamepad_t {"ds4", false, ""},
};
static std::vector<supported_gamepad_t> gps;
if (!input || !input->get()) {
gps = virtualhid::static_supported_gamepads();
return gps;
}
auto vigem = ((input_raw_t *) input)->vigem;
auto enabled = vigem != nullptr;
auto reason = enabled ? "" : "gamepads.vigem-not-available";
// ds4 == ps4
static std::vector gps {
supported_gamepad_t {"auto", true, reason},
supported_gamepad_t {"x360", enabled, reason},
const auto raw = (input_raw_t *) input->get();
gps = virtualhid::supported_gamepads(raw->virtualhid.runtime.get(), raw->vigem != nullptr);
supported_gamepad_t {"ds4", enabled, reason}
};
for (auto &[name, is_enabled, reason_disabled] : gps) {
if (!is_enabled) {
BOOST_LOG(warning) << "Gamepad " << name << " is disabled due to " << reason_disabled;
}
}
return gps;
}
@@ -1828,18 +1234,20 @@
platform_caps::caps_t get_capabilities() {
platform_caps::caps_t caps = 0;
// We support controller touchpad input as long as we're not emulating X360
if (virtualhid::configured_gamepad_supports_touchpad()) {
if (config::input.gamepad != "x360"sv) {
caps |= platform_caps::controller_touch;
}
if (config::input.native_pen_touch) {
const auto runtime = virtualhid::create_runtime();
if (runtime) {
const auto &capabilities = runtime->capabilities();
if (capabilities.supports_touchscreen || capabilities.supports_pen_tablet) {
caps |= platform_caps::pen_touch;
}
} else {
BOOST_LOG(warning) << "Unable to create libvirtualhid runtime for touch/pen capability detection"sv;
// We support pen and touch input on Win10 1809+
if (GetProcAddress(GetModuleHandleA("user32.dll"), "CreateSyntheticPointerDevice") != nullptr) {
if (config::input.native_pen_touch) {
caps |= platform_caps::pen_touch;
}
} else {
BOOST_LOG(warning) << "Touch input requires Windows 10 1809 or later"sv;
}
return caps;
▸
src/platform/windows/keylayout.h
+0
−275
@@ -1,275 +1,0 @@
/**
* @file src/platform/windows/keylayout.h
* @brief Keyboard layout mapping for scancode translation
*/
#pragma once
// standard includes
#include <array>
#include <cstdint>
namespace platf {
// Virtual Key to Scan Code mapping for the US English layout (00000409).
// GameStream uses this as the canonical key layout for scancode conversion.
/**
* @brief Protocol or platform constant for vk to scancode map.
*/
constexpr std::array<std::uint8_t, std::numeric_limits<std::uint8_t>::max() + 1> VK_TO_SCANCODE_MAP {
0, /* 0x00 */
0, /* 0x01 */
0, /* 0x02 */
70, /* 0x03 */
0, /* 0x04 */
0, /* 0x05 */
0, /* 0x06 */
0, /* 0x07 */
14, /* 0x08 */
15, /* 0x09 */
0, /* 0x0a */
0, /* 0x0b */
76, /* 0x0c */
28, /* 0x0d */
0, /* 0x0e */
0, /* 0x0f */
42, /* 0x10 */
29, /* 0x11 */
56, /* 0x12 */
0, /* 0x13 */
58, /* 0x14 */
0, /* 0x15 */
0, /* 0x16 */
0, /* 0x17 */
0, /* 0x18 */
0, /* 0x19 */
0, /* 0x1a */
1, /* 0x1b */
0, /* 0x1c */
0, /* 0x1d */
0, /* 0x1e */
0, /* 0x1f */
57, /* 0x20 */
73, /* 0x21 */
81, /* 0x22 */
79, /* 0x23 */
71, /* 0x24 */
75, /* 0x25 */
72, /* 0x26 */
77, /* 0x27 */
80, /* 0x28 */
0, /* 0x29 */
0, /* 0x2a */
0, /* 0x2b */
84, /* 0x2c */
82, /* 0x2d */
83, /* 0x2e */
99, /* 0x2f */
11, /* 0x30 */
2, /* 0x31 */
3, /* 0x32 */
4, /* 0x33 */
5, /* 0x34 */
6, /* 0x35 */
7, /* 0x36 */
8, /* 0x37 */
9, /* 0x38 */
10, /* 0x39 */
0, /* 0x3a */
0, /* 0x3b */
0, /* 0x3c */
0, /* 0x3d */
0, /* 0x3e */
0, /* 0x3f */
0, /* 0x40 */
30, /* 0x41 */
48, /* 0x42 */
46, /* 0x43 */
32, /* 0x44 */
18, /* 0x45 */
33, /* 0x46 */
34, /* 0x47 */
35, /* 0x48 */
23, /* 0x49 */
36, /* 0x4a */
37, /* 0x4b */
38, /* 0x4c */
50, /* 0x4d */
49, /* 0x4e */
24, /* 0x4f */
25, /* 0x50 */
16, /* 0x51 */
19, /* 0x52 */
31, /* 0x53 */
20, /* 0x54 */
22, /* 0x55 */
47, /* 0x56 */
17, /* 0x57 */
45, /* 0x58 */
21, /* 0x59 */
44, /* 0x5a */
91, /* 0x5b */
92, /* 0x5c */
93, /* 0x5d */
0, /* 0x5e */
95, /* 0x5f */
82, /* 0x60 */
79, /* 0x61 */
80, /* 0x62 */
81, /* 0x63 */
75, /* 0x64 */
76, /* 0x65 */
77, /* 0x66 */
71, /* 0x67 */
72, /* 0x68 */
73, /* 0x69 */
55, /* 0x6a */
78, /* 0x6b */
0, /* 0x6c */
74, /* 0x6d */
83, /* 0x6e */
53, /* 0x6f */
59, /* 0x70 */
60, /* 0x71 */
61, /* 0x72 */
62, /* 0x73 */
63, /* 0x74 */
64, /* 0x75 */
65, /* 0x76 */
66, /* 0x77 */
67, /* 0x78 */
68, /* 0x79 */
87, /* 0x7a */
88, /* 0x7b */
100, /* 0x7c */
101, /* 0x7d */
102, /* 0x7e */
103, /* 0x7f */
104, /* 0x80 */
105, /* 0x81 */
106, /* 0x82 */
107, /* 0x83 */
108, /* 0x84 */
109, /* 0x85 */
110, /* 0x86 */
118, /* 0x87 */
0, /* 0x88 */
0, /* 0x89 */
0, /* 0x8a */
0, /* 0x8b */
0, /* 0x8c */
0, /* 0x8d */
0, /* 0x8e */
0, /* 0x8f */
69, /* 0x90 */
70, /* 0x91 */
0, /* 0x92 */
0, /* 0x93 */
0, /* 0x94 */
0, /* 0x95 */
0, /* 0x96 */
0, /* 0x97 */
0, /* 0x98 */
0, /* 0x99 */
0, /* 0x9a */
0, /* 0x9b */
0, /* 0x9c */
0, /* 0x9d */
0, /* 0x9e */
0, /* 0x9f */
42, /* 0xa0 */
54, /* 0xa1 */
29, /* 0xa2 */
29, /* 0xa3 */
56, /* 0xa4 */
56, /* 0xa5 */
106, /* 0xa6 */
105, /* 0xa7 */
103, /* 0xa8 */
104, /* 0xa9 */
101, /* 0xaa */
102, /* 0xab */
50, /* 0xac */
32, /* 0xad */
46, /* 0xae */
48, /* 0xaf */
25, /* 0xb0 */
16, /* 0xb1 */
36, /* 0xb2 */
34, /* 0xb3 */
108, /* 0xb4 */
109, /* 0xb5 */
107, /* 0xb6 */
33, /* 0xb7 */
0, /* 0xb8 */
0, /* 0xb9 */
39, /* 0xba */
13, /* 0xbb */
51, /* 0xbc */
12, /* 0xbd */
52, /* 0xbe */
53, /* 0xbf */
41, /* 0xc0 */
115, /* 0xc1 */
126, /* 0xc2 */
0, /* 0xc3 */
0, /* 0xc4 */
0, /* 0xc5 */
0, /* 0xc6 */
0, /* 0xc7 */
0, /* 0xc8 */
0, /* 0xc9 */
0, /* 0xca */
0, /* 0xcb */
0, /* 0xcc */
0, /* 0xcd */
0, /* 0xce */
0, /* 0xcf */
0, /* 0xd0 */
0, /* 0xd1 */
0, /* 0xd2 */
0, /* 0xd3 */
0, /* 0xd4 */
0, /* 0xd5 */
0, /* 0xd6 */
0, /* 0xd7 */
0, /* 0xd8 */
0, /* 0xd9 */
0, /* 0xda */
26, /* 0xdb */
43, /* 0xdc */
27, /* 0xdd */
40, /* 0xde */
0, /* 0xdf */
0, /* 0xe0 */
0, /* 0xe1 */
86, /* 0xe2 */
0, /* 0xe3 */
0, /* 0xe4 */
0, /* 0xe5 */
0, /* 0xe6 */
0, /* 0xe7 */
0, /* 0xe8 */
113, /* 0xe9 */
92, /* 0xea */
123, /* 0xeb */
0, /* 0xec */
111, /* 0xed */
90, /* 0xee */
0, /* 0xef */
0, /* 0xf0 */
91, /* 0xf1 */
0, /* 0xf2 */
95, /* 0xf3 */
0, /* 0xf4 */
94, /* 0xf5 */
0, /* 0xf6 */
0, /* 0xf7 */
0, /* 0xf8 */
93, /* 0xf9 */
0, /* 0xfa */
98, /* 0xfb */
0, /* 0xfc */
0, /* 0xfd */
0, /* 0xfe */
0, /* 0xff */
};
} // namespace platf
▸
src/process.cpp
+2
−0
@@ -25,6 +25,7 @@
#include "config.h"
#include "crypto.h"
#include "display_device.h"
#include "input.h"
#include "logging.h"
#include "platform/common.h"
#include "process.h"
@@ -318,6 +319,7 @@
}
void proc_t::terminate() {
input::terminate_gamepads();
std::error_code ec;
placebo = false;
terminate_process_group(_process, _process_group, _app.exit_timeout);
▸
src/rtsp.cpp
+2
−0
@@ -758,6 +758,7 @@
void terminate_sessions() {
server.clear(true);
input::terminate_gamepads();
}
/**
@@ -765,6 +766,7 @@
*/
void terminate_sessions_by_cert(std::string_view cert) {
server.clear_by_cert(cert);
input::terminate_gamepads(cert);
}
/**
▸
src/stream.cpp
+6
−3
@@ -536,6 +536,7 @@
std::uint32_t launch_session_id; ///< RTSP launch-session ID associated with this stream.
std::string client_cert; ///< PEM certificate for the paired client owning the stream.
std::string input_session_id; ///< Stable client identity used to retain input devices across resume.
safe::mail_raw_t::event_t<bool> shutdown_event; ///< Event raised when the stream should shut down.
safe::signal_t controlEnd; ///< Signal raised when the control channel exits.
@@ -1805,8 +1806,8 @@
// but we can simply replace it with the matrix generated by OpenFEC which
// works correctly. This is possible because the data and FEC shard count is
// constant and known in advance.
constexpr std::array<unsigned char, 8> parity {0x77, 0x40, 0x38, 0x0e, 0xc7, 0xa7, 0x0d, 0x6c};
memcpy(rs.get()->p, parity.data(), parity.size());
const unsigned char parity[] = {0x77, 0x40, 0x38, 0x0e, 0xc7, 0xa7, 0x0d, 0x6c};
memcpy(rs.get()->p, parity, sizeof(parity));
audio_packet.rtp.header = 0x80;
audio_packet.rtp.packetType = 97;
@@ -2191,6 +2192,7 @@
} else {
// We have no app running and also no clients anymore.
revert_display_config = true;
input::terminate_gamepads();
}
if (revert_display_config) {
@@ -2207,7 +2209,7 @@
* @brief Start the audio, video, and control workers for a streaming session.
*/
int start(session_t &session, const std::string &addr_string) {
session.input = input::alloc(session.mail);
session.input = input::alloc(session.mail, session.input_session_id);
session.broadcast_ref = broadcast.ref();
if (!session.broadcast_ref) {
@@ -2259,5 +2261,6 @@
session->shutdown_event = mail->event<bool>(mail::shutdown);
session->launch_session_id = launch_session.id;
session->client_cert = launch_session.client_cert;
session->input_session_id = launch_session.client_cert.empty() ? launch_session.unique_id : launch_session.client_cert;
session->config = config;
▸
src/system_tray.cpp
+467
−67
@@ -25,6 +25,11 @@
* @brief Path to the system tray icon used for pairing requests.
*/
#define TRAY_ICON_LOCKED WEB_DIR "images/sunshine-locked.svg"
/**
* @def TRAY_ICON_VIRTUALHID
* @brief Path to the Virtual HID Driver notification icon.
*/
#define TRAY_ICON_VIRTUALHID WEB_DIR "images/logo-libvirtualhid.svg"
#if defined(_WIN32)
/**
@@ -32,26 +37,38 @@
* @brief Macro for WIN32 LEAN AND MEAN.
*/
#define WIN32_LEAN_AND_MEAN
#include <accctrl.h>
#include <aclapi.h>
#include <AccCtrl.h>
#include <AclAPI.h>
#elif defined(__APPLE__) || defined(__MACH__)
#include <CoreFoundation/CoreFoundation.h>
#include <dispatch/dispatch.h>
#include <unordered_map>
#endif
// standard includes
#include <array>
#include <atomic>
#include <chrono>
#include <csignal>
#include <cstddef>
#include <filesystem>
#include <format>
#include <functional>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
#include <system_error>
#include <thread>
#include <unordered_map>
#include <utility>
// lib includes
#include <boost/filesystem.hpp>
#include <boost/process/v1/environment.hpp>
#include <tray.h>
#ifdef _WIN32
#include <libvirtualhid/license.hpp>
#endif
// local includes
#include "confighttp.h"
@@ -60,13 +77,88 @@
#include "platform/common.h"
#include "process.h"
#include "src/entry_handler.h"
#include "system_tray.h"
#ifdef _WIN32
#include "platform/windows/utf_utils.h"
#endif
using namespace std::literals;
// system_tray namespace
namespace system_tray {
namespace {
/**
* @brief Access the process-wide tray initialization state.
*
* @return Atomic initialization flag shared by tray operations.
*/
std::atomic_bool &tray_initialized_state() {
static std::atomic_bool initialized {false};
return initialized;
}
/**
* @brief Access the mutex that serializes tray data and UI changes.
*
* @return Mutex shared by tray operations.
*/
std::mutex &tray_state_mutex() {
static std::mutex mutex;
return mutex;
}
/**
* @brief Access the owned tray worker thread.
*
static std::atomic tray_initialized = false;
* @return Worker thread used by the threaded tray lifecycle.
*/
std::jthread &tray_worker_thread() {
static std::jthread worker;
return worker;
}
/**
* @brief Hash string-like resource paths without allocating temporary strings.
*/
struct transparent_string_hash_t {
using is_transparent = void; ///< Enable heterogeneous unordered-map lookup.
/**
* @brief Hash a string view.
*
* @param value String view to hash.
* @return Hash value for the supplied text.
*/
std::size_t operator()(const std::string_view value) const noexcept {
return std::hash<std::string_view> {}(value);
}
};
#ifdef _WIN32
/**
* @brief Access storage for dynamic Virtual HID Driver license menu labels.
*
* @return Persistent string storage backing the tray menu label pointers.
*/
std::array<std::string, 11> &virtualhid_license_menu_text_storage() {
static std::array<std::string, 11> menu_text;
return menu_text;
}
#endif
} // namespace
#ifdef _WIN32
constexpr auto LIBVIRTUALHID_RELEASES_URL = "https://github.com/LizardByte/libvirtualhid/releases/latest"sv; ///< Latest Virtual HID Driver release.
static std::array<struct tray_menu, 6> virtualhid_benefits_menu {{
{.text = "Xbox One, Xbox Series, DualSense (DS5), Switch Pro, and Generic", .disabled = 1},
{.text = "Motion, touchpads, LEDs, and adaptive triggers where supported", .disabled = 1},
{.text = "Actively developed and supported by LizardByte", .disabled = 1},
{.text = "-"},
{.text = "Open License Settings", .cb = tray_virtualhid_license_cb},
{},
}}; ///< Persistent Virtual HID Driver benefits shown in the notification area.
#endif
void tray_open_ui_cb([[maybe_unused]] struct tray_menu *item) {
BOOST_LOG(info) << "Opening UI from system tray"sv;
launch_ui();
@@ -84,6 +176,18 @@
platf::open_url("https://www.paypal.com/paypalme/ReenigneArcher");
}
#ifdef _WIN32
void tray_virtualhid_license_cb([[maybe_unused]] struct tray_menu *item) {
BOOST_LOG(info) << "Opening Virtual HID Driver license settings from system tray"sv;
launch_ui("/troubleshooting#virtualhid-license");
}
void tray_virtualhid_download_cb([[maybe_unused]] struct tray_menu *item) {
BOOST_LOG(info) << "Opening Virtual HID Driver download from system tray"sv;
platf::open_url(std::string {LIBVIRTUALHID_RELEASES_URL});
}
#endif
/**
* @brief Forwards Qt log messages to Sunshine's BOOST_LOG logger.
* @param level Log level: 0=debug, 1=info, 2=warning, 3=error.
@@ -136,15 +240,38 @@
lifetime::exit_sunshine(0, true);
}
#ifdef _WIN32
/**
* @brief Create the initial Virtual HID Driver license submenu.
*
* @return Menu storage with a checking state, benefits, license settings, and driver download.
*/
std::array<struct tray_menu, 11> initial_virtualhid_license_menu() {
std::array<struct tray_menu, 11> menu {};
menu[0] = {.text = "Status: Checking", .disabled = 1};
menu[1] = {.text = "-"};
menu[2] = {.text = "Open License Settings", .cb = tray_virtualhid_license_cb};
menu[3] = {.text = "Benefits over ViGEmBus", .submenu = virtualhid_benefits_menu.data()};
menu[4] = {.text = "Download Virtual HID Driver", .cb = tray_virtualhid_download_cb};
return menu;
}
static auto virtualhid_license_menu = initial_virtualhid_license_menu(); ///< Virtual HID Driver license submenu.
#endif
// Tray menu
static struct tray tray = {
.icon = TRAY_ICON,
.tooltip = PROJECT_NAME,
.menu =
(struct tray_menu[]) {
// todo - use boost/locale to translate menu strings
// Tray menu labels currently use the project's English source strings.
{.text = "Open Sunshine", .cb = tray_open_ui_cb},
{.text = "-"},
#ifdef _WIN32
{.text = "Virtual HID Driver", .submenu = virtualhid_license_menu.data()},
{.text = "-"},
#endif
{.text = "Donate",
.submenu =
(struct tray_menu[]) {
@@ -162,8 +289,13 @@
{.text = "Quit", .cb = tray_quit_cb},
{.text = nullptr}
},
#ifdef _WIN32
.iconPathCount = 5,
.allIconPaths = {TRAY_ICON, TRAY_ICON_LOCKED, TRAY_ICON_PLAYING, TRAY_ICON_PAUSING, TRAY_ICON_VIRTUALHID},
#else
.iconPathCount = 4,
.allIconPaths = {TRAY_ICON, TRAY_ICON_LOCKED, TRAY_ICON_PLAYING, TRAY_ICON_PAUSING},
#endif
};
#ifdef SUNSHINE_TESTS
@@ -172,38 +304,231 @@
}
bool tray_initialized_for_testing() {
return tray_initialized_state().load();
return tray_initialized;
}
void reset_tray_data_for_testing() {
const std::scoped_lock lock(tray_state_mutex());
tray.icon = tray.allIconPaths[0];
tray.tooltip = PROJECT_NAME;
tray.notification_icon = nullptr;
tray.notification_text = nullptr;
tray.notification_title = nullptr;
tray.notification_cb = nullptr;
#ifdef _WIN32
virtualhid_license_menu_text_storage() = {};
virtualhid_license_menu = initial_virtualhid_license_menu();
#endif
}
#endif
#ifdef _WIN32
/**
* @brief Return the user-visible label for a Virtual HID Driver license state.
*
* @param state License state reported by libvirtualhid.
* @return Short state label suitable for a tray menu.
*/
std::string_view virtualhid_license_state_label(lvh::LicenseState state) {
using enum lvh::LicenseState;
switch (state) {
case unlicensed:
return "Not Activated";
case licensed:
return "Licensed";
case expired:
return "Expired";
case disabled:
return "Disabled";
case invalid:
return "Invalid";
case unavailable:
default:
return "Unavailable";
}
}
/**
* @brief Return explanatory tray text for a non-active license state.
*
* @param state License state reported by libvirtualhid.
* @return Short explanation of the state.
*/
std::string_view virtualhid_license_state_detail(lvh::LicenseState state) {
using enum lvh::LicenseState;
switch (state) {
case unlicensed:
return "No license is active on this machine";
case expired:
return "The license on this machine has expired";
case disabled:
return "The license on this machine is disabled";
case invalid:
return "The license on this machine is invalid";
case licensed:
return "This machine is activated";
case unavailable:
default:
return "The local license service is unavailable";
}
}
/**
* @brief Assign text and behavior to one Virtual HID Driver submenu item.
*
* @tparam Callback Callback type accepted by the tray library.
* @param index Submenu index to populate.
* @param text User-visible menu text.
* @param disabled Whether the item is informational rather than actionable.
* @param callback Callback invoked for actionable items.
*/
template<typename Callback = std::nullptr_t>
void set_virtualhid_license_menu_item(
const std::size_t index,
std::string text,
const bool disabled,
Callback callback = nullptr
) {
auto &menu_text = virtualhid_license_menu_text_storage();
menu_text[index] = std::move(text);
virtualhid_license_menu[index] = {
.text = menu_text[index].c_str(),
.disabled = disabled,
.cb = callback,
};
}
/**
* @brief Rebuild the Virtual HID Driver submenu for the latest license state.
*
* @param license Latest machine license details.
*/
void rebuild_virtualhid_license_menu(const lvh::LicenseStatus &license) {
virtualhid_license_menu = {};
virtualhid_license_menu_text_storage() = {};
set_virtualhid_license_menu_item(0, std::format("Status: {}", virtualhid_license_state_label(license.state)), true);
if (license.licensed()) {
set_virtualhid_license_menu_item(
1,
license.plan_name.empty() ? std::string {virtualhid_license_state_detail(license.state)} : std::format("Plan: {}", license.plan_name),
true
);
set_virtualhid_license_menu_item(
2,
license.customer_email.empty() ? "Customer: Not reported" : std::format("Customer: {}", license.customer_email),
true
);
set_virtualhid_license_menu_item(
3,
license.expires_at.empty() ? "Expiration: Not reported" : std::format("Expires: {}", license.expires_at),
true
);
set_virtualhid_license_menu_item(
4,
license.activation_limit == 0 ?
"Machine activations: Not reported" :
std::format("Machine activations: {} / {}", license.activation_usage, license.activation_limit),
true
);
set_virtualhid_license_menu_item(6, "View License Details", false, tray_virtualhid_license_cb);
set_virtualhid_license_menu_item(7, "Manage License", false, tray_virtualhid_license_cb);
} else {
set_virtualhid_license_menu_item(1, std::string {virtualhid_license_state_detail(license.state)}, true);
set_virtualhid_license_menu_item(2, "Full virtual gamepad support is locked", true);
set_virtualhid_license_menu_item(
3,
license.service_available ? "License service: Available" : "License service: Unavailable",
true
);
set_virtualhid_license_menu_item(4, "Activate this machine to use Virtual HID Driver", true);
set_virtualhid_license_menu_item(6, "Activate License", false, tray_virtualhid_license_cb);
set_virtualhid_license_menu_item(7, "Buy License", false, tray_virtualhid_license_cb);
}
virtualhid_license_menu[5] = {.text = "-"};
set_virtualhid_license_menu_item(8, "Benefits over ViGEmBus", false);
virtualhid_license_menu[8].submenu = virtualhid_benefits_menu.data();
set_virtualhid_license_menu_item(9, "Download Virtual HID Driver", false, tray_virtualhid_download_cb);
}
/**
* @brief Clear notification fields before replacing the tray state.
*/
void clear_tray_notification() {
tray.notification_title = nullptr;
tray.notification_text = nullptr;
tray.notification_cb = nullptr;
tray.notification_icon = nullptr;
}
void update_tray_virtualhid_license(const lvh::LicenseStatus &license, const bool notify_if_unlicensed) {
const std::scoped_lock lock(tray_state_mutex());
clear_tray_notification();
rebuild_virtualhid_license_menu(license);
if (notify_if_unlicensed && !license.licensed()) {
tray.notification_title = "Activate Virtual HID Driver";
tray.notification_text =
"Adds Xbox One/Series, DualSense (DS5), Switch Pro, and Generic gamepads beyond ViGEmBus. Actively maintained by LizardByte. Click to activate or buy a license; details remain in the tray menu.";
tray.notification_icon = tray.allIconPaths[4];
tray.notification_cb = []() {
launch_ui("/troubleshooting#virtualhid-license");
};
}
if (tray_initialized_state().load()) {
tray_update(&tray);
}
}
void prepare_tray_virtualhid_license() {
const auto result = lvh::get_license_status();
update_tray_virtualhid_license(result.license, !result.license.licensed());
}
#endif
/**
* @brief Get resource path.
*
* @param relativePath Relative path.
* @return Absolute path to the resource file for the current platform bundle layout.
*/
const char *GetResourcePath(const char *relativePath) {
#ifdef __APPLE__
if (!relativePath || !*relativePath) {
return nullptr;
}
if (std::filesystem::path {relativePath}.is_absolute()) {
// Simple cache ensures our string pointers live forever
static std::unordered_map<std::string, std::string> g_cache;
auto search = g_cache.find(relativePath);
return relativePath;
}
#if defined(_WIN32) || defined(__APPLE__)
// Simple cache ensures our string pointers live forever.
static std::unordered_map<std::string, std::string, transparent_string_hash_t, std::equal_to<>> g_cache;
auto search = g_cache.find(std::string_view {relativePath});
if (search != g_cache.end()) {
return search->second.c_str();
}
#endif
#ifdef _WIN32
std::array<wchar_t, 32768> executable {};
const auto executable_length = GetModuleFileNameW(nullptr, executable.data(), executable.size());
if (executable_length == 0 || executable_length >= executable.size()) {
return relativePath;
}
const auto full_path =
(std::filesystem::path {executable.data()}.parent_path() / utf_utils::from_utf8(relativePath)).lexically_normal();
auto full = utf_utils::to_utf8(full_path.wstring());
BOOST_LOG(debug) << "System Tray: using " << full << " for icon path";
const auto it = g_cache.try_emplace(relativePath, std::move(full)).first;
return it->second.c_str();
#elif defined(__APPLE__)
// If we're running from an .app bundle, get the internal Resources dir
CFBundleRef bundle = CFBundleGetMainBundle();
if (!bundle) {
@@ -236,14 +561,55 @@
BOOST_LOG(debug) << "System Tray: using " << full << " for icon path";
auto [it, inserted] = g_cache.emplace(relativePath, std::move(full));
const auto it = g_cache.try_emplace(relativePath, std::move(full)).first;
return it->second.c_str();
#else
return relativePath;
#endif
}
/**
* @brief Resolve tray icons against the current executable or application bundle.
*/
void resolve_tray_icon_paths() {
#if defined(_WIN32) || defined(__APPLE__)
std::optional<int> notification_icon_index;
if (tray.notification_icon != nullptr) {
for (int index = 0; index < tray.iconPathCount; ++index) {
if (std::string_view {tray.notification_icon} == tray.allIconPaths[index]) {
notification_icon_index = index;
break;
}
}
}
for (int index = 0; index < tray.iconPathCount; ++index) {
tray.allIconPaths[index] = GetResourcePath(tray.allIconPaths[index]);
}
tray.icon = tray.allIconPaths[0];
if (notification_icon_index.has_value()) {
tray.notification_icon = tray.allIconPaths[*notification_icon_index];
}
#endif
}
#ifdef SUNSHINE_TESTS
const char *resource_path_for_testing(const char *relative_path) {
return GetResourcePath(relative_path);
}
void resolve_tray_icon_paths_for_testing() {
resolve_tray_icon_paths();
}
#endif
/**
* @brief Initialize the tray, optionally observing an owning thread's stop request.
*
int init_tray() {
* @param stop_token Stop token supplied by the managed tray worker, or an empty token for direct initialization.
* @return Zero when initialization succeeds; otherwise, a non-zero error code.
*/
int init_tray_with_stop_token(const std::stop_token stop_token) {
#ifdef _WIN32
// If we're running as SYSTEM, Explorer.exe will not have permission to open our thread handle
// to monitor for thread termination. If Explorer fails to open our thread, our tray icon
@@ -252,8 +618,8 @@
{
PACL old_dacl;
PSECURITY_DESCRIPTOR sd;
auto error = GetSecurityInfo(GetCurrentThread(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, &old_dacl, nullptr, &sd);
if (error != ERROR_SUCCESS) {
BOOST_LOG(warning) << "GetSecurityInfo() failed: "sv << error;
auto security_error = GetSecurityInfo(GetCurrentThread(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, &old_dacl, nullptr, &sd);
if (security_error != ERROR_SUCCESS) {
BOOST_LOG(warning) << "GetSecurityInfo() failed: "sv << security_error;
return 1;
}
@@ -265,7 +631,7 @@
SID_IDENTIFIER_AUTHORITY sid_authority = SECURITY_WORLD_SID_AUTHORITY;
PSID world_sid;
if (!AllocateAndInitializeSid(&sid_authority, 1, SECURITY_WORLD_RID, 0, 0, 0, 0, 0, 0, 0, &world_sid)) {
error = GetLastError();
BOOST_LOG(warning) << "AllocateAndInitializeSid() failed: "sv << error;
security_error = GetLastError();
BOOST_LOG(warning) << "AllocateAndInitializeSid() failed: "sv << security_error;
return 1;
}
@@ -282,8 +648,8 @@
ea.Trustee.ptstrName = (LPSTR) world_sid;
PACL new_dacl;
security_error = SetEntriesInAcl(1, &ea, old_dacl, &new_dacl);
error = SetEntriesInAcl(1, &ea, old_dacl, &new_dacl);
if (error != ERROR_SUCCESS) {
BOOST_LOG(warning) << "SetEntriesInAcl() failed: "sv << error;
if (security_error != ERROR_SUCCESS) {
BOOST_LOG(warning) << "SetEntriesInAcl() failed: "sv << security_error;
return 1;
}
@@ -292,46 +658,52 @@
LocalFree(new_dacl);
});
error = SetSecurityInfo(GetCurrentThread(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, new_dacl, nullptr);
security_error = SetSecurityInfo(GetCurrentThread(), SE_KERNEL_OBJECT, DACL_SECURITY_INFORMATION, nullptr, nullptr, new_dacl, nullptr);
if (security_error != ERROR_SUCCESS) {
BOOST_LOG(warning) << "SetSecurityInfo() failed: "sv << security_error;
if (error != ERROR_SUCCESS) {
BOOST_LOG(warning) << "SetSecurityInfo() failed: "sv << error;
return 1;
}
}
// Wait for the shell to be initialized before registering the tray icon.
// This ensures the tray icon works reliably after a logoff/logon cycle.
while (GetShellWindow() == nullptr) {
while (GetShellWindow() == nullptr && !stop_token.stop_requested()) {
Sleep(1000);
}
if (stop_token.stop_requested()) {
return 1;
}
#endif
#ifdef __APPLE__
// if these icon paths are relative, resolve to internal .app Resources path
tray.allIconPaths[0] = GetResourcePath(TRAY_ICON);
tray.allIconPaths[1] = GetResourcePath(TRAY_ICON_LOCKED);
tray.allIconPaths[2] = GetResourcePath(TRAY_ICON_PLAYING);
tray.allIconPaths[3] = GetResourcePath(TRAY_ICON_PAUSING);
tray.icon = tray.allIconPaths[0];
#endif
resolve_tray_icon_paths();
tray_set_log_callback(qt_log_to_boost);
tray_set_app_info(PROJECT_NAME, PROJECT_NAME, PROJECT_FQDN);
{
const std::scoped_lock lock(tray_state_mutex());
if (stop_token.stop_requested()) {
return 1;
}
if (tray_init(&tray) < 0) {
BOOST_LOG(warning) << "Failed to create system tray"sv;
return 1;
if (tray_init(&tray) < 0) {
BOOST_LOG(warning) << "Failed to create system tray"sv;
}
tray_initialized_state().store(true);
return 1;
}
BOOST_LOG(info) << "System tray created"sv;
tray_initialized = true;
return 0;
}
int init_tray() {
return init_tray_with_stop_token({});
}
int process_tray_events() {
if (!tray_initialized) {
if (!tray_initialized_state().load()) {
BOOST_LOG(error) << "System tray is not initialized"sv;
return 1;
}
@@ -341,14 +713,24 @@
}
int end_tray() {
auto &worker = tray_worker_thread();
worker.request_stop();
{
if (tray_initialized) {
tray_initialized = false;
tray_exit();
const std::scoped_lock lock(tray_state_mutex());
if (tray_initialized_state().exchange(false)) {
tray_exit();
}
}
if (worker.joinable() && worker.get_id() != std::this_thread::get_id()) {
worker.join();
}
return 0;
}
void update_tray_playing(std::string app_name) {
if (!tray_initialized) {
const std::scoped_lock lock(tray_state_mutex());
if (!tray_initialized_state().load()) {
return;
}
@@ -357,19 +739,20 @@
tray.notification_text = nullptr;
tray.notification_cb = nullptr;
tray.notification_icon = nullptr;
tray.icon = TRAY_ICON_PLAYING;
tray.icon = tray.allIconPaths[2];
tray_update(&tray);
tray.icon = TRAY_ICON_PLAYING;
tray.icon = tray.allIconPaths[2];
tray.notification_title = "Stream Started";
static std::string msg = std::format("Streaming started for {}", app_name);
tray.notification_text = msg.c_str();
tray.tooltip = msg.c_str();
tray.notification_icon = tray.allIconPaths[2];
tray.notification_icon = TRAY_ICON_PLAYING;
tray_update(&tray);
}
void update_tray_pausing(std::string app_name) {
const std::scoped_lock lock(tray_state_mutex());
if (!tray_initialized) {
if (!tray_initialized_state().load()) {
return;
}
@@ -378,19 +761,20 @@
tray.notification_text = nullptr;
tray.notification_cb = nullptr;
tray.notification_icon = nullptr;
tray.icon = TRAY_ICON_PAUSING;
tray.icon = tray.allIconPaths[3];
tray_update(&tray);
static std::string msg = std::format("Streaming paused for {}", app_name);
tray.icon = TRAY_ICON_PAUSING;
tray.icon = tray.allIconPaths[3];
tray.notification_title = "Stream Paused";
tray.notification_text = msg.c_str();
tray.tooltip = msg.c_str();
tray.notification_icon = TRAY_ICON_PAUSING;
tray.notification_icon = tray.allIconPaths[3];
tray_update(&tray);
}
void update_tray_stopped(std::string app_name) {
if (!tray_initialized) {
const std::scoped_lock lock(tray_state_mutex());
if (!tray_initialized_state().load()) {
return;
}
@@ -399,12 +783,12 @@
tray.notification_text = nullptr;
tray.notification_cb = nullptr;
tray.notification_icon = nullptr;
tray.icon = tray.allIconPaths[0];
tray.icon = TRAY_ICON;
tray_update(&tray);
static std::string msg = std::format("Application {} successfully stopped", app_name);
tray.icon = TRAY_ICON;
tray.notification_icon = TRAY_ICON;
tray.icon = tray.allIconPaths[0];
tray.notification_icon = tray.allIconPaths[0];
tray.notification_title = "Application Stopped";
tray.notification_text = msg.c_str();
tray.tooltip = PROJECT_NAME;
@@ -412,6 +796,7 @@
}
void update_tray_require_pin() {
if (!tray_initialized) {
const std::scoped_lock lock(tray_state_mutex());
if (!tray_initialized_state().load()) {
return;
}
@@ -420,12 +805,12 @@
tray.notification_text = nullptr;
tray.notification_cb = nullptr;
tray.notification_icon = nullptr;
tray.icon = TRAY_ICON;
tray.icon = tray.allIconPaths[0];
tray_update(&tray);
tray.icon = TRAY_ICON;
tray.icon = tray.allIconPaths[0];
tray.notification_title = "Incoming Pairing Request";
tray.notification_text = "Click here to complete the pairing process";
tray.notification_icon = TRAY_ICON_LOCKED;
tray.notification_icon = tray.allIconPaths[1];
tray.tooltip = PROJECT_NAME;
tray.notification_cb = []() {
launch_ui("/pin");
@@ -434,33 +819,48 @@
}
// Threading functions available on all platforms
static void tray_thread_worker() {
/**
* @brief Run the managed system tray event loop.
*
* @param stop_token Stop token owned by the tray worker thread.
*/
static void tray_thread_worker(const std::stop_token stop_token) {
platf::set_thread_name("system_tray");
BOOST_LOG(info) << "System tray thread started"sv;
// Initialize the tray in this thread
if (init_tray_with_stop_token(stop_token) != 0) {
if (!stop_token.stop_requested()) {
BOOST_LOG(error) << "Failed to initialize tray in thread"sv;
}
if (init_tray() != 0) {
BOOST_LOG(error) << "Failed to initialize tray in thread"sv;
return;
}
// Main tray event loop
while (!stop_token.stop_requested() && process_tray_events() == 0) {
// Continue until the tray requests shutdown or event processing stops.
}
{
const std::scoped_lock lock(tray_state_mutex());
while (process_tray_events() == 0);
tray_initialized_state().store(false);
}
BOOST_LOG(info) << "System tray thread ended"sv;
}
int init_tray_threaded() {
try {
auto tray_thread = std::jthread(tray_thread_worker);
// The tray thread doesn't require strong lifetime management.
// It will exit asynchronously when tray_exit() is called.
tray_thread.detach();
auto &worker = tray_worker_thread();
if (worker.joinable()) {
BOOST_LOG(error) << "System tray thread is already running"sv;
return 1;
}
worker = std::jthread(tray_thread_worker);
BOOST_LOG(info) << "System tray thread initialized successfully"sv;
return 0;
} catch (const std::exception &e) {
} catch (const std::system_error &e) {
BOOST_LOG(error) << "Failed to create tray thread: " << e.what();
return 1;
}
▸
src/system_tray.h
+51
−0
@@ -4,6 +4,15 @@
*/
#pragma once
// standard includes
#include <string>
#ifdef _WIN32
namespace lvh {
struct LicenseStatus;
}
#endif
/**
* @brief Handles the system tray icon and notification system.
*/
@@ -32,7 +41,21 @@
*/
void tray_donate_paypal_cb([[maybe_unused]] struct tray_menu *item);
#ifdef _WIN32
/**
* @brief Callback for opening Virtual HID Driver license settings in the Web UI.
* @param item The tray menu item.
*/
void tray_virtualhid_license_cb([[maybe_unused]] struct tray_menu *item);
/**
* @brief Callback for opening the latest Virtual HID Driver release.
* @param item The tray menu item.
*/
void tray_virtualhid_download_cb([[maybe_unused]] struct tray_menu *item);
#endif
/**
* @brief Callback for resetting display device configuration.
* @param item The tray menu item.
*/
@@ -91,7 +114,22 @@
*/
void update_tray_require_pin();
#ifdef _WIN32
/**
* @brief Update the Virtual HID Driver license submenu and optional notification.
*
* @param license Latest machine license details.
* @param notify_if_unlicensed Whether to notify the user when the machine is not activated.
*/
void update_tray_virtualhid_license(const lvh::LicenseStatus &license, bool notify_if_unlicensed);
/**
* @brief Query the Virtual HID Driver license and prepare the startup tray state.
*/
void prepare_tray_virtualhid_license();
#endif
/**
* @brief Initializes and runs the system tray in a separate thread.
* @return 0 if initialization was successful, non-zero otherwise.
*/
@@ -116,5 +154,18 @@
* @brief Restore the persistent tray data to its initial state between tests.
*/
void reset_tray_data_for_testing();
/**
* @brief Resolve a tray resource path using the production platform logic.
*
* @param relative_path Resource path relative to the executable or application bundle.
* @return Stable resource path used by the tray backend.
*/
const char *resource_path_for_testing(const char *relative_path);
/**
* @brief Resolve all tray icon paths using the production platform logic.
*/
void resolve_tray_icon_paths_for_testing();
#endif
} // namespace system_tray
▸
src/thread_pool.h
+10
−0
@@ -136,6 +136,16 @@
}
}
/**
* @brief Check whether worker threads are available to execute queued tasks.
*
* @return True while the thread pool is running.
*/
bool running() {
std::lock_guard lg(_lock);
return _continue;
}
public:
/**
* @brief Run the main application or worker loop.
▸
src_assets/common/assets/web/config.html
+1
−1
@@ -227,7 +227,7 @@
"ds4_back_as_touchpad_click": "enabled",
"motion_as_ds4": "enabled",
"touchpad_as_ds4": "enabled",
"ds5_inputtino_randomize_mac": "enabled",
"virtualhid_randomize_mac": "enabled",
"back_button_timeout": -1,
"keyboard": "enabled",
"key_repeat_delay": 500,
▸
src_assets/common/assets/web/configs/tabs/Inputs.vue
+27
−13
@@ -29,19 +29,33 @@
<PlatformLayout :platform="platform">
<template #freebsd>
<option value="switch">{{ $t("config.gamepad_switch") }}</option>
<option value="generic">{{ $t("config.gamepad_generic") }}</option>
<option value="x360">{{ $t('config.gamepad_x360') }}</option>
<option value="xone">{{ $t("config.gamepad_xone") }}</option>
<option value="xseries">{{ $t("config.gamepad_xseries") }}</option>
<option value="ds4">{{ $t('config.gamepad_ds4') }}</option>
<option value="ds5">{{ $t("config.gamepad_ds5") }}</option>
<option value="switch">{{ $t("config.gamepad_switch") }}</option>
</template>
<template #linux>
<option value="generic">{{ $t("config.gamepad_generic") }}</option>
<option value="x360">{{ $t('config.gamepad_x360') }}</option>
<option value="xone">{{ $t("config.gamepad_xone") }}</option>
<option value="xseries">{{ $t("config.gamepad_xseries") }}</option>
<option value="ds4">{{ $t('config.gamepad_ds4') }}</option>
<option value="ds5">{{ $t("config.gamepad_ds5") }}</option>
<option value="switch">{{ $t("config.gamepad_switch") }}</option>
<option value="xone">{{ $t("config.gamepad_xone") }}</option>
</template>
<template #windows>
<option value="ds4">{{ $t('config.gamepad_ds4') }}</option>
<option value="generic">{{ $t("config.gamepad_generic") }}</option>
<option value="x360">{{ $t('config.gamepad_x360') }}</option>
<option value="xone">{{ $t("config.gamepad_xone") }}</option>
<option value="xseries">{{ $t("config.gamepad_xseries") }}</option>
<option value="ds4">{{ $t('config.gamepad_ds4') }}</option>
<option value="ds5">{{ $t("config.gamepad_ds5") }}</option>
<option value="switch">{{ $t("config.gamepad_switch") }}</option>
</template>
</PlatformLayout>
</select>
@@ -56,22 +70,22 @@
<h2 class="accordion-header">
<button class="accordion-button" type="button" data-bs-toggle="collapse"
data-bs-target="#panelsStayOpen-collapseOne">
{{ $t(config.gamepad === 'ds4' ? 'config.gamepad_ds4_manual' : (config.gamepad === 'ds5' ? 'config.gamepad_ds5_manual' : 'config.gamepad_auto')) }}
{{ $t(config.gamepad === 'auto' ? 'config.gamepad_auto' : 'config.gamepad_ds4_manual') }}
</button>
</h2>
<div id="panelsStayOpen-collapseOne" class="accordion-collapse collapse show"
aria-labelledby="panelsStayOpen-headingOne">
<div class="accordion-body">
<!-- Automatic PlayStation-style detection options -->
<!-- Automatic detection options (for Windows and Linux) -->
<template v-if="config.gamepad === 'auto' && (platform === 'windows' || platform === 'linux')">
<!-- Gamepad with motion-capability as DS4(Windows)/DS5(Linux) -->
<!-- Gamepad with motion capability as a PlayStation-style controller -->
<Checkbox class="mb-3"
id="motion_as_ds4"
locale-prefix="config"
v-model="config.motion_as_ds4"
default="true"
></Checkbox>
<!-- Gamepad with touch capability as a PlayStation-style controller -->
<!-- Gamepad with touch-capability as DS4(Windows)/DS5(Linux) -->
<Checkbox class="mb-3"
id="touchpad_as_ds4"
locale-prefix="config"
@@ -79,8 +93,8 @@
default="true"
></Checkbox>
</template>
<!-- DS4 option: DS4 back button as touchpad click (on Automatic: Windows only) -->
<template v-if="config.gamepad === 'ds4' || (config.gamepad === 'auto' && platform === 'windows')">
<!-- PlayStation-style option: Back/Select as touchpad click -->
<template v-if="config.gamepad === 'ds4' || config.gamepad === 'ds5' || config.gamepad === 'auto'">
<Checkbox class="mb-3"
id="ds4_back_as_touchpad_click"
locale-prefix="config"
@@ -88,12 +102,12 @@
default="true"
></Checkbox>
</template>
<!-- Virtual HID option: Controller MAC randomization -->
<!-- DS5 Option: Controller MAC randomization (on Automatic: Linux only) -->
<template v-if="config.gamepad === 'ds5' || (config.gamepad === 'auto' && platform === 'linux')">
<template v-if="config.gamepad === 'ds4' || config.gamepad === 'ds5' || (config.gamepad === 'auto' && platform !== 'macos')">
<Checkbox class="mb-3"
id="virtualhid_randomize_mac"
id="ds5_inputtino_randomize_mac"
locale-prefix="config"
v-model="config.ds5_inputtino_randomize_mac"
v-model="config.virtualhid_randomize_mac"
default="true"
></Checkbox>
</template>
▸
src_assets/common/assets/web/index.html
+48
−12
@@ -28,29 +28,56 @@
</div>
</div>
<!-- ViGEmBus Warning -->
<div class="alert alert-warning my-4" v-if="platform === 'windows' && controllerEnabled && vigembus && (!vigembus.installed || !vigembus.version_compatible)">
<!-- libvirtualhid Warning -->
<div class="alert alert-warning my-4" v-if="platform === 'windows' && controllerEnabled && virtualhid && (!virtualhid.installed || !virtualhid.version_compatible)">
<div>
<div class="d-flex align-items-center mb-3">
<alert-triangle :size="32" class="icon-lg me-3"></alert-triangle>
<div>
<div v-if="!vigembus.installed">
<div v-if="!virtualhid.installed">
<p class="mb-1"><strong>{{ $t('index.virtualhid_not_installed_title') }}</strong></p>
<p class="mb-0">{{ $t('index.virtualhid_not_installed_desc') }}</p>
<p class="mb-1"><strong>{{ $t('index.vigembus_not_installed_title') }}</strong></p>
<p class="mb-0">{{ $t('index.vigembus_not_installed_desc') }}</p>
</div>
<div v-else-if="!virtualhid.version_compatible">
<p class="mb-1"><strong>{{ $t('index.virtualhid_outdated_title') }}</strong></p>
<div v-else-if="!vigembus.version_compatible">
<p class="mb-1"><strong>{{ $t('index.vigembus_outdated_title') }}</strong></p>
<p class="mb-0">{{ $t('index.vigembus_outdated_desc', { version: vigembus.version }) }}</p>
<p class="mb-0">{{ $t('index.virtualhid_outdated_desc', { version: virtualhid.version, supported_versions: virtualhid.supported_versions }) }}</p>
</div>
</div>
</div>
<a class="btn btn-warning" href="./troubleshooting#vigembus">
<a class="btn btn-warning" href="./troubleshooting#virtualhid">
<wrench :size="18" class="icon"></wrench>
{{ $t('index.fix_now') }}
</a>
</div>
</div>
<div class="alert alert-warning my-4" v-if="platform === 'windows' && controllerEnabled && virtualhid && vigembus && !virtualhid.installed && vigembus.installed">
<div class="d-flex align-items-center">
<alert-triangle :size="32" class="icon-lg me-3"></alert-triangle>
<div>
<p class="mb-1"><strong>{{ $t('index.virtualhid_missing_vigembus_installed_title') }}</strong></p>
<p class="mb-0">{{ $t('index.virtualhid_missing_vigembus_installed_desc') }}</p>
</div>
</div>
</div>
<!-- Virtual HID Driver license warning -->
<div class="alert alert-warning my-4" v-if="platform === 'windows' && controllerEnabled && virtualhidLicense && !virtualhidLicense.licensed">
<div>
<div class="d-flex align-items-center mb-3">
<alert-triangle :size="32" class="icon-lg me-3"></alert-triangle>
<div>
<p class="mb-1"><strong>{{ $t('index.virtualhid_license_required_title') }}</strong></p>
<p class="mb-0">{{ $t('index.virtualhid_license_required_desc') }}</p>
</div>
</div>
<a class="btn btn-warning" href="./troubleshooting#virtualhid-license">
<wrench :size="18" class="icon"></wrench>
{{ $t('index.fix_now') }}
</a>
</div>
</div>
<!-- Version -->
<div class="card my-4">
<div class="card-body" v-if="version">
@@ -177,6 +204,8 @@
logs: null,
platform: "",
controllerEnabled: false,
virtualhid: null,
virtualhidLicense: null,
vigembus: null,
}
},
@@ -193,12 +222,19 @@
this.preReleaseVersion = new SunshineVersion((await fetch("https://api.github.com/repos/LizardByte/Sunshine/releases").then((r) => r.json())).find(release => release.prerelease), null);
console.log("Pre-Release Version: ", this.preReleaseVersion.version)
// Fetch ViGEmBus status only on Windows when controller is enabled
// Fetch virtual input driver status only on Windows when controller is enabled
if (this.platform === 'windows' && this.controllerEnabled) {
try {
const virtualInputStatus = await fetch("./api/virtual-input/status").then((r) => r.json());
this.virtualhid = virtualInputStatus.virtualhid;
this.vigembus = virtualInputStatus.vigembus;
} catch (e) {
console.error("Failed to fetch virtual input driver status:", e);
}
try {
this.virtualhidLicense = await fetch("./api/virtual-input/license").then((r) => r.json());
this.vigembus = await fetch("./api/vigembus/status").then((r) => r.json());
} catch (e) {
console.error("Failed to fetch Virtual HID Driver license status:", e);
console.error("Failed to fetch ViGEmBus status:", e);
}
}
} catch (e) {
▸
src_assets/common/assets/web/public/assets/locale/en.json
+76
−22
@@ -223,9 +223,9 @@
"dd_wa_hdr_toggle_delay_desc_3": "DO NOT use this workaround unless you actually have issues with HDR as it directly impacts stream start time!",
"dd_wa_hdr_toggle_delay": "High-contrast workaround for HDR",
"ds4_back_as_touchpad_click": "Map Back/Select to Touchpad Click",
"ds4_back_as_touchpad_click_desc": "When forcing DS4 emulation, map Back/Select to Touchpad Click",
"ds4_back_as_touchpad_click_desc": "When using PlayStation-style gamepad emulation, map Back/Select to Touchpad Click",
"virtualhid_randomize_mac": "Randomize virtual controller MAC",
"virtualhid_randomize_mac_desc": "Use a random MAC address for PlayStation-style virtual controllers instead of one based on the controller index. This avoids mixing per-controller settings when controllers are swapped on the client.",
"ds5_inputtino_randomize_mac": "Randomize virtual controller MAC",
"ds5_inputtino_randomize_mac_desc": "Upon controller registration use a random MAC instead of one based on the controllers internal index to avoid mixing configuration settings of different controllers when the are swapped on client-side.",
"encoder": "Force a Specific Encoder",
"encoder_desc": "Force a specific encoder, otherwise Sunshine will select the best available option. Note: If you specify a hardware encoder on Windows, it must match the GPU where the display is connected.",
"encoder_software": "Software",
@@ -243,13 +243,13 @@
"gamepad_auto": "Automatic selection options",
"gamepad_desc": "Choose which type of gamepad to emulate on the host",
"gamepad_ds4": "DS4 (PS4)",
"gamepad_ds4_manual": "PlayStation-style gamepad options",
"gamepad_ds4_manual": "DS4 selection options",
"gamepad_ds5": "DS5 (PS5)",
"gamepad_generic": "Generic HID gamepad",
"gamepad_ds5_manual": "DS5 selection options",
"gamepad_switch": "Nintendo Pro (Switch)",
"gamepad_manual": "Manual DS4 options",
"gamepad_x360": "X360 (Xbox 360)",
"gamepad_xone": "XOne (Xbox One)",
"gamepad_xseries": "Xbox Series",
"global_prep_cmd": "Command Preparations",
"global_prep_cmd_desc": "Configure a list of commands to be executed before or after running any application. If any of the specified preparation commands fail, the application launch process will be aborted.",
"hevc_mode": "HEVC Support",
@@ -295,7 +295,7 @@
"min_threads": "Minimum CPU Thread Count",
"min_threads_desc": "Increasing the value slightly reduces encoding efficiency, but the tradeoff is usually worth it to gain the use of more CPU cores for encoding. The ideal value is the lowest value that can reliably encode at your desired streaming settings on your hardware.",
"misc": "Miscellaneous options",
"motion_as_ds4": "Emulate a PlayStation-style gamepad if the client gamepad reports motion sensors are present",
"motion_as_ds4": "Emulate a DS4 gamepad if the client gamepad reports motion sensors are present",
"motion_as_ds4_desc": "If disabled, motion sensors will not be taken into account during gamepad type selection.",
"mouse": "Enable Mouse Input",
"mouse_desc": "Allows guests to control the host system with the mouse",
@@ -394,7 +394,7 @@
"sw_tune_zerolatency": "zerolatency -- good for fast encoding and low-latency streaming (default)",
"system_tray": "Enable system tray",
"system_tray_desc": "Show icon in system tray and display desktop notifications",
"touchpad_as_ds4": "Emulate a PlayStation-style gamepad if the client gamepad reports a touchpad is present",
"touchpad_as_ds4": "Emulate a DS4 gamepad if the client gamepad reports a touchpad is present",
"touchpad_as_ds4_desc": "If disabled, touchpad presence will not be taken into account during gamepad type selection.",
"upnp": "UPnP",
"upnp_desc": "Automatically configure port forwarding for streaming over the Internet",
@@ -451,10 +451,14 @@
"startup_errors": "<b>Attention!</b> Sunshine detected these errors during startup. We <b>STRONGLY RECOMMEND</b> fixing them before streaming.",
"version_dirty": "Thank you for helping to make Sunshine a better software!",
"version_latest": "You are running the latest version of Sunshine",
"vigembus_not_installed_desc": "Virtual gamepad support will not work without the ViGEmBus driver. Click the button below to install it.",
"virtualhid_license_required_desc": "Virtual HID Driver adds Xbox One, Xbox Series, DualSense, Nintendo Switch Pro, and Generic gamepads beyond the limited ViGEmBus fallback. It is actively maintained by the LizardByte team. Activate or manage the license to unlock full support.",
"virtualhid_license_required_title": "Virtual HID Driver License Missing",
"virtualhid_missing_vigembus_installed_desc": "ViGEmBus is installed, but without the libvirtualhid driver Sunshine can only offer limited fallback gamepad support.",
"virtualhid_missing_vigembus_installed_title": "Gamepad Support Is Limited",
"virtualhid_not_installed_desc": "Sunshine requires the libvirtualhid Windows driver for full virtual gamepad support. Install the libvirtualhid driver package, then restart Sunshine.",
"virtualhid_not_installed_title": "libvirtualhid Driver Not Installed",
"virtualhid_outdated_desc": "The installed libvirtualhid driver version (v{version}) is not supported. Supported versions: {supported_versions}.",
"virtualhid_outdated_title": "libvirtualhid Driver Outdated",
"vigembus_not_installed_title": "ViGEmBus Driver Not Installed",
"vigembus_outdated_desc": "You are running an outdated version of ViGEmBus (v{version}). Version 1.17 or higher is required for proper gamepad support. Click the button below to update.",
"vigembus_outdated_title": "ViGEmBus Driver Outdated",
"welcome": "Hello, Sunshine!"
},
"navbar": {
@@ -530,6 +534,25 @@
"dd_reset_desc": "If Sunshine is stuck trying to restore the changed display device settings, you can reset the settings and proceed to restore the display state manually.",
"dd_reset_error": "Error while resetting persistence!",
"dd_reset_success": "Success resetting persistence!",
"driver_download": "Download",
"driver_installed_version": "Installed Version",
"driver_latest_version": "Latest Release",
"driver_name": "Driver",
"driver_not_installed": "Not installed",
"driver_refresh": "Refresh Drivers",
"driver_release_checking": "Checking...",
"driver_release_current": "Latest version installed",
"driver_release_loading": "Checking GitHub...",
"driver_release_not_installed": "Ready to install",
"driver_release_outdated": "Update available",
"driver_release_unavailable": "Unavailable",
"driver_release_unknown": "Unable to compare versions",
"driver_status": "Status",
"driver_status_compatible": "Compatible",
"driver_status_not_installed": "Not installed",
"driver_status_unsupported": "Unsupported version",
"driver_supported_versions": "Supported Versions",
"driver_version_unknown": "Not reported",
"force_close": "Force Close",
"force_close_desc": "If Moonlight complains about an app currently running, force closing the app should fix the issue.",
"force_close_error": "Error while closing Application",
@@ -549,16 +572,47 @@
"unpair_single_success": "However, the device(s) may still be in an active session. Use the 'Force Close' button above to end any open sessions.",
"unpair_single_unknown": "Unknown Client",
"unpair_title": "Unpair Devices",
"vigembus_compatible": "ViGEmBus is installed and compatible.",
"vigembus_current_version": "Current Version",
"vigembus_desc": "ViGEmBus is required for virtual gamepad support. Install or update the driver if it's missing or outdated (version 1.17 or higher required).",
"vigembus_incompatible": "ViGEmBus version is too old. Please install version 1.17 or higher.",
"vigembus_install": "ViGEmBus Driver",
"vigembus_install_button": "Install ViGEmBus v{version}",
"vigembus_install_error": "Failed to install ViGEmBus driver.",
"vigembus_install_success": "ViGEmBus driver installed successfully! You may need to restart your computer.",
"vigembus_force_reinstall_button": "Force Reinstall ViGEmBus v{version}",
"vigembus_not_installed": "ViGEmBus is not installed."
"vigembus_driver": "ViGEmBus Fallback Driver",
"virtual_gamepad": "Virtual Gamepad",
"virtual_gamepad_desc": "Sunshine uses libvirtualhid for full virtual gamepad support on Windows. ViGEmBus is detected only as a limited fallback for Xbox 360 and DualShock 4 gamepads.",
"virtual_gamepad_drivers": "Windows Driver Status",
"virtual_gamepad_drivers_desc": "Installed versions are compared with each project's latest stable GitHub release. Prereleases are ignored.",
"virtualhid_benefit_features": "Supports controller-specific features such as motion, touchpads, LEDs, and adaptive triggers when available.",
"virtualhid_benefit_features_title": "Modern Controller Features",
"virtualhid_benefit_gamepads": "Creates Xbox One, Xbox Series, DualSense, Nintendo Switch Pro, and Generic gamepads in addition to Xbox 360 and DualShock 4.",
"virtualhid_benefit_gamepads_title": "More Gamepad Types",
"virtualhid_benefit_maintained": "Actively developed and supported by the LizardByte team instead of relying only on the discontinued ViGEmBus project.",
"virtualhid_benefit_maintained_title": "Actively Supported",
"virtualhid_benefits_title": "Benefits over ViGEmBus",
"virtualhid_download": "Download Virtual HID Driver",
"virtualhid_driver": "libvirtualhid Driver",
"virtualhid_license": "Virtual HID Driver License",
"virtualhid_license_activate": "Activate License",
"virtualhid_license_activations": "Machine Activations",
"virtualhid_license_active_devices": "Active Virtual Gamepads",
"virtualhid_license_buy": "Buy License",
"virtualhid_license_customer": "Customer",
"virtualhid_license_deactivate": "Deactivate This Machine",
"virtualhid_license_desc": "An active license is required before Sunshine can create libvirtualhid gamepads. Activation is handled by the local libvirtualhid service; Sunshine does not save your license key.",
"virtualhid_license_expires": "Expires",
"virtualhid_license_key": "License Key",
"virtualhid_license_key_desc": "The key is sent only to the local libvirtualhid service and is cleared from this page after successful activation.",
"virtualhid_license_key_placeholder": "Enter your product key",
"virtualhid_license_machine_activated": "This machine is activated",
"virtualhid_license_machine_activated_desc": "Sunshine can create Virtual HID Driver gamepads while this license remains valid.",
"virtualhid_license_manage": "Manage Account and Activations",
"virtualhid_license_not_reported": "Not reported",
"virtualhid_license_plan": "Plan",
"virtualhid_license_refresh": "Refresh Validation",
"virtualhid_license_request_failed": "The license request failed.",
"virtualhid_license_state": "Status",
"virtualhid_license_state_disabled": "Disabled",
"virtualhid_license_state_expired": "Expired",
"virtualhid_license_state_invalid": "Invalid",
"virtualhid_license_state_licensed": "Licensed",
"virtualhid_license_state_unavailable": "Unavailable",
"virtualhid_license_state_unlicensed": "Unlicensed",
"virtualhid_license_unavailable": "The local libvirtualhid license service is unavailable. Verify that the driver package and broker service are installed and running."
},
"featured": {
"categories": {
▸
src_assets/common/assets/web/sunshine.css
+286
−0
@@ -1537,5 +1537,291 @@
}
/* ==========================================================================
Virtual Gamepad and Driver License
========================================================================== */
.virtual-gamepad-card {
overflow: hidden;
}
.virtual-gamepad-hero {
display: flex;
align-items: flex-start;
gap: var(--spacing-md);
}
.virtual-gamepad-heading-icon {
align-items: center;
background-color: var(--color-primary-light);
border: 1px solid var(--color-primary);
border-radius: var(--radius-lg);
color: var(--color-primary);
display: flex;
flex: 0 0 auto;
height: 3rem;
justify-content: center;
width: 3rem;
}
.virtual-gamepad-feature-grid {
display: grid;
gap: var(--spacing-md);
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-top: var(--spacing-xl);
}
.virtual-gamepad-feature {
align-items: flex-start;
background-color: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
display: flex;
gap: var(--spacing-md);
padding: var(--spacing-lg);
}
.virtual-gamepad-feature-icon {
align-items: center;
background-color: var(--color-primary-light);
border-radius: var(--radius-md);
color: var(--color-primary);
display: flex;
flex: 0 0 auto;
height: 2.25rem;
justify-content: center;
width: 2.25rem;
}
.virtual-gamepad-feature h3 {
font-size: 1rem;
letter-spacing: normal;
line-height: 1.4;
margin: 0 0 var(--spacing-xs);
}
.virtual-gamepad-feature p {
font-size: 0.875rem;
line-height: 1.55;
margin: 0;
}
.virtual-gamepad-section,
.virtualhid-license-section {
border-top: 1px solid var(--color-border);
margin-top: var(--spacing-xl);
padding-top: var(--spacing-xl);
}
.virtual-gamepad-section-heading,
.virtualhid-license-heading {
align-items: flex-start;
display: flex;
gap: var(--spacing-lg);
justify-content: space-between;
margin-bottom: var(--spacing-lg);
}
.driver-refresh-button,
.driver-download-button {
flex: 0 0 auto;
font-size: 0.875rem;
padding: 0.45rem 0.75rem;
white-space: nowrap;
}
.driver-table-shell {
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.driver-table-shell .table thead th:first-child {
border-top-left-radius: var(--radius-lg);
}
.driver-table-shell .table thead th:last-child {
border-top-right-radius: var(--radius-lg);
}
.driver-table-shell .table tbody tr:last-child {
border-bottom: none;
}
.driver-table-shell .table tbody th {
padding: var(--spacing-md) var(--spacing-lg);
white-space: nowrap;
}
.driver-release-state {
align-items: center;
display: flex;
font-weight: 600;
gap: var(--spacing-sm);
white-space: nowrap;
}
.driver-release-current {
color: var(--color-success);
}
.driver-release-outdated {
color: var(--color-warning);
}
.driver-release-neutral {
color: var(--color-text-muted);
}
.driver-release-detail {
color: var(--color-text-muted);
display: block;
margin-top: 0.15rem;
white-space: nowrap;
}
.driver-download-column {
text-align: right;
}
.driver-release-spinning {
animation: driver-release-spin 0.9s linear infinite;
}
@keyframes driver-release-spin {
to {
transform: rotate(360deg);
}
}
.virtualhid-license-heading .badge {
flex: 0 0 auto;
font-size: 0.8rem;
padding: 0.5rem 0.75rem;
}
.virtualhid-license-stats {
display: grid;
gap: var(--spacing-sm);
grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr));
margin: 0;
}
.virtualhid-license-stat {
background-color: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
margin: 0;
min-width: 0;
padding: var(--spacing-md);
}
.virtualhid-license-stat dt {
color: var(--color-text-muted);
font-size: 0.75rem;
letter-spacing: 0.04em;
margin-bottom: var(--spacing-xs);
text-transform: uppercase;
}
.virtualhid-license-stat dd {
color: var(--color-text-base);
font-size: 1rem;
font-weight: 600;
margin: 0;
overflow-wrap: anywhere;
}
.virtualhid-license-message {
margin: var(--spacing-md) 0 0;
}
.virtualhid-license-toolbar {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-sm);
justify-content: flex-end;
margin-top: var(--spacing-lg);
}
.virtualhid-license-activation,
.virtualhid-license-active {
background-color: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
margin-top: var(--spacing-md);
padding: var(--spacing-lg);
}
.virtualhid-license-input .form-control {
border-bottom-right-radius: 0;
border-top-right-radius: 0;
}
.virtualhid-license-input .btn {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
}
.virtualhid-license-active {
align-items: flex-start;
border-color: var(--color-success);
color: var(--color-success);
display: flex;
gap: var(--spacing-md);
}
.virtualhid-license-active svg {
flex: 0 0 auto;
}
.virtualhid-license-active p {
font-size: 0.875rem;
line-height: 1.5;
margin-top: var(--spacing-xs);
}
@media (max-width: 991.98px) {
.virtual-gamepad-feature-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 575.98px) {
.virtual-gamepad-section-heading,
.virtualhid-license-heading {
align-items: stretch;
flex-direction: column;
}
.driver-refresh-button {
justify-content: center;
width: 100%;
}
.virtualhid-license-toolbar {
justify-content: stretch;
}
.virtualhid-license-toolbar .btn {
justify-content: center;
width: 100%;
}
.virtualhid-license-input {
display: block;
}
.virtualhid-license-input .form-control,
.virtualhid-license-input .btn {
border-radius: var(--radius-md);
width: 100%;
}
.virtualhid-license-input .btn {
justify-content: center;
margin-top: var(--spacing-sm);
}
}
/* ==========================================================================
Navigation Tabs
========================================================================== */
▸
src_assets/common/assets/web/troubleshooting.html
+599
−74
@@ -9,44 +9,267 @@
<Navbar></Navbar>
<div id="content" class="container">
<h1 class="my-4">{{ $t('troubleshooting.troubleshooting') }}</h1>
<!-- ViGEmBus Installation -->
<div class="card my-4" v-if="platform === 'windows' && controllerEnabled">
<!-- Virtual gamepad and Virtual HID Driver license -->
<div class="card my-4 virtual-gamepad-card" v-if="platform === 'windows'">
<div class="card-body">
<h2 id="vigembus">{{ $t('troubleshooting.vigembus_install') }}</h2>
<p style="white-space: pre-line">{{ $t('troubleshooting.vigembus_desc') }}</p>
<div v-if="vigembus.installed && vigembus.version">
<p><strong>{{ $t('troubleshooting.vigembus_current_version') }}:</strong> {{ vigembus.version }}</p>
<div class="alert alert-success" v-if="vigembus.version_compatible">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.vigembus_compatible') }}
<header class="virtual-gamepad-hero">
<div class="virtual-gamepad-heading-icon" aria-hidden="true">
<gamepad-2 :size="28"></gamepad-2>
</div>
<div>
<h2 id="virtualhid" class="mb-1">{{ $t('troubleshooting.virtual_gamepad') }}</h2>
<p class="mb-0">{{ $t('troubleshooting.virtual_gamepad_desc') }}</p>
<div class="alert alert-danger" v-if="!vigembus.version_compatible">
<alert-circle :size="18" class="icon"></alert-circle>
{{ $t('troubleshooting.vigembus_incompatible') }}
</div>
</header>
<div class="virtual-gamepad-feature-grid" :aria-label="$t('troubleshooting.virtualhid_benefits_title')">
<article class="virtual-gamepad-feature">
<div class="virtual-gamepad-feature-icon" aria-hidden="true">
<gamepad-2 :size="21"></gamepad-2>
</div>
<div>
<h3>{{ $t('troubleshooting.virtualhid_benefit_gamepads_title') }}</h3>
<p>{{ $t('troubleshooting.virtualhid_benefit_gamepads') }}</p>
</div>
</article>
<article class="virtual-gamepad-feature">
<div class="virtual-gamepad-feature-icon" aria-hidden="true">
<sparkles :size="21"></sparkles>
</div>
<div>
<h3>{{ $t('troubleshooting.virtualhid_benefit_features_title') }}</h3>
<p>{{ $t('troubleshooting.virtualhid_benefit_features') }}</p>
</div>
</article>
<article class="virtual-gamepad-feature">
<div class="virtual-gamepad-feature-icon" aria-hidden="true">
<shield-check :size="21"></shield-check>
</div>
<div>
<h3>{{ $t('troubleshooting.virtualhid_benefit_maintained_title') }}</h3>
<p>{{ $t('troubleshooting.virtualhid_benefit_maintained') }}</p>
</div>
</article>
</div>
<section class="virtual-gamepad-section" v-if="controllerEnabled">
<div class="virtual-gamepad-section-heading">
<div>
<h3 class="h4 mb-1">{{ $t('troubleshooting.virtual_gamepad_drivers') }}</h3>
<p class="mb-0">{{ $t('troubleshooting.virtual_gamepad_drivers_desc') }}</p>
</div>
<button class="btn btn-outline-secondary driver-refresh-button"
type="button"
@click="refreshDriverInformation">
<refresh-cw :size="17" :class="{ 'driver-release-spinning': driverReleaseLoading }"></refresh-cw>
{{ $t('troubleshooting.driver_refresh') }}
</button>
</div>
<div class="table-responsive driver-table-shell">
<table class="table align-middle mb-0">
<thead>
<tr>
<th scope="col">{{ $t('troubleshooting.driver_name') }}</th>
<th scope="col">{{ $t('troubleshooting.driver_installed_version') }}</th>
<th scope="col">{{ $t('troubleshooting.driver_latest_version') }}</th>
<th scope="col">{{ $t('troubleshooting.driver_supported_versions') }}</th>
<th scope="col">{{ $t('troubleshooting.driver_status') }}</th>
<th scope="col" class="driver-download-column">
<span class="visually-hidden">{{ $t('troubleshooting.driver_download') }}</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">{{ $t('troubleshooting.virtualhid_driver') }}</th>
<td>{{ driverVersion(virtualhid) }}</td>
<td>
<div class="driver-release-state"
:class="driverReleaseStateClass(virtualhid, virtualhidRelease)"
:title="driverReleaseStatusText(virtualhid, virtualhidRelease)"
aria-live="polite">
<refresh-cw v-if="virtualhidRelease.loading"
:size="17"
class="driver-release-spinning"></refresh-cw>
<check-circle v-else-if="driverReleaseState(virtualhid, virtualhidRelease) === 'current'"
:size="17"></check-circle>
<alert-triangle v-else-if="driverReleaseState(virtualhid, virtualhidRelease) === 'outdated'"
:size="17"></alert-triangle>
<alert-circle v-else :size="17"></alert-circle>
<span>{{ driverLatestVersion(virtualhidRelease) }}</span>
</div>
<small class="driver-release-detail">{{ driverReleaseStatusText(virtualhid, virtualhidRelease) }}</small>
</td>
<td>{{ virtualhid.supported_versions }}</td>
<td>
<span :class="driverStatusClass(virtualhid)">
{{ driverStatusText(virtualhid) }}
</span>
</td>
<td class="driver-download-column">
<a class="btn btn-outline-primary driver-download-button"
:href="virtualhidRelease.url"
target="_blank"
rel="noopener noreferrer">
<download :size="17"></download>
{{ $t('troubleshooting.driver_download') }}
</a>
</td>
</tr>
<tr id="vigembus" v-if="showVigembus">
<th scope="row">{{ $t('troubleshooting.vigembus_driver') }}</th>
<td>{{ driverVersion(vigembus) }}</td>
<td>
<div class="driver-release-state"
:class="driverReleaseStateClass(vigembus, vigembusRelease)"
:title="driverReleaseStatusText(vigembus, vigembusRelease)"
aria-live="polite">
<refresh-cw v-if="vigembusRelease.loading"
:size="17"
class="driver-release-spinning"></refresh-cw>
<check-circle v-else-if="driverReleaseState(vigembus, vigembusRelease) === 'current'"
:size="17"></check-circle>
<alert-triangle v-else-if="driverReleaseState(vigembus, vigembusRelease) === 'outdated'"
:size="17"></alert-triangle>
<alert-circle v-else :size="17"></alert-circle>
<span>{{ driverLatestVersion(vigembusRelease) }}</span>
</div>
<small class="driver-release-detail">{{ driverReleaseStatusText(vigembus, vigembusRelease) }}</small>
</td>
<td>{{ vigembus.supported_versions }}</td>
<td>
<span :class="driverStatusClass(vigembus)">
{{ driverStatusText(vigembus) }}
</span>
</td>
<td class="driver-download-column">
<a class="btn btn-outline-primary driver-download-button"
:href="vigembusRelease.url"
target="_blank"
rel="noopener noreferrer">
<download :size="17"></download>
{{ $t('troubleshooting.driver_download') }}
</a>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section class="virtualhid-license-section" aria-labelledby="virtualhid-license">
<div class="virtualhid-license-heading">
<div>
<h3 id="virtualhid-license" class="h4 mb-1">{{ $t('troubleshooting.virtualhid_license') }}</h3>
<p class="mb-0">{{ $t('troubleshooting.virtualhid_license_desc') }}</p>
</div>
<span :class="licenseStatusClass()">{{ licenseStatusText() }}</span>
</div>
<div class="alert alert-danger alert-inline" role="alert" v-if="licenseError">
<alert-circle :size="18" class="icon flex-shrink-0"></alert-circle>
<span>{{ licenseError }}</span>
<div class="alert alert-warning" v-else-if="!vigembus.installed">
<alert-triangle :size="18" class="icon"></alert-triangle>
{{ $t('troubleshooting.vigembus_not_installed') }}
</div>
<div class="alert alert-success" v-if="vigemBusInstallStatus === true">
<check-circle :size="18" class="icon"></check-circle>
{{ $t('troubleshooting.vigembus_install_success') }}
</div>
<div class="alert alert-danger" v-if="vigemBusInstallStatus === false">
<alert-circle :size="18" class="icon"></alert-circle>
{{ vigemBusInstallError || $t('troubleshooting.vigembus_install_error') }}
</div>
<div>
<button
:class="vigembus.installed && vigembus.version === vigembus.packaged_version ? 'btn btn-danger' : 'btn btn-primary'"
:disabled="vigemBusInstallPressed"
@click="installViGEmBus">
<download :size="18" class="icon" v-if="!(vigembus.installed && vigembus.version === vigembus.packaged_version)"></download>
<refresh-cw :size="18" class="icon" v-else></refresh-cw>
{{ vigembus.installed && vigembus.version === vigembus.packaged_version ? $t('troubleshooting.vigembus_force_reinstall_button', { version: vigembus.packaged_version }) : $t('troubleshooting.vigembus_install_button', { version: vigembus.packaged_version }) }}
</button>
</div>
</div>
<div class="alert alert-warning alert-inline" role="alert" v-else-if="!virtualhidLicense.service_available">
<alert-triangle :size="18" class="icon flex-shrink-0"></alert-triangle>
<span>{{ virtualhidLicense.message || $t('troubleshooting.virtualhid_license_unavailable') }}</span>
</div>
<div class="virtualhid-license-stats">
<dl class="virtualhid-license-stat" v-if="virtualhidLicense.plan_name">
<dt>{{ $t('troubleshooting.virtualhid_license_plan') }}</dt>
<dd>{{ virtualhidLicense.plan_name }}</dd>
</dl>
<dl class="virtualhid-license-stat">
<dt>{{ $t('troubleshooting.virtualhid_license_activations') }}</dt>
<dd>{{ licenseActivationText() }}</dd>
</dl>
<dl class="virtualhid-license-stat">
<dt>{{ $t('troubleshooting.virtualhid_license_active_devices') }}</dt>
<dd>{{ virtualhidLicense.active_devices }}</dd>
</dl>
<dl class="virtualhid-license-stat" v-if="virtualhidLicense.customer_email">
<dt>{{ $t('troubleshooting.virtualhid_license_customer') }}</dt>
<dd>{{ virtualhidLicense.customer_email }}</dd>
</dl>
<dl class="virtualhid-license-stat" v-if="virtualhidLicense.expires_at">
<dt>{{ $t('troubleshooting.virtualhid_license_expires') }}</dt>
<dd>{{ virtualhidLicense.expires_at }}</dd>
</dl>
</div>
<p class="virtualhid-license-message"
v-if="virtualhidLicense.message && virtualhidLicense.service_available">
{{ virtualhidLicense.message }}
</p>
<div class="virtualhid-license-toolbar">
<button class="btn btn-secondary"
type="button"
:disabled="licenseBusy || !virtualhidLicense.service_available"
@click="updateLicense('validate')">
<refresh-cw :size="18" :class="{ 'driver-release-spinning': licenseBusy }"></refresh-cw>
{{ $t('troubleshooting.virtualhid_license_refresh') }}
</button>
<a class="btn btn-outline-primary"
v-if="virtualhidLicense.manage_account_url"
:href="virtualhidLicense.manage_account_url"
target="_blank"
rel="noopener noreferrer">
<external-link :size="18"></external-link>
{{ $t('troubleshooting.virtualhid_license_manage') }}
</a>
<a class="btn btn-success"
v-if="virtualhidLicense.purchase_url && !virtualhidLicense.licensed"
:href="virtualhidLicense.purchase_url"
target="_blank"
rel="noopener noreferrer">
{{ $t('troubleshooting.virtualhid_license_buy') }}
</a>
<button class="btn btn-outline-danger"
type="button"
v-if="virtualhidLicense.licensed"
:disabled="licenseBusy || !virtualhidLicense.service_available"
@click="updateLicense('deactivate')">
{{ $t('troubleshooting.virtualhid_license_deactivate') }}
</button>
</div>
<div class="virtualhid-license-activation" v-if="!virtualhidLicense.licensed">
<label for="virtualhid-license-key" class="form-label">
{{ $t('troubleshooting.virtualhid_license_key') }}
</label>
<div class="input-group virtualhid-license-input">
<input id="virtualhid-license-key"
class="form-control"
type="password"
v-model="licenseKey"
:placeholder="$t('troubleshooting.virtualhid_license_key_placeholder')"
:disabled="licenseBusy || !virtualhidLicense.service_available"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
@keyup.enter="updateLicense('activate')" />
<button class="btn btn-primary"
type="button"
:disabled="licenseBusy || !virtualhidLicense.service_available || !licenseKey.trim()"
@click="updateLicense('activate')">
<key-round :size="18"></key-round>
{{ $t('troubleshooting.virtualhid_license_activate') }}
</button>
</div>
<div class="form-text">{{ $t('troubleshooting.virtualhid_license_key_desc') }}</div>
</div>
<div class="virtualhid-license-active" v-else>
<check-circle :size="22" aria-hidden="true"></check-circle>
<div>
<strong>{{ $t('troubleshooting.virtualhid_license_machine_activated') }}</strong>
<p class="mb-0">{{ $t('troubleshooting.virtualhid_license_machine_activated_desc') }}</p>
</div>
</div>
</section>
</div>
</div>
<!-- Force Close App -->
@@ -214,9 +437,14 @@
ChevronsUp,
Copy,
Download,
ExternalLink,
Gamepad2,
KeyRound,
RefreshCw,
RotateCcw,
Search,
ShieldCheck,
Sparkles,
Trash2,
XCircle,
} from '@lucide/vue'
@@ -234,9 +462,14 @@
ChevronsUp,
Copy,
Download,
ExternalLink,
Gamepad2,
KeyRound,
RefreshCw,
RotateCcw,
Search,
ShieldCheck,
Sparkles,
Trash2,
XCircle,
},
@@ -251,21 +484,57 @@
logs: 'Loading...',
logFilter: null,
logInterval: null,
licenseBusy: false,
licenseError: '',
licenseKey: '',
restartPressed: false,
showApplyMessage: false,
platform: "",
controllerEnabled: false,
virtualInputStatusLoaded: false,
unpairAllPressed: false,
unpairAllStatus: null,
virtualhid: {
installed: false,
version: '',
version_compatible: false,
minimum_version: '',
supported_versions: '',
},
virtualhidLicense: {
operation_ok: false,
service_available: false,
state: 'unavailable',
licensed: false,
active_devices: 0,
activation_limit: 0,
activation_usage: 0,
plan_name: '',
customer_email: '',
expires_at: '',
message: '',
purchase_url: '',
manage_account_url: '',
},
virtualhidRelease: {
loading: false,
version: '',
url: 'https://github.com/LizardByte/libvirtualhid/releases/latest',
error: false,
},
vigembus: {
installed: false,
version: '',
version_compatible: false,
minimum_version: '',
supported_versions: '',
},
vigembusRelease: {
packaged_version: '',
loading: false,
version: '',
url: 'https://github.com/nefarius/ViGEmBus/releases/latest',
error: false,
},
vigemBusInstallPressed: false,
vigemBusInstallStatus: null,
vigemBusInstallError: null,
currentLogIndex: -1,
logLines: [],
};
@@ -386,6 +655,14 @@
if (indices.length === 0) return false;
if (this.currentLogIndex === -1) return false;
return indices.some((i) => i < this.currentLogIndex);
},
driverReleaseLoading() {
return this.virtualhidRelease.loading || (this.showVigembus && this.vigembusRelease.loading);
},
showVigembus() {
return this.virtualInputStatusLoaded && (!this.virtualhid.installed || this.vigembus.installed);
}
},
created() {
@@ -395,10 +672,13 @@
.then((r) => {
this.platform = r.platform;
this.controllerEnabled = r.controller !== "disabled";
// Fetch ViGEmBus status only on Windows when gamepad is enabled
// Fetch virtual input driver status only on Windows when gamepad is enabled
if (this.platform === 'windows' && this.controllerEnabled) {
this.refreshDriverInformation();
this.refreshViGEmBusStatus();
}
if (this.platform === 'windows') {
this.refreshLicenseStatus();
}
});
this.logInterval = setInterval(() => {
@@ -538,54 +818,299 @@
}, 5000);
});
},
/**
* @brief Refresh the installed driver details and latest stable GitHub releases.
*/
refreshDriverInformation() {
this.refreshVirtualInputStatus();
this.refreshDriverReleases();
},
/**
* @brief Refresh the latest stable release metadata for both Windows gamepad drivers.
refreshViGEmBusStatus() {
fetch("/api/vigembus/status")
*/
refreshDriverReleases() {
this.updateLatestRelease(
'virtualhidRelease',
'LizardByte/libvirtualhid',
'https://github.com/LizardByte/libvirtualhid/releases/latest',
);
this.updateLatestRelease(
'vigembusRelease',
'nefarius/ViGEmBus',
'https://github.com/nefarius/ViGEmBus/releases/latest',
);
},
/**
* @brief Fetch one repository's latest non-prerelease release from GitHub.
*
* @param {string} property Component data property that stores the release metadata.
* @param {string} repository GitHub repository in owner/name form.
* @param {string} fallbackUrl Releases page used if the API request fails.
*/
updateLatestRelease(property, repository, fallbackUrl) {
this[property] = {
...this[property],
loading: true,
error: false,
};
fetch(`https://api.github.com/repos/${repository}/releases/latest`, {
headers: {
Accept: 'application/vnd.github+json',
},
})
.then((response) => {
if (!response.ok) {
throw new Error(`GitHub returned ${response.status}`);
}
return response.json();
})
.then((release) => {
if (!release.tag_name || release.draft || release.prerelease) {
throw new Error('GitHub did not return a stable release');
}
this[property] = {
loading: false,
version: release.tag_name,
url: release.html_url || fallbackUrl,
error: false,
};
})
.catch((error) => {
console.error(`Failed to fetch the latest ${repository} release:`, error);
this[property] = {
loading: false,
version: '',
url: fallbackUrl,
error: true,
};
});
},
refreshVirtualInputStatus() {
fetch("/api/virtual-input/status")
.then((r) => r.json())
.then((r) => {
const virtualhid = r.virtualhid || {};
const vigembus = r.vigembus || {};
this.virtualhid = {
installed: virtualhid.installed || false,
version: virtualhid.version || '',
version_compatible: virtualhid.version_compatible || false,
minimum_version: virtualhid.minimum_version || '',
supported_versions: virtualhid.supported_versions || '',
};
this.vigembus = {
installed: r.installed || false,
version: r.version || '',
installed: vigembus.installed || false,
version: vigembus.version || '',
version_compatible: vigembus.version_compatible || false,
minimum_version: vigembus.minimum_version || '',
supported_versions: vigembus.supported_versions || '',
version_compatible: r.version_compatible || false,
packaged_version: r.packaged_version,
};
this.virtualInputStatusLoaded = true;
})
.catch((err) => {
console.error("Failed to fetch ViGEmBus status:", err);
console.error("Failed to fetch virtual input driver status:", err);
});
},
installViGEmBus() {
this.vigemBusInstallPressed = true;
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
apiFetch("/api/vigembus/install", {
/**
* @brief Parse a numeric dotted driver or release version.
*
* @param {string} version Version string, optionally prefixed with "v".
* @return {number[]|null} Numeric version parts, or null for an invalid version.
*/
parseDriverVersion(version) {
const match = /^v?(\d+(?:\.\d+)*)/i.exec(String(version || '').trim());
return match ? match[1].split('.').map(Number) : null;
},
/**
* @brief Compare installed and latest driver versions.
*
* @param {string} installedVersion Installed driver version.
* @param {string} latestVersion Latest stable release version.
* @return {number|null} Negative if outdated, zero if equal, positive if newer, or null if invalid.
*/
compareDriverVersions(installedVersion, latestVersion) {
const installedParts = this.parseDriverVersion(installedVersion);
const latestParts = this.parseDriverVersion(latestVersion);
if (!installedParts || !latestParts) {
return null;
}
const partCount = Math.max(installedParts.length, latestParts.length);
for (let index = 0; index < partCount; ++index) {
const installedPart = installedParts[index] || 0;
const latestPart = latestParts[index] || 0;
if (installedPart !== latestPart) {
return installedPart > latestPart ? 1 : -1;
}
}
return 0;
},
/**
* @brief Get the availability state for one installed driver and GitHub release.
*
* @param {object} driver Installed driver status.
* @param {object} release Latest GitHub release status.
* @return {string} Release state used to select the icon and message.
*/
driverReleaseState(driver, release) {
if (release.loading) {
return 'loading';
}
if (release.error || !release.version) {
return 'unavailable';
}
if (!driver.installed) {
return 'not-installed';
}
const comparison = this.compareDriverVersions(driver.version, release.version);
if (comparison === null) {
return 'unknown';
}
return comparison >= 0 ? 'current' : 'outdated';
},
/**
* @brief Get the localized release comparison message for a driver.
*
* @param {object} driver Installed driver status.
* @param {object} release Latest GitHub release status.
* @return {string} Localized release comparison message.
*/
driverReleaseStatusText(driver, release) {
const state = this.driverReleaseState(driver, release).replace('-', '_');
return this.$t(`troubleshooting.driver_release_${state}`);
},
/**
* @brief Get a theme-aware text class for a driver release state.
*
* @param {object} driver Installed driver status.
* @param {object} release Latest GitHub release status.
* @return {string} CSS class for the release icon and version.
*/
driverReleaseStateClass(driver, release) {
const state = this.driverReleaseState(driver, release);
if (state === 'current') {
return 'driver-release-current';
}
if (state === 'outdated') {
return 'driver-release-outdated';
}
return 'driver-release-neutral';
},
/**
* @brief Format a latest release version for the drivers table.
*
* @param {object} release Latest GitHub release status.
* @return {string} Release version or a localized placeholder.
*/
driverLatestVersion(release) {
if (release.loading) {
return this.$t('troubleshooting.driver_release_checking');
}
return release.version || this.$t('troubleshooting.driver_release_unavailable');
},
applyLicenseStatus(status) {
this.virtualhidLicense = {
...this.virtualhidLicense,
...status,
};
this.licenseError = status.error || '';
},
refreshLicenseStatus() {
this.licenseBusy = true;
this.licenseError = '';
fetch("/api/virtual-input/license")
.then(async (response) => {
const status = await response.json();
if (!response.ok) {
throw new Error(status.error || this.$t('troubleshooting.virtualhid_license_request_failed'));
}
this.applyLicenseStatus(status);
})
.catch((error) => {
this.licenseError = error.message;
})
.finally(() => {
this.licenseBusy = false;
});
},
updateLicense(action) {
if (this.licenseBusy || (action === 'activate' && !this.licenseKey.trim())) {
return;
}
this.licenseBusy = true;
this.licenseError = '';
const body = { action };
if (action === 'activate') {
body.license_key = this.licenseKey.trim();
}
apiFetch("/api/virtual-input/license", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Type": "application/json"
},
body: JSON.stringify(body),
}
})
.then(async (response) => {
.then((r) => r.json())
.then((r) => {
this.vigemBusInstallPressed = false;
this.vigemBusInstallStatus = r.status;
if (!r.status && r.error) {
const status = await response.json();
if (!response.ok) {
throw new Error(status.error || this.$t('troubleshooting.virtualhid_license_request_failed'));
this.vigemBusInstallError = r.error;
}
setTimeout(() => {
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
}, 10000);
// Refresh status after installation attempt
this.applyLicenseStatus(status);
if (action === 'activate' && status.operation_ok) {
this.licenseKey = '';
}
})
.catch((error) => {
this.licenseError = error.message;
this.refreshViGEmBusStatus();
})
.catch((err) => {
this.vigemBusInstallPressed = false;
this.vigemBusInstallStatus = false;
this.vigemBusInstallError = err.message;
setTimeout(() => {
this.vigemBusInstallStatus = null;
this.vigemBusInstallError = null;
}, 10000);
.finally(() => {
body.license_key = '';
this.licenseBusy = false;
});
},
licenseStatusClass() {
if (this.virtualhidLicense.state === 'licensed') {
return 'badge text-bg-success';
}
if (['expired', 'disabled', 'invalid'].includes(this.virtualhidLicense.state)) {
return 'badge text-bg-danger';
}
return 'badge text-bg-secondary';
},
licenseStatusText() {
const key = `troubleshooting.virtualhid_license_state_${this.virtualhidLicense.state}`;
return this.$t(key);
},
licenseActivationText() {
if (!this.virtualhidLicense.activation_limit) {
return this.$t('troubleshooting.virtualhid_license_not_reported');
}
return `${this.virtualhidLicense.activation_usage} / ${this.virtualhidLicense.activation_limit}`;
},
driverVersion(driver) {
if (!driver.installed) {
return this.$t('troubleshooting.driver_not_installed');
}
return driver.version || this.$t('troubleshooting.driver_version_unknown');
},
driverStatusClass(driver) {
if (!driver.installed) {
return 'badge text-bg-secondary';
}
return driver.version_compatible ? 'badge text-bg-success' : 'badge text-bg-danger';
},
driverStatusText(driver) {
if (!driver.installed) {
return this.$t('troubleshooting.driver_status_not_installed');
}
if (driver.version_compatible) {
return this.$t('troubleshooting.driver_status_compatible');
}
return this.$t('troubleshooting.driver_status_unsupported');
},
navigateToLog(direction) {
const indices = this.errorWarningEntries;
▸
src_assets/linux/misc/60-sunshine.rules
+4
−5
@@ -5,8 +5,7 @@
KERNEL=="uhid", GROUP="input", MODE="0660", TAG+="uaccess"
# Joypads
KERNEL=="hidraw*", ATTRS{name}=="Sunshine PS5 (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine X-Box One (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine gamepad (virtual) motion sensors*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine Nintendo (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine PS5 (virtual) pad*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{phys}=="libvirtualhid/uhid/*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEM=="input", KERNEL=="event*", ATTRS{phys}=="libvirtualhid/uhid/*", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="hidraw*", ATTRS{name}=="Sunshine (libvirtualhid)*", GROUP="input", MODE="0660", TAG+="uaccess"
SUBSYSTEMS=="input", ATTRS{name}=="Sunshine (libvirtualhid)*", GROUP="input", MODE="0660", TAG+="uaccess"
▸
tests/CMakeLists.txt
+7
−0
@@ -122,6 +122,13 @@
"${SUNSHINE_TRAY_TEST_ICON_DESTINATION_DIR}/logo-sunshine.svg"
COPYONLY)
if(WIN32)
configure_file(
"${CMAKE_SOURCE_DIR}/third-party/libvirtualhid/libvirtualhid.svg"
"${SUNSHINE_TRAY_TEST_ICON_DESTINATION_DIR}/logo-libvirtualhid.svg"
COPYONLY)
endif()
set(SUNSHINE_TRAY_TEST_ICONS
sunshine-locked.svg
sunshine-playing.svg
▸
tests/unit/platform/test_virtualhid_input.cpp
+850
−0
@@ -1,0 +1,850 @@
/**
* @file tests/unit/platform/test_virtualhid_input.cpp
* @brief Tests for shared libvirtualhid input helpers.
*/
#include "../../tests_common.h"
// standard includes
#include <array>
#include <chrono>
#include <cstdint>
#include <limits>
#include <memory>
#include <string>
#include <string_view>
#include <tuple>
#include <utility>
// local includes
#include "src/config.h"
#include "src/globals.h"
#include "src/platform/virtualhid_input.h"
using namespace std::chrono_literals;
using namespace std::literals;
namespace {
/**
* @brief Expected touchpad support for a configured gamepad.
*/
struct gamepad_touchpad_case_t {
std::string_view gamepad; ///< Configured gamepad name.
bool expected; ///< Whether the configured gamepad supports touchpad input.
};
/**
* @brief Parameterized fixture that restores the configured gamepad after each test.
*/
class VirtualHidInputTest: public ::testing::TestWithParam<gamepad_touchpad_case_t> {
protected:
/**
* @brief Preserve the configured gamepad.
*/
void SetUp() override {
original_gamepad = config::input.gamepad;
}
/**
* @brief Restore the configured gamepad.
*/
void TearDown() override {
config::input.gamepad = std::move(original_gamepad);
}
private:
std::string original_gamepad; ///< Configured gamepad restored after each test.
};
} // namespace
TEST_P(VirtualHidInputTest, ReportsExpectedTouchpadSupport) {
const auto &[gamepad, expected] = GetParam();
config::input.gamepad = gamepad;
EXPECT_EQ(platf::virtualhid::configured_gamepad_supports_touchpad(), expected) << gamepad;
}
INSTANTIATE_TEST_SUITE_P(
ConfiguredGamepads,
VirtualHidInputTest,
::testing::Values(
gamepad_touchpad_case_t {"auto"sv, true},
gamepad_touchpad_case_t {"generic"sv, false},
gamepad_touchpad_case_t {"x360"sv, false},
gamepad_touchpad_case_t {"xone"sv, false},
gamepad_touchpad_case_t {"xseries"sv, false},
gamepad_touchpad_case_t {"ds4"sv, true},
gamepad_touchpad_case_t {"ds5"sv, true},
gamepad_touchpad_case_t {"switch"sv, false}
),
[](const ::testing::TestParamInfo<gamepad_touchpad_case_t> &info) {
return std::string {info.param.gamepad};
}
);
namespace {
/**
* @brief Fixture backed by libvirtualhid's observable in-memory devices.
*/
class VirtualHidDeviceTest: public ::testing::Test {
protected:
/**
* @brief Preserve input configuration and create fake virtual devices.
*/
void SetUp() override {
original_input = config::input;
context_ = std::make_unique<platf::virtualhid::input_context_t>(lvh::BackendKind::fake);
client_ = std::make_unique<platf::virtualhid::client_context_t>(*context_);
const auto *test_info = ::testing::UnitTest::GetInstance()->current_test_info();
const std::string queue_name = std::string {"virtualhid-input-test-"} + test_info->test_suite_name() + '-' + test_info->name();
feedback_queue_ = mail::man->queue<platf::gamepad_feedback_msg_t>(queue_name);
ASSERT_NE(context_->runtime, nullptr);
ASSERT_NE(context_->keyboard, nullptr);
ASSERT_NE(context_->mouse, nullptr);
ASSERT_NE(client_->touch, nullptr);
ASSERT_NE(client_->pen, nullptr);
}
/**
* @brief Restore process-wide input configuration.
*/
void TearDown() override {
client_.reset();
context_.reset();
feedback_queue_.reset();
config::input = std::move(original_input);
}
/**
* @brief Allocate a fake gamepad and return its state adapter.
*
* @param profile Configured Sunshine profile name.
* @param type Client-reported controller type.
* @param capabilities Client-reported capability flags.
* @param global_index Global Sunshine gamepad slot.
* @param client_index Client-relative gamepad index.
* @return Allocated adapter, or `nullptr` when allocation fails.
*/
lvh::GamepadStateAdapter *allocate_gamepad(
std::string_view profile,
std::uint8_t type = LI_CTYPE_UNKNOWN,
std::uint16_t capabilities = 0,
int global_index = 0,
std::uint8_t client_index = 3
) {
config::input.gamepad = profile;
const platf::gamepad_id_t id {global_index, client_index};
const platf::gamepad_arrival_t metadata {type, capabilities, 0};
if (platf::virtualhid::alloc_gamepad(*context_, id, metadata, feedback_queue_) != 0) {
ADD_FAILURE() << "Failed to allocate fake gamepad profile " << profile;
return nullptr;
}
return platf::virtualhid::gamepad_adapter_for_testing(*context_, global_index);
}
/**
* @brief Access the fake global virtual HID context.
*
* @return Pointer to the fixture-owned global context.
*/
platf::virtualhid::input_context_t *context() const {
return context_.get();
}
/**
* @brief Access the fake per-client virtual HID context.
*
* @return Pointer to the fixture-owned client context.
*/
platf::virtualhid::client_context_t *client() const {
return client_.get();
}
/**
* @brief Access the gamepad feedback queue.
*
* @return Reference to the fixture-owned feedback queue.
*/
platf::feedback_queue_t &feedback_queue() {
return feedback_queue_;
}
private:
config::input_t original_input; ///< Input configuration restored after each test.
std::unique_ptr<platf::virtualhid::input_context_t> context_; ///< Shared fake virtual devices.
std::unique_ptr<platf::virtualhid::client_context_t> client_; ///< Per-client fake touch and pen devices.
platf::feedback_queue_t feedback_queue_; ///< Captured gamepad feedback messages.
};
/**
* @brief Auto-selection scenario for a client-reported gamepad.
*/
struct auto_profile_case_t {
const char *name; ///< Stable parameter name.
std::uint8_t type; ///< Client-reported controller type.
std::uint16_t capabilities; ///< Client-reported capability flags.
lvh::GamepadProfileKind expected; ///< Expected libvirtualhid profile.
};
/**
* @brief Parameterized fixture for automatic gamepad profile selection.
*/
class VirtualHidAutoProfileTest:
public VirtualHidDeviceTest,
public ::testing::WithParamInterface<auto_profile_case_t> {};
} // namespace
TEST_F(VirtualHidDeviceTest, CreatesEveryDeviceWithFakeRuntime) {
EXPECT_EQ(context()->runtime->backend_kind(), lvh::BackendKind::fake);
EXPECT_EQ(context()->runtime->capabilities().backend_name, "fake");
EXPECT_TRUE(context()->runtime->capabilities().supports_gamepad);
EXPECT_TRUE(context()->keyboard->is_open());
EXPECT_TRUE(context()->mouse->is_open());
EXPECT_TRUE(client()->touch->is_open());
EXPECT_TRUE(client()->pen->is_open());
const auto runtime = platf::virtualhid::create_runtime(lvh::BackendKind::fake);
ASSERT_NE(runtime, nullptr);
EXPECT_EQ(runtime->backend_kind(), lvh::BackendKind::fake);
}
TEST_F(VirtualHidDeviceTest, ReportsStaticAndRuntimeGamepadChoices) {
constexpr std::array expected_names {"auto"sv, "generic"sv, "x360"sv, "xone"sv, "xseries"sv, "ds4"sv, "ds5"sv, "switch"sv};
const auto static_gamepads = platf::virtualhid::static_supported_gamepads();
ASSERT_EQ(static_gamepads.size(), expected_names.size());
EXPECT_TRUE(static_gamepads.front().is_enabled);
for (std::size_t index = 0; index < expected_names.size(); ++index) {
EXPECT_EQ(static_gamepads[index].name, expected_names[index]);
EXPECT_TRUE(static_gamepads[index].reason_disabled.empty());
}
const auto available_gamepads = platf::virtualhid::supported_gamepads(context()->runtime.get());
ASSERT_EQ(available_gamepads.size(), expected_names.size());
for (const auto &gamepad : available_gamepads) {
EXPECT_TRUE(gamepad.is_enabled) << gamepad.name;
EXPECT_TRUE(gamepad.reason_disabled.empty()) << gamepad.name;
}
const auto no_runtime_gamepads = platf::virtualhid::supported_gamepads(nullptr, true);
EXPECT_EQ(no_runtime_gamepads.size(), expected_names.size());
}
TEST_F(VirtualHidDeviceTest, RejectsUnavailableAndInvalidGamepadSlots) {
const platf::gamepad_arrival_t metadata {LI_CTYPE_XBOX, 0, 0};
const platf::gamepad_id_t valid_id {0, 0};
platf::virtualhid::input_context_t no_runtime {lvh::BackendKind::fake};
no_runtime.runtime.reset();
EXPECT_EQ(platf::virtualhid::alloc_gamepad(no_runtime, valid_id, metadata, nullptr), -1);
EXPECT_EQ(platf::virtualhid::alloc_gamepad(*context(), {-1, 0}, metadata, nullptr), -1);
EXPECT_EQ(platf::virtualhid::alloc_gamepad(*context(), {static_cast<int>(context()->gamepads.size()), 0}, metadata, nullptr), -1);
EXPECT_FALSE(platf::virtualhid::has_gamepad(*context(), -1));
EXPECT_FALSE(platf::virtualhid::has_gamepad(*context(), static_cast<int>(context()->gamepads.size())));
EXPECT_EQ(platf::virtualhid::gamepad_adapter_for_testing(*context(), 0), nullptr);
EXPECT_EQ(platf::virtualhid::rebind_gamepad(*context(), valid_id, feedback_queue()), -1);
}
TEST_F(VirtualHidDeviceTest, AllocatesManualProfileAndTranslatesFullState) {
const auto active_devices_before_gamepad = context()->runtime->active_device_count();
config::input.ds4_back_as_touchpad_click = true;
config::input.virtualhid_randomize_mac = false;
const auto capabilities = static_cast<std::uint16_t>(LI_CCAP_ACCEL | LI_CCAP_GYRO | LI_CCAP_TOUCHPAD | LI_CCAP_RGB_LED | LI_CCAP_BATTERY_STATE);
auto *adapter = allocate_gamepad("ds5"sv, LI_CTYPE_PS, capabilities, 1, 4);
ASSERT_NE(adapter, nullptr);
ASSERT_NE(adapter->gamepad(), nullptr);
EXPECT_EQ(context()->runtime->active_device_count(), active_devices_before_gamepad + 1);
EXPECT_EQ(adapter->gamepad()->profile().gamepad_kind, lvh::GamepadProfileKind::dualsense);
EXPECT_TRUE(adapter->gamepad()->profile().name.starts_with("Sunshine "));
const auto &metadata = adapter->gamepad()->metadata();
EXPECT_EQ(metadata.global_index, 1);
EXPECT_EQ(metadata.client_relative_index, 4);
EXPECT_EQ(metadata.client_type, lvh::ClientControllerType::playstation);
EXPECT_TRUE(metadata.has_motion_sensors);
EXPECT_TRUE(metadata.has_touchpad);
EXPECT_TRUE(metadata.has_rgb_led);
EXPECT_TRUE(metadata.has_battery);
EXPECT_EQ(metadata.stable_id, "02:00:00:00:00:01");
const std::uint32_t button_flags = platf::DPAD_UP | platf::DPAD_DOWN | platf::DPAD_LEFT | platf::DPAD_RIGHT | platf::START | platf::BACK |
platf::LEFT_STICK | platf::RIGHT_STICK | platf::LEFT_BUTTON | platf::RIGHT_BUTTON | platf::HOME |
platf::A | platf::B | platf::X | platf::Y | platf::MISC_BUTTON;
const platf::gamepad_state_t input_state {
button_flags,
128,
255,
std::numeric_limits<std::int16_t>::min(),
std::numeric_limits<std::int16_t>::max(),
-16384,
0,
};
platf::virtualhid::gamepad_update(*context(), 1, input_state);
const auto &state = adapter->state();
using enum lvh::GamepadButton;
for (const auto button : {dpad_up, dpad_down, dpad_left, dpad_right, start, back, left_stick, right_stick, left_shoulder, right_shoulder, guide, a, b, x, y, misc1, touchpad}) {
EXPECT_TRUE(state.buttons.test(button));
}
EXPECT_FLOAT_EQ(state.left_stick.x, -1.0F);
EXPECT_FLOAT_EQ(state.left_stick.y, 1.0F);
EXPECT_FLOAT_EQ(state.right_stick.x, -0.5F);
EXPECT_FLOAT_EQ(state.right_stick.y, 0.0F);
EXPECT_FLOAT_EQ(state.left_trigger, 128.0F / 255.0F);
EXPECT_FLOAT_EQ(state.right_trigger, 1.0F);
config::input.ds4_back_as_touchpad_click = false;
platf::virtualhid::gamepad_update(*context(), 1, {platf::BACK, 0, 0, 0, 0, 0, 0});
EXPECT_FALSE(adapter->state().buttons.test(touchpad));
platf::virtualhid::free_gamepad(*context(), 1);
EXPECT_FALSE(platf::virtualhid::has_gamepad(*context(), 1));
EXPECT_EQ(context()->runtime->active_device_count(), active_devices_before_gamepad);
platf::virtualhid::free_gamepad(*context(), 1);
platf::virtualhid::gamepad_update(*context(), 1, input_state);
}
TEST_P(VirtualHidAutoProfileTest, SelectsProfileFromClientMetadata) {
const auto &test_case = GetParam();
auto *adapter = allocate_gamepad("auto"sv, test_case.type, test_case.capabilities);
ASSERT_NE(adapter, nullptr);
ASSERT_NE(adapter->gamepad(), nullptr);
EXPECT_EQ(adapter->gamepad()->profile().gamepad_kind, test_case.expected);
}
INSTANTIATE_TEST_SUITE_P(
ClientMetadata,
VirtualHidAutoProfileTest,
::testing::Values(
auto_profile_case_t {"PlayStation", LI_CTYPE_PS, 0, lvh::GamepadProfileKind::dualsense},
auto_profile_case_t {"Nintendo", LI_CTYPE_NINTENDO, 0, lvh::GamepadProfileKind::switch_pro},
auto_profile_case_t {"Xbox", LI_CTYPE_XBOX, 0, lvh::GamepadProfileKind::xbox_series},
auto_profile_case_t {"UnknownMotion", LI_CTYPE_UNKNOWN, LI_CCAP_ACCEL, lvh::GamepadProfileKind::dualsense},
auto_profile_case_t {"UnknownTouchpad", LI_CTYPE_UNKNOWN, LI_CCAP_TOUCHPAD, lvh::GamepadProfileKind::dualsense},
auto_profile_case_t {"UnknownDefault", LI_CTYPE_UNKNOWN, 0, lvh::GamepadProfileKind::xbox_series}
),
[](const ::testing::TestParamInfo<auto_profile_case_t> &info) {
return info.param.name;
}
);
TEST_F(VirtualHidDeviceTest, FallsBackForUnknownManualProfileAndRandomizesPlayStationIdentity) {
auto *fallback = allocate_gamepad("not-a-profile"sv, LI_CTYPE_UNKNOWN, 0, 0);
ASSERT_NE(fallback, nullptr);
EXPECT_EQ(fallback->gamepad()->profile().gamepad_kind, lvh::GamepadProfileKind::xbox_series);
EXPECT_EQ(fallback->gamepad()->metadata().client_type, lvh::ClientControllerType::unknown);
EXPECT_EQ(fallback->gamepad()->metadata().stable_id, "sunshine-gamepad-0");
config::input.virtualhid_randomize_mac = true;
auto *randomized = allocate_gamepad("ds4"sv, LI_CTYPE_PS, 0, 1);
ASSERT_NE(randomized, nullptr);
const auto &stable_id = randomized->gamepad()->metadata().stable_id;
EXPECT_EQ(stable_id.size(), 17);
EXPECT_TRUE(stable_id.starts_with("02:00:"));
}
TEST_F(VirtualHidDeviceTest, RoutesAndDeduplicatesGamepadFeedback) {
auto *adapter = allocate_gamepad("xseries"sv, LI_CTYPE_XBOX);
ASSERT_NE(adapter, nullptr);
lvh::GamepadOutput output;
output.kind = lvh::GamepadOutputKind::rumble;
output.low_frequency_rumble = 100;
output.high_frequency_rumble = 200;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
auto feedback = feedback_queue()->pop(10ms);
ASSERT_TRUE(feedback);
EXPECT_EQ(feedback->type, platf::gamepad_feedback_e::rumble);
EXPECT_EQ(feedback->id, 3);
EXPECT_EQ(feedback->data.rumble.lowfreq, 100);
EXPECT_EQ(feedback->data.rumble.highfreq, 200);
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_FALSE(feedback_queue()->pop(0ms));
output.high_frequency_rumble = 201;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_TRUE(feedback_queue()->pop(10ms));
output.kind = lvh::GamepadOutputKind::trigger_rumble;
output.left_trigger_rumble = 300;
output.right_trigger_rumble = 400;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
feedback = feedback_queue()->pop(10ms);
ASSERT_TRUE(feedback);
EXPECT_EQ(feedback->type, platf::gamepad_feedback_e::rumble_triggers);
EXPECT_EQ(feedback->data.rumble_triggers.left_trigger, 300);
EXPECT_EQ(feedback->data.rumble_triggers.right_trigger, 400);
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_FALSE(feedback_queue()->pop(0ms));
++output.right_trigger_rumble;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_TRUE(feedback_queue()->pop(10ms));
output.kind = lvh::GamepadOutputKind::rgb_led;
output.red = 10;
output.green = 20;
output.blue = 30;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
feedback = feedback_queue()->pop(10ms);
ASSERT_TRUE(feedback);
EXPECT_EQ(feedback->type, platf::gamepad_feedback_e::set_rgb_led);
EXPECT_EQ(feedback->data.rgb_led.r, 10);
EXPECT_EQ(feedback->data.rgb_led.g, 20);
EXPECT_EQ(feedback->data.rgb_led.b, 30);
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_FALSE(feedback_queue()->pop(0ms));
++output.blue;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_TRUE(feedback_queue()->pop(10ms));
output.kind = lvh::GamepadOutputKind::adaptive_triggers;
output.adaptive_trigger_flags = 5;
output.left_trigger_effect_type = 6;
output.right_trigger_effect_type = 7;
output.left_trigger_effect = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
output.right_trigger_effect = {9, 8, 7, 6, 5, 4, 3, 2, 1, 0};
ASSERT_TRUE(adapter->dispatch_output(output).ok());
feedback = feedback_queue()->pop(10ms);
ASSERT_TRUE(feedback);
EXPECT_EQ(feedback->type, platf::gamepad_feedback_e::set_adaptive_triggers);
EXPECT_EQ(feedback->data.adaptive_triggers.event_flags, 5);
EXPECT_EQ(feedback->data.adaptive_triggers.type_left, 6);
EXPECT_EQ(feedback->data.adaptive_triggers.type_right, 7);
EXPECT_EQ(feedback->data.adaptive_triggers.left, output.left_trigger_effect);
EXPECT_EQ(feedback->data.adaptive_triggers.right, output.right_trigger_effect);
auto resumed_feedback = mail::man->queue<platf::gamepad_feedback_msg_t>("virtualhid-input-test-resumed-feedback");
ASSERT_EQ(platf::virtualhid::rebind_gamepad(*context(), {0, 7}, resumed_feedback), 0);
output.kind = lvh::GamepadOutputKind::rumble;
output.low_frequency_rumble = 100;
output.high_frequency_rumble = 201;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
feedback = resumed_feedback->pop(10ms);
ASSERT_TRUE(feedback);
EXPECT_EQ(feedback->type, platf::gamepad_feedback_e::rumble);
EXPECT_EQ(feedback->id, 7);
EXPECT_FALSE(feedback_queue()->pop(0ms));
output.kind = lvh::GamepadOutputKind::raw_report;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
EXPECT_FALSE(feedback_queue()->pop(0ms));
platf::virtualhid::free_gamepad(*context(), 0);
const platf::gamepad_id_t id {0, 0};
const platf::gamepad_arrival_t metadata {LI_CTYPE_XBOX, 0, 0};
ASSERT_EQ(platf::virtualhid::alloc_gamepad(*context(), id, metadata, nullptr), 0);
adapter = platf::virtualhid::gamepad_adapter_for_testing(*context(), 0);
ASSERT_NE(adapter, nullptr);
output.kind = lvh::GamepadOutputKind::rumble;
ASSERT_TRUE(adapter->dispatch_output(output).ok());
}
TEST_F(VirtualHidDeviceTest, TranslatesGamepadTouchMotionAndBattery) {
const auto capabilities = static_cast<std::uint16_t>(LI_CCAP_ACCEL | LI_CCAP_GYRO | LI_CCAP_TOUCHPAD | LI_CCAP_BATTERY_STATE);
auto *adapter = allocate_gamepad("ds5"sv, LI_CTYPE_PS, capabilities);
ASSERT_NE(adapter, nullptr);
EXPECT_TRUE(feedback_queue()->pop(10ms));
EXPECT_TRUE(feedback_queue()->pop(10ms));
platf::gamepad_touch_t touch {{0, 3}, LI_TOUCH_EVENT_DOWN, 10, -0.25F, 1.25F, 1.0F};
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_EQ(adapter->state().touchpad_contacts[0].id, 0);
EXPECT_TRUE(adapter->state().touchpad_contacts[0].active);
EXPECT_FLOAT_EQ(adapter->state().touchpad_contacts[0].x, 0.0F);
EXPECT_FLOAT_EQ(adapter->state().touchpad_contacts[0].y, 1.0F);
touch.eventType = LI_TOUCH_EVENT_MOVE;
touch.pressure = 0.25F;
touch.x = 0.4F;
touch.y = 0.6F;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_FALSE(adapter->state().touchpad_contacts[0].active);
EXPECT_FLOAT_EQ(adapter->state().touchpad_contacts[0].x, 0.4F);
touch.eventType = LI_TOUCH_EVENT_DOWN;
touch.pointerId = 11;
platf::virtualhid::gamepad_touch(*context(), touch);
const auto full_touch_submit_count = adapter->gamepad()->submit_count();
touch.pointerId = 12;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_EQ(adapter->gamepad()->submit_count(), full_touch_submit_count);
touch.eventType = LI_TOUCH_EVENT_MOVE;
touch.pointerId = 99;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_EQ(adapter->gamepad()->submit_count(), full_touch_submit_count);
touch.eventType = 0xFF;
touch.pointerId = 10;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_EQ(adapter->gamepad()->submit_count(), full_touch_submit_count);
touch.eventType = LI_TOUCH_EVENT_CANCEL;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_FALSE(adapter->state().touchpad_contacts[0].active);
touch.eventType = LI_TOUCH_EVENT_UP;
touch.pointerId = 11;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_FALSE(adapter->state().touchpad_contacts[1].active);
touch.eventType = LI_TOUCH_EVENT_DOWN;
touch.pointerId = 20;
platf::virtualhid::gamepad_touch(*context(), touch);
touch.pointerId = 21;
platf::virtualhid::gamepad_touch(*context(), touch);
touch.eventType = LI_TOUCH_EVENT_CANCEL_ALL;
platf::virtualhid::gamepad_touch(*context(), touch);
EXPECT_FALSE(adapter->state().touchpad_contacts[0].active);
EXPECT_FALSE(adapter->state().touchpad_contacts[1].active);
const platf::gamepad_motion_t acceleration {{0, 3}, LI_MOTION_TYPE_ACCEL, 1.0F, 2.0F, 3.0F};
platf::virtualhid::gamepad_motion(*context(), acceleration);
ASSERT_TRUE(adapter->state().acceleration);
EXPECT_FLOAT_EQ(adapter->state().acceleration->x, 1.0F);
const platf::gamepad_motion_t gyroscope {{0, 3}, LI_MOTION_TYPE_GYRO, 4.0F, 5.0F, 6.0F};
platf::virtualhid::gamepad_motion(*context(), gyroscope);
ASSERT_TRUE(adapter->state().gyroscope);
EXPECT_FLOAT_EQ(adapter->state().gyroscope->z, 6.0F);
const auto motion_submit_count = adapter->gamepad()->submit_count();
platf::virtualhid::gamepad_motion(*context(), {{0, 3}, 0xFF, 0.0F, 0.0F, 0.0F});
platf::virtualhid::gamepad_motion(*context(), {{7, 3}, LI_MOTION_TYPE_ACCEL, 0.0F, 0.0F, 0.0F});
EXPECT_EQ(adapter->gamepad()->submit_count(), motion_submit_count);
platf::virtualhid::gamepad_battery(*context(), {{0, 3}, LI_BATTERY_STATE_DISCHARGING, 75});
ASSERT_TRUE(adapter->state().battery);
EXPECT_EQ(adapter->state().battery->state, lvh::GamepadBatteryState::discharging);
EXPECT_EQ(adapter->state().battery->percentage, 75);
platf::virtualhid::gamepad_battery(*context(), {{0, 3}, LI_BATTERY_STATE_CHARGING, LI_BATTERY_PERCENTAGE_UNKNOWN});
EXPECT_EQ(adapter->state().battery->state, lvh::GamepadBatteryState::charging);
EXPECT_EQ(adapter->state().battery->percentage, 100);
platf::virtualhid::gamepad_battery(*context(), {{0, 3}, LI_BATTERY_STATE_FULL, 120});
EXPECT_EQ(adapter->state().battery->state, lvh::GamepadBatteryState::full);
EXPECT_EQ(adapter->state().battery->percentage, 100);
platf::virtualhid::gamepad_battery(*context(), {{0, 3}, LI_BATTERY_STATE_NOT_CHARGING, 50});
EXPECT_EQ(adapter->state().battery->state, lvh::GamepadBatteryState::charging_error);
platf::virtualhid::gamepad_battery(*context(), {{0, 3}, LI_BATTERY_STATE_UNKNOWN, 0});
EXPECT_FALSE(adapter->state().battery);
platf::virtualhid::gamepad_battery(*context(), {{0, 3}, LI_BATTERY_STATE_NOT_PRESENT, 0});
EXPECT_FALSE(adapter->state().battery);
platf::virtualhid::gamepad_battery(*context(), {{7, 3}, LI_BATTERY_STATE_FULL, 100});
const auto unsupported_capabilities = static_cast<std::uint16_t>(LI_CCAP_ACCEL | LI_CCAP_TOUCHPAD | LI_CCAP_RGB_LED);
auto *unsupported = allocate_gamepad("xseries"sv, LI_CTYPE_XBOX, unsupported_capabilities, 1);
ASSERT_NE(unsupported, nullptr);
const auto unsupported_count = unsupported->gamepad()->submit_count();
platf::virtualhid::gamepad_touch(*context(), {{1, 3}, LI_TOUCH_EVENT_DOWN, 0, 0.5F, 0.5F, 1.0F});
EXPECT_EQ(unsupported->gamepad()->submit_count(), unsupported_count);
}
TEST_F(VirtualHidDeviceTest, TranslatesMouseAndKeyboardInput) {
platf::virtualhid::move_mouse(*context(), -4, 7);
auto mouse_event = context()->mouse->last_submitted_event();
EXPECT_EQ(mouse_event.kind, lvh::MouseEventKind::relative_motion);
EXPECT_EQ(mouse_event.x, -4);
EXPECT_EQ(mouse_event.y, 7);
const platf::touch_port_t viewport {10, 20, 1920, 1080, 1920, 1080};
platf::virtualhid::abs_mouse(*context(), viewport, 10.6F, 20.4F);
mouse_event = context()->mouse->last_submitted_event();
EXPECT_EQ(mouse_event.kind, lvh::MouseEventKind::absolute_motion);
EXPECT_EQ(mouse_event.x, 11);
EXPECT_EQ(mouse_event.y, 20);
EXPECT_EQ(mouse_event.width, 1920);
EXPECT_EQ(mouse_event.height, 1080);
constexpr std::array button_cases {
std::pair {BUTTON_LEFT, lvh::MouseButton::left},
std::pair {BUTTON_MIDDLE, lvh::MouseButton::middle},
std::pair {BUTTON_RIGHT, lvh::MouseButton::right},
std::pair {BUTTON_X1, lvh::MouseButton::side},
std::pair {BUTTON_X2, lvh::MouseButton::extra},
};
for (const auto &[button, expected] : button_cases) {
platf::virtualhid::button_mouse(*context(), button, false);
mouse_event = context()->mouse->last_submitted_event();
EXPECT_EQ(mouse_event.kind, lvh::MouseEventKind::button);
EXPECT_EQ(mouse_event.button, expected);
EXPECT_TRUE(mouse_event.pressed);
platf::virtualhid::button_mouse(*context(), button, true);
EXPECT_FALSE(context()->mouse->last_submitted_event().pressed);
}
const auto button_submit_count = context()->mouse->submit_count();
platf::virtualhid::button_mouse(*context(), 999, false);
EXPECT_EQ(context()->mouse->submit_count(), button_submit_count);
platf::virtualhid::scroll(*context(), 120);
mouse_event = context()->mouse->last_submitted_event();
EXPECT_EQ(mouse_event.kind, lvh::MouseEventKind::vertical_scroll);
EXPECT_EQ(mouse_event.high_resolution_scroll, 120);
platf::virtualhid::hscroll(*context(), -240);
mouse_event = context()->mouse->last_submitted_event();
EXPECT_EQ(mouse_event.kind, lvh::MouseEventKind::horizontal_scroll);
EXPECT_EQ(mouse_event.high_resolution_scroll, -240);
config::input.always_send_scancodes = true;
platf::virtualhid::keyboard_update(*context(), 0x41, false, 0);
auto keyboard_event = context()->keyboard->last_submitted_event();
EXPECT_EQ(keyboard_event.key_code, 0x41);
EXPECT_TRUE(keyboard_event.pressed);
#ifdef _WIN32
EXPECT_TRUE(keyboard_event.uses_normalized_key_code);
EXPECT_TRUE(keyboard_event.prefer_native_scan_code);
platf::virtualhid::keyboard_update(*context(), 0x41, true, SS_KBE_FLAG_NON_NORMALIZED);
keyboard_event = context()->keyboard->last_submitted_event();
EXPECT_FALSE(keyboard_event.uses_normalized_key_code);
#else
EXPECT_FALSE(keyboard_event.uses_normalized_key_code);
EXPECT_FALSE(keyboard_event.prefer_native_scan_code);
platf::virtualhid::keyboard_update(*context(), 0x41, true, 0xFF);
keyboard_event = context()->keyboard->last_submitted_event();
#endif
EXPECT_FALSE(keyboard_event.pressed);
const auto keyboard_submit_count = context()->keyboard->submit_count();
const std::string text = "Sunshine \u{2600}";
platf::virtualhid::unicode(*context(), text.data(), static_cast<int>(text.size()));
EXPECT_EQ(context()->keyboard->submit_count(), keyboard_submit_count + 1);
platf::virtualhid::unicode(*context(), nullptr, 1);
platf::virtualhid::unicode(*context(), text.data(), 0);
EXPECT_EQ(context()->keyboard->submit_count(), keyboard_submit_count + 1);
ASSERT_TRUE(context()->mouse->close().ok());
ASSERT_TRUE(context()->keyboard->close().ok());
platf::virtualhid::move_mouse(*context(), 1, 1);
platf::virtualhid::keyboard_update(*context(), 0x41, false, 0);
context()->mouse.reset();
context()->keyboard.reset();
platf::virtualhid::move_mouse(*context(), 1, 1);
platf::virtualhid::abs_mouse(*context(), viewport, 1.0F, 1.0F);
platf::virtualhid::button_mouse(*context(), BUTTON_LEFT, false);
platf::virtualhid::scroll(*context(), 1);
platf::virtualhid::hscroll(*context(), 1);
platf::virtualhid::keyboard_update(*context(), 0x41, false, 0);
platf::virtualhid::unicode(*context(), text.data(), static_cast<int>(text.size()));
}
TEST_F(VirtualHidDeviceTest, TranslatesTouchscreenLifecycleAndGeometry) {
const platf::touch_port_t viewport {10, 20, 800, 600, 400, 300};
platf::touch_input_t touch {LI_TOUCH_EVENT_DOWN, 100, 7, -0.1F, 1.1F, 1.2F, 15.0F, 10.0F};
platf::virtualhid::touch_update(*client(), viewport, touch);
auto contact = client()->touch->last_submitted_contact();
EXPECT_EQ(contact.id, 7);
EXPECT_FLOAT_EQ(contact.x, 0.0F);
EXPECT_FLOAT_EQ(contact.y, 1.0F);
EXPECT_FLOAT_EQ(contact.pressure, 1.0F);
EXPECT_EQ(contact.orientation, 80);
EXPECT_TRUE(contact.touching);
EXPECT_EQ(contact.viewport.offset_x, 10);
EXPECT_EQ(contact.viewport.offset_y, 20);
EXPECT_EQ(contact.viewport.width, 800);
EXPECT_EQ(contact.viewport.height, 600);
EXPECT_FLOAT_EQ(contact.contact_major_axis, 15.0F);
EXPECT_FLOAT_EQ(contact.contact_minor_axis, 10.0F);
EXPECT_TRUE(client()->active_touches.contains(7));
touch.eventType = LI_TOUCH_EVENT_HOVER;
touch.rotation = 300;
touch.pointerId = 8;
touch.pressureOrDistance = 0.25F;
platf::virtualhid::touch_update(*client(), viewport, touch);
contact = client()->touch->last_submitted_contact();
EXPECT_EQ(contact.orientation, -60);
EXPECT_FALSE(contact.touching);
EXPECT_TRUE(client()->active_touches.contains(8));
touch.eventType = LI_TOUCH_EVENT_UP;
platf::virtualhid::touch_update(*client(), viewport, touch);
EXPECT_FALSE(client()->active_touches.contains(8));
touch.eventType = LI_TOUCH_EVENT_CANCEL;
touch.pointerId = 7;
platf::virtualhid::touch_update(*client(), viewport, touch);
EXPECT_FALSE(client()->active_touches.contains(7));
touch.eventType = LI_TOUCH_EVENT_MOVE;
touch.rotation = LI_ROT_UNKNOWN;
touch.pointerId = 9;
platf::virtualhid::touch_update(*client(), viewport, touch);
EXPECT_EQ(client()->touch->last_submitted_contact().orientation, 0);
touch.eventType = LI_TOUCH_EVENT_HOVER_LEAVE;
platf::virtualhid::touch_update(*client(), viewport, touch);
EXPECT_FALSE(client()->active_touches.contains(9));
touch.eventType = LI_TOUCH_EVENT_DOWN;
touch.pointerId = 10;
platf::virtualhid::touch_update(*client(), viewport, touch);
touch.pointerId = 11;
platf::virtualhid::touch_update(*client(), viewport, touch);
const auto before_cancel = client()->touch->submit_count();
touch.eventType = LI_TOUCH_EVENT_CANCEL_ALL;
platf::virtualhid::touch_update(*client(), viewport, touch);
EXPECT_TRUE(client()->active_touches.empty());
EXPECT_EQ(client()->touch->submit_count(), before_cancel + 2);
const auto before_unknown = client()->touch->submit_count();
touch.eventType = 0xFF;
platf::virtualhid::touch_update(*client(), viewport, touch);
EXPECT_EQ(client()->touch->submit_count(), before_unknown);
ASSERT_TRUE(client()->touch->close().ok());
touch.eventType = LI_TOUCH_EVENT_DOWN;
platf::virtualhid::touch_update(*client(), viewport, touch);
client()->touch.reset();
platf::virtualhid::touch_update(*client(), viewport, touch);
}
TEST_F(VirtualHidDeviceTest, TranslatesPenButtonsToolsAndTransitions) {
const platf::touch_port_t viewport {1, 2, 1000, 500, 1000, 500};
platf::pen_input_t pen {
LI_TOUCH_EVENT_DOWN,
LI_TOOL_TYPE_PEN,
static_cast<std::uint8_t>(LI_PEN_BUTTON_PRIMARY | LI_PEN_BUTTON_SECONDARY),
45,
450,
-0.25F,
1.25F,
1.5F,
0.0F,
0.0F,
};
platf::virtualhid::pen_update(*client(), viewport, pen);
auto state = client()->pen->last_submitted_tool();
EXPECT_EQ(state.tool, lvh::PenToolType::pen);
EXPECT_FLOAT_EQ(state.x, 0.0F);
EXPECT_FLOAT_EQ(state.y, 1.0F);
EXPECT_FLOAT_EQ(state.pressure, 1.0F);
EXPECT_FLOAT_EQ(state.distance, -1.0F);
EXPECT_NEAR(state.tilt_x, -45.0F, 0.001F);
EXPECT_NEAR(state.tilt_y, 0.0F, 0.001F);
EXPECT_EQ(state.transition, lvh::PointerTransition::update);
EXPECT_EQ(state.viewport.offset_x, 1);
EXPECT_EQ(state.viewport.offset_y, 2);
EXPECT_TRUE(client()->pressed_pen_buttons.contains(lvh::PenButton::primary));
EXPECT_TRUE(client()->pressed_pen_buttons.contains(lvh::PenButton::secondary));
const auto before_unchanged_buttons = client()->pen->submit_count();
platf::virtualhid::pen_update(*client(), viewport, pen);
EXPECT_EQ(client()->pen->submit_count(), before_unchanged_buttons + 1);
pen.eventType = LI_TOUCH_EVENT_HOVER;
pen.toolType = LI_TOOL_TYPE_ERASER;
pen.penButtons = LI_PEN_BUTTON_TERTIARY;
pen.tilt = LI_TILT_UNKNOWN;
pen.rotation = LI_ROT_UNKNOWN;
pen.pressureOrDistance = 0.4F;
platf::virtualhid::pen_update(*client(), viewport, pen);
state = client()->pen->last_submitted_tool();
EXPECT_EQ(state.tool, lvh::PenToolType::eraser);
EXPECT_FLOAT_EQ(state.pressure, -1.0F);
EXPECT_FLOAT_EQ(state.distance, 0.4F);
EXPECT_FLOAT_EQ(state.tilt_x, 0.0F);
EXPECT_FLOAT_EQ(state.tilt_y, 0.0F);
EXPECT_TRUE(client()->pressed_pen_buttons.contains(lvh::PenButton::tertiary));
EXPECT_FALSE(client()->pressed_pen_buttons.contains(lvh::PenButton::primary));
pen.eventType = LI_TOUCH_EVENT_UP;
pen.toolType = LI_TOOL_TYPE_UNKNOWN;
pen.penButtons = 0;
platf::virtualhid::pen_update(*client(), viewport, pen);
EXPECT_EQ(client()->pen->last_submitted_tool().tool, lvh::PenToolType::unchanged);
EXPECT_EQ(client()->pen->last_submitted_tool().transition, lvh::PointerTransition::release);
pen.eventType = LI_TOUCH_EVENT_HOVER_LEAVE;
platf::virtualhid::pen_update(*client(), viewport, pen);
EXPECT_EQ(client()->pen->last_submitted_tool().transition, lvh::PointerTransition::leave);
pen.eventType = LI_TOUCH_EVENT_CANCEL;
platf::virtualhid::pen_update(*client(), viewport, pen);
EXPECT_EQ(client()->pen->last_submitted_tool().transition, lvh::PointerTransition::cancel);
pen.eventType = LI_TOUCH_EVENT_DOWN;
pen.penButtons = LI_PEN_BUTTON_PRIMARY;
platf::virtualhid::pen_update(*client(), viewport, pen);
ASSERT_FALSE(client()->pressed_pen_buttons.empty());
pen.eventType = LI_TOUCH_EVENT_CANCEL_ALL;
platf::virtualhid::pen_update(*client(), viewport, pen);
EXPECT_TRUE(client()->pressed_pen_buttons.empty());
EXPECT_EQ(client()->pen->last_submitted_tool().transition, lvh::PointerTransition::cancel);
ASSERT_TRUE(client()->pen->close().ok());
pen.eventType = LI_TOUCH_EVENT_DOWN;
platf::virtualhid::pen_update(*client(), viewport, pen);
client()->pen.reset();
platf::virtualhid::pen_update(*client(), viewport, pen);
}
TEST_F(VirtualHidDeviceTest, PlatformWrappersForwardToVirtualHidContext) {
auto platform_input = platf::input();
ASSERT_TRUE(platform_input);
auto &platform_context = platf::virtualhid::get_input_context(platform_input);
platform_context = platf::virtualhid::input_context_t {lvh::BackendKind::fake};
ASSERT_NE(platform_context.mouse, nullptr);
ASSERT_NE(platform_context.keyboard, nullptr);
const platf::touch_port_t viewport {0, 0, 1280, 720, 1280, 720};
platf::move_mouse(platform_input, 2, 3);
EXPECT_EQ(platform_context.mouse->last_submitted_event().kind, lvh::MouseEventKind::relative_motion);
platf::abs_mouse(platform_input, viewport, 40.0F, 50.0F);
EXPECT_EQ(platform_context.mouse->last_submitted_event().kind, lvh::MouseEventKind::absolute_motion);
platf::button_mouse(platform_input, BUTTON_LEFT, false);
EXPECT_EQ(platform_context.mouse->last_submitted_event().kind, lvh::MouseEventKind::button);
platf::scroll(platform_input, 120);
EXPECT_EQ(platform_context.mouse->last_submitted_event().kind, lvh::MouseEventKind::vertical_scroll);
platf::hscroll(platform_input, -120);
EXPECT_EQ(platform_context.mouse->last_submitted_event().kind, lvh::MouseEventKind::horizontal_scroll);
platf::keyboard_update(platform_input, 0x41, false, 0);
EXPECT_EQ(platform_context.keyboard->last_submitted_event().key_code, 0x41);
const std::string text = "wrapper";
const auto keyboard_count = platform_context.keyboard->submit_count();
platf::unicode(platform_input, text.data(), static_cast<int>(text.size()));
EXPECT_EQ(platform_context.keyboard->submit_count(), keyboard_count + 1);
config::input.gamepad = "ds5";
const auto capabilities = static_cast<std::uint16_t>(LI_CCAP_ACCEL | LI_CCAP_GYRO | LI_CCAP_TOUCHPAD | LI_CCAP_BATTERY_STATE);
const platf::gamepad_id_t gamepad_id {0, 2};
const platf::gamepad_arrival_t gamepad_metadata {LI_CTYPE_PS, capabilities, 0};
ASSERT_EQ(platf::alloc_gamepad(platform_input, gamepad_id, gamepad_metadata, feedback_queue()), 0);
auto *adapter = platf::virtualhid::gamepad_adapter_for_testing(platform_context, 0);
ASSERT_NE(adapter, nullptr);
platf::gamepad_update(platform_input, 0, {platf::A, 255, 0, 0, 0, 0, 0});
EXPECT_TRUE(adapter->state().buttons.test(lvh::GamepadButton::a));
platf::gamepad_touch(platform_input, {{0, 2}, LI_TOUCH_EVENT_DOWN, 1, 0.25F, 0.5F, 1.0F});
EXPECT_TRUE(adapter->state().touchpad_contacts[0].active);
platf::gamepad_motion(platform_input, {{0, 2}, LI_MOTION_TYPE_ACCEL, 1.0F, 2.0F, 3.0F});
EXPECT_TRUE(adapter->state().acceleration.has_value());
platf::gamepad_battery(platform_input, {{0, 2}, LI_BATTERY_STATE_FULL, 100});
EXPECT_TRUE(adapter->state().battery.has_value());
const auto &supported = platf::supported_gamepads(std::addressof(platform_input));
ASSERT_FALSE(supported.empty());
EXPECT_TRUE(supported.front().is_enabled);
EXPECT_FALSE(platf::supported_gamepads(nullptr).empty());
#ifdef __APPLE__
EXPECT_EQ(platf::get_capabilities() & platf::platform_caps::controller_touch, 0U);
#else
EXPECT_NE(platf::get_capabilities() & platf::platform_caps::controller_touch, 0U);
#endif
platf::free_gamepad(platform_input, 0);
EXPECT_FALSE(platf::virtualhid::has_gamepad(platform_context, 0));
platf::free_gamepad(platform_input, 0);
platf::gamepad_update(platform_input, 0, {});
platf::gamepad_touch(platform_input, {{0, 2}, LI_TOUCH_EVENT_DOWN, 1, 0.25F, 0.5F, 1.0F});
platf::gamepad_motion(platform_input, {{0, 2}, LI_MOTION_TYPE_ACCEL, 1.0F, 2.0F, 3.0F});
platf::gamepad_battery(platform_input, {{0, 2}, LI_BATTERY_STATE_FULL, 100});
auto platform_client = platf::allocate_client_input_context(platform_input);
ASSERT_NE(platform_client, nullptr);
auto &platform_client_context = platf::virtualhid::get_client_context(platform_client.get());
ASSERT_NE(platform_client_context.touch, nullptr);
ASSERT_NE(platform_client_context.pen, nullptr);
platf::touch_update(platform_client.get(), viewport, {LI_TOUCH_EVENT_DOWN, 0, 1, 0.25F, 0.5F, 1.0F, 2.0F, 1.0F});
EXPECT_EQ(platform_client_context.touch->last_submitted_contact().id, 1);
platf::pen_update(platform_client.get(), viewport, {LI_TOUCH_EVENT_HOVER, LI_TOOL_TYPE_PEN, 0, LI_TILT_UNKNOWN, LI_ROT_UNKNOWN, 0.25F, 0.5F, 0.5F, 0.0F, 0.0F});
EXPECT_EQ(platform_client_context.pen->last_submitted_tool().tool, lvh::PenToolType::pen);
platform_context.runtime.reset();
config::input.gamepad = "generic";
EXPECT_EQ(platf::alloc_gamepad(platform_input, gamepad_id, gamepad_metadata, nullptr), -1);
}
▸
tests/unit/test_confighttp.cpp
+301
−16
@@ -11,12 +11,14 @@
#include "../tests_common.h"
// standard includes
#include <array>
#include <chrono>
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
#include <thread>
#include <utility>
// lib imports
#include <Simple-Web-Server/client_https.hpp>
@@ -108,6 +110,15 @@
void SetUp() override {
BaseTest::SetUp();
confighttp::set_virtual_input_license_status_provider_for_testing([]() {
lvh::LicenseStatus license;
license.service_available = true;
license.state = lvh::LicenseState::licensed;
license.active_devices = 2;
license.message = "Test license status";
return lvh::LicenseResult {lvh::OperationStatus::success(), std::move(license)};
});
// Save current config
saved_username = config::sunshine.username;
saved_password = config::sunshine.password;
@@ -308,6 +319,10 @@
) {
confighttp::browseDirectory(response, request);
};
server->resource["^/virtual-input-status-test$"]["GET"] = confighttp::getVirtualInputStatus;
server->resource["^/virtual-input-license-test$"]["GET"] = confighttp::getVirtualInputLicense;
server->resource["^/virtual-input-license-test$"]["POST"] = confighttp::updateVirtualInputLicense;
// Start server
server_thread = std::jthread([this]() {
@@ -340,6 +355,7 @@
if (server_thread.joinable()) {
server_thread.join();
}
confighttp::reset_virtual_input_license_status_provider_for_testing();
config::sunshine.username = saved_username;
config::sunshine.password = saved_password;
@@ -385,6 +401,126 @@
}
};
namespace {
/**
* @brief HTTP request used to verify a shared endpoint contract.
*/
struct endpoint_request_t {
const char *name; ///< Stable parameter name.
const char *method; ///< HTTP method.
const char *path; ///< Test-server route.
const char *body; ///< Request body.
};
/**
* @brief Invalid virtual-input license request and expected error.
*/
struct invalid_license_request_t {
const char *name; ///< Stable parameter name.
const char *body; ///< Invalid request body.
const char *expected_error; ///< Expected response error.
};
/**
* @brief Return the stable name for an endpoint request parameter.
*
* @param param_info Parameter information supplied by GoogleTest.
* @return Stable test suffix.
*/
std::string endpoint_request_name(const testing::TestParamInfo<endpoint_request_t> ¶m_info) {
return param_info.param.name;
}
/**
* @brief Return the stable name for an invalid license request parameter.
*
* @param param_info Parameter information supplied by GoogleTest.
* @return Stable test suffix.
*/
std::string invalid_license_request_name(const testing::TestParamInfo<invalid_license_request_t> ¶m_info) {
return param_info.param.name;
}
} // namespace
/**
* @brief Parameterized fixture for endpoints that require authentication.
*/
class AuthenticatedConfigHttpEndpointTest: public ConfigHttpTest, public testing::WithParamInterface<endpoint_request_t> {};
/**
* @brief Parameterized fixture for endpoints protected from cross-origin requests.
*/
class CsrfProtectedConfigHttpEndpointTest: public ConfigHttpTest, public testing::WithParamInterface<endpoint_request_t> {};
/**
* @brief Parameterized fixture for invalid virtual-input license requests.
*/
class InvalidVirtualInputLicenseRequestTest: public ConfigHttpTest, public testing::WithParamInterface<invalid_license_request_t> {};
TEST_P(AuthenticatedConfigHttpEndpointTest, RejectsUnauthenticatedRequest) {
const auto &request = GetParam();
const auto response = client->request(request.method, request.path, request.body);
ASSERT_EQ(response->status_code, "401 Unauthorized");
EXPECT_NE(response->header.find("WWW-Authenticate"), response->header.end());
}
INSTANTIATE_TEST_SUITE_P(
ConfigHttpEndpoints,
AuthenticatedConfigHttpEndpointTest,
testing::Values(
endpoint_request_t {"Page", "GET", "/page-test", ""},
endpoint_request_t {"CsrfToken", "GET", "/csrf-token-test", ""},
endpoint_request_t {"BrowseDirectory", "GET", "/browse-test", ""},
endpoint_request_t {"VirtualInputStatus", "GET", "/virtual-input-status-test", ""},
endpoint_request_t {"VirtualInputLicense", "GET", "/virtual-input-license-test", ""},
endpoint_request_t {"VirtualInputLicenseUpdate", "POST", "/virtual-input-license-test", R"({"action":"validate"})"}
),
endpoint_request_name
);
TEST_P(CsrfProtectedConfigHttpEndpointTest, RejectsCrossOriginRequestWithoutToken) {
const auto &request = GetParam();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
headers.emplace("Origin", "https://example.invalid");
const auto response = client->request(request.method, request.path, request.body, headers);
ASSERT_EQ(response->status_code, "400 Bad Request");
EXPECT_TRUE(response->content.string().contains("Missing CSRF token"));
}
INSTANTIATE_TEST_SUITE_P(
ConfigHttpEndpoints,
CsrfProtectedConfigHttpEndpointTest,
testing::Values(
endpoint_request_t {"CsrfValidation", "POST", "/csrf-validate-test", ""},
endpoint_request_t {"VirtualInputLicenseUpdate", "POST", "/virtual-input-license-test", R"({"action":"validate"})"}
),
endpoint_request_name
);
TEST_P(InvalidVirtualInputLicenseRequestTest, ReturnsExpectedError) {
const auto &request = GetParam();
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
headers.emplace("Origin", std::format("https://localhost:{}", port));
const auto response = client->request("POST", "/virtual-input-license-test", request.body, headers);
ASSERT_EQ(response->status_code, "400 Bad Request");
EXPECT_TRUE(response->content.string().contains(request.expected_error));
}
INSTANTIATE_TEST_SUITE_P(
InvalidRequests,
InvalidVirtualInputLicenseRequestTest,
testing::Values(
invalid_license_request_t {"InvalidJson", "not-json", "Invalid license request"},
invalid_license_request_t {"UnknownAction", R"({"action":"unknown"})", "Unknown license action"},
invalid_license_request_t {"MissingActivationKey", R"({"action":"activate","license_key":""})", "License key is required"}
),
invalid_license_request_name
);
// Test: confighttp::authenticate() rejects requests without auth header
TEST_F(ConfigHttpTest, AuthenticateRejectsNoAuth) {
const auto response = client->request("GET", "/auth-test");
@@ -675,16 +811,6 @@
ASSERT_TRUE(body.find("</html>") != std::string::npos);
}
// Test: confighttp::getPage() requires authentication when require_auth=true
TEST_F(ConfigHttpTest, GetPageRequiresAuth) {
const auto response = client->request("GET", "/page-test");
ASSERT_EQ(response->status_code, "401 Unauthorized");
// Should have WWW-Authenticate header since auth is required
const auto www_auth = response->header.find("WWW-Authenticate");
ASSERT_NE(www_auth, response->header.end());
}
// Test: confighttp::getPage() works without authentication when require_auth=false
TEST_F(ConfigHttpTest, GetPageWithoutAuthRequired) {
const auto response = client->request("GET", "/page-noauth-test");
@@ -837,12 +963,6 @@
}
};
// Test: browseDirectory requires authentication
TEST_F(BrowseDirectoryTest, BrowseRequiresAuthentication) {
const auto response = client->request("GET", browse_url(browse_test_dir.string()));
ASSERT_EQ(response->status_code, "401 Unauthorized");
}
// Test: browseDirectory returns 200 with valid JSON for a real directory
TEST_F(BrowseDirectoryTest, BrowseListsValidDirectory) {
SimpleWeb::CaseInsensitiveMultimap headers;
@@ -1187,6 +1307,171 @@
ASSERT_EQ(parent, "/");
}
#endif
// ============================================================
// Direct unit tests for virtual input driver status helpers
// ============================================================
// Test: empty minimum driver versions accept any detected version
TEST(ConfigHttpDriverStatusTest, IsDriverVersionSupported_EmptyMinimum_ReturnsTrue) {
ASSERT_TRUE(confighttp::is_driver_version_supported("", ""));
ASSERT_TRUE(confighttp::is_driver_version_supported("1.0.0.0", ""));
}
// Test: equal and newer numeric driver versions are supported
TEST(ConfigHttpDriverStatusTest, IsDriverVersionSupported_EqualOrNewerVersion_ReturnsTrue) {
ASSERT_TRUE(confighttp::is_driver_version_supported("1.17.0.0", "1.17.0.0"));
ASSERT_TRUE(confighttp::is_driver_version_supported("1.18.0.0", "1.17.0.0"));
ASSERT_TRUE(confighttp::is_driver_version_supported("2.0", "1.17.0.0"));
}
// Test: older numeric driver versions are unsupported
TEST(ConfigHttpDriverStatusTest, IsDriverVersionSupported_OlderVersion_ReturnsFalse) {
ASSERT_FALSE(confighttp::is_driver_version_supported("1.16.9.9", "1.17.0.0"));
ASSERT_FALSE(confighttp::is_driver_version_supported("1.16", "1.17.0.0"));
}
// Test: invalid driver versions are unsupported when a minimum is required
TEST(ConfigHttpDriverStatusTest, IsDriverVersionSupported_InvalidVersion_ReturnsFalse) {
ASSERT_FALSE(confighttp::is_driver_version_supported("", "1.17.0.0"));
ASSERT_FALSE(confighttp::is_driver_version_supported("1.17.beta", "1.17.0.0"));
ASSERT_FALSE(confighttp::is_driver_version_supported("1.17.", "1.17.0.0"));
}
// Test: driver status JSON includes compatibility and supported version metadata
TEST(ConfigHttpDriverStatusTest, BuildDriverStatus_IncludesExpectedFields) {
const auto status = confighttp::build_driver_status(true, "1.17.0.0", "1.17.0.0"); // NOSONAR(cpp:S1313): not an IP address
ASSERT_TRUE(status["installed"].get<bool>());
ASSERT_EQ(status["version"].get<std::string>(), "1.17.0.0");
ASSERT_EQ(status["minimum_version"].get<std::string>(), "1.17.0.0");
ASSERT_EQ(status["supported_versions"].get<std::string>(), ">= 1.17.0.0");
ASSERT_TRUE(status["version_compatible"].get<bool>());
}
// Test: missing drivers are not compatible even when any version would be accepted
TEST(ConfigHttpDriverStatusTest, BuildDriverStatus_NotInstalledIsNotCompatible) {
const auto status = confighttp::build_driver_status(false, "", "");
ASSERT_FALSE(status["installed"].get<bool>());
ASSERT_EQ(status["supported_versions"].get<std::string>(), "Any");
ASSERT_FALSE(status["version_compatible"].get<bool>());
}
TEST(ConfigHttpDriverStatusTest, BuildsLiveVirtualInputDriverStatus) {
const auto virtualhid = confighttp::get_virtualhid_driver_status();
EXPECT_TRUE(virtualhid.contains("installed"));
EXPECT_TRUE(virtualhid.contains("version"));
EXPECT_TRUE(virtualhid.contains("version_compatible"));
EXPECT_TRUE(virtualhid.contains("backend_name"));
EXPECT_TRUE(virtualhid.contains("requires_installed_driver"));
const auto vigembus = confighttp::get_vigembus_driver_status();
EXPECT_TRUE(vigembus.contains("installed"));
EXPECT_TRUE(vigembus.contains("version"));
EXPECT_TRUE(vigembus.contains("minimum_version"));
EXPECT_TRUE(vigembus.contains("version_compatible"));
}
TEST(ConfigHttpLicenseStatusTest, ClearsSensitiveRequestString) {
std::string sensitive_value = "license-key";
confighttp::clear_sensitive_string_for_testing(sensitive_value);
EXPECT_TRUE(sensitive_value.empty());
}
TEST_F(ConfigHttpTest, VirtualInputStatusReturnsBothBackends) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", "/virtual-input-status-test", "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const auto body = nlohmann::json::parse(response->content.string());
ASSERT_TRUE(body.contains("virtualhid"));
ASSERT_TRUE(body.contains("vigembus"));
EXPECT_TRUE(body.at("virtualhid").contains("installed"));
EXPECT_TRUE(body.at("vigembus").contains("installed"));
}
TEST_F(ConfigHttpTest, VirtualInputLicenseReturnsCurrentStatus) {
SimpleWeb::CaseInsensitiveMultimap headers;
headers.emplace("Authorization", create_auth_header("testuser", "testpass"));
const auto response = client->request("GET", "/virtual-input-license-test", "", headers);
ASSERT_EQ(response->status_code, "200 OK");
const auto body = nlohmann::json::parse(response->content.string());
EXPECT_TRUE(body.at("operation_ok").get<bool>());
EXPECT_TRUE(body.at("service_available").get<bool>());
EXPECT_EQ(body.at("state").get<std::string>(), "licensed");
EXPECT_TRUE(body.at("licensed").get<bool>());
EXPECT_EQ(body.at("active_devices").get<unsigned int>(), 2U);
EXPECT_EQ(body.at("message").get<std::string>(), "Test license status");
}
// Test: every public license state maps to a stable Web UI state string
TEST(ConfigHttpLicenseStatusTest, BuildVirtualHidLicenseStatus_MapsEveryState) {
using enum lvh::LicenseState;
const std::array states {
std::pair {unavailable, "unavailable"s},
std::pair {unlicensed, "unlicensed"s},
std::pair {licensed, "licensed"s},
std::pair {expired, "expired"s},
std::pair {disabled, "disabled"s},
std::pair {invalid, "invalid"s},
};
for (const auto &[state, expected] : states) {
lvh::LicenseStatus license;
license.state = state;
const auto output = confighttp::build_virtualhid_license_status({lvh::OperationStatus::success(), license});
EXPECT_EQ(output["state"].get<std::string>(), expected);
EXPECT_EQ(output["licensed"].get<bool>(), state == licensed);
}
}
// Test: license status JSON includes all customer-visible metadata without a key field
TEST(ConfigHttpLicenseStatusTest, BuildVirtualHidLicenseStatus_IncludesExpectedFields) {
lvh::LicenseStatus license;
license.service_available = true;
license.state = lvh::LicenseState::licensed;
license.active_devices = 2;
license.activation_limit = 5;
license.activation_usage = 3;
license.plan_name = "Yearly";
license.customer_email = "customer@example.com";
license.expires_at = "2027-08-10T00:00:00Z";
license.message = "License is active";
license.purchase_url = "https://example.com/buy";
license.manage_account_url = "https://example.com/manage";
const auto output = confighttp::build_virtualhid_license_status({lvh::OperationStatus::success(), license});
EXPECT_TRUE(output["operation_ok"].get<bool>());
EXPECT_TRUE(output["service_available"].get<bool>());
EXPECT_EQ(output["active_devices"].get<unsigned int>(), 2U);
EXPECT_EQ(output["activation_limit"].get<unsigned int>(), 5U);
EXPECT_EQ(output["activation_usage"].get<unsigned int>(), 3U);
EXPECT_EQ(output["plan_name"].get<std::string>(), "Yearly");
EXPECT_EQ(output["customer_email"].get<std::string>(), "customer@example.com");
EXPECT_EQ(output["expires_at"].get<std::string>(), "2027-08-10T00:00:00Z");
EXPECT_EQ(output["message"].get<std::string>(), "License is active");
EXPECT_EQ(output["purchase_url"].get<std::string>(), "https://example.com/buy");
EXPECT_EQ(output["manage_account_url"].get<std::string>(), "https://example.com/manage");
EXPECT_EQ(output["error"].get<std::string>(), "");
EXPECT_FALSE(output.contains("license_key"));
}
// Test: failed license operations preserve the latest status and expose only the safe error message
TEST(ConfigHttpLicenseStatusTest, BuildVirtualHidLicenseStatus_IncludesOperationFailure) {
lvh::LicenseStatus license;
license.service_available = true;
license.state = lvh::LicenseState::invalid;
const auto operation = lvh::OperationStatus::failure(lvh::ErrorCode::license_invalid, "License is invalid");
const auto output = confighttp::build_virtualhid_license_status({operation, license});
EXPECT_FALSE(output["operation_ok"].get<bool>());
EXPECT_EQ(output["state"].get<std::string>(), "invalid");
EXPECT_EQ(output["error"].get<std::string>(), "License is invalid");
}
// ============================================================
// Direct unit tests for browseDirectory helper functions
▸
tests/unit/test_input.cpp
+94
−0
@@ -1,0 +1,94 @@
/**
* @file tests/unit/test_input.cpp
* @brief Tests for retained stream input and virtual gamepad lifecycle behavior.
*/
// standard includes
#include <memory>
#include <string>
// local includes
#include "../tests_common.h"
#include "src/config.h"
#include "src/input.h"
#include "src/platform/virtualhid_input.h"
namespace {
/**
* @brief Fixture that installs a fake global virtual input backend.
*/
class InputGamepadSessionTest: public ::testing::Test {
protected:
/**
* @brief Preserve configuration and install observable fake devices.
*/
void SetUp() override {
original_input_ = config::input;
config::input.controller = true;
config::input.gamepad = "xseries";
auto platform_input = platf::input();
ASSERT_TRUE(platform_input);
auto &context = platf::virtualhid::get_input_context(platform_input);
context = platf::virtualhid::input_context_t {lvh::BackendKind::fake};
runtime_ = context.runtime.get();
ASSERT_NE(runtime_, nullptr);
input::testing::set_platform_input(std::move(platform_input));
}
/**
* @brief Destroy retained test sessions and restore configuration.
*/
void TearDown() override {
input::terminate_gamepads();
input::testing::set_platform_input({});
runtime_ = nullptr;
config::input = std::move(original_input_);
}
/**
* @brief Access the fake runtime installed for the current test.
*
* @return Fake libvirtualhid runtime.
*/
lvh::Runtime &runtime() const {
return *runtime_;
}
private:
lvh::Runtime *runtime_ = nullptr; ///< Fake runtime installed in the global input backend.
config::input_t original_input_; ///< Input configuration restored after each test.
};
} // namespace
TEST_F(InputGamepadSessionTest, ReusesGamepadsAcrossPauseAndDestroysThemOnTermination) {
const std::string session_id = "paired-client-certificate";
auto first_mail = std::make_shared<safe::mail_raw_t>();
auto first = input::alloc(first_mail, session_id);
ASSERT_NE(first, nullptr);
const auto active_devices_before_gamepad = runtime().active_device_count();
const platf::gamepad_arrival_t metadata {LI_CTYPE_XBOX, 0, 0};
const auto original_id = input::testing::alloc_gamepad(first, 0, metadata);
ASSERT_GE(original_id, 0);
EXPECT_EQ(runtime().active_device_count(), active_devices_before_gamepad + 1);
const std::weak_ptr<input::input_t> paused = first;
first.reset();
EXPECT_FALSE(paused.expired());
EXPECT_EQ(runtime().active_device_count(), active_devices_before_gamepad + 1);
auto resumed_mail = std::make_shared<safe::mail_raw_t>();
auto resumed = input::alloc(resumed_mail, session_id);
ASSERT_NE(resumed, nullptr);
EXPECT_EQ(paused.lock(), resumed);
EXPECT_EQ(input::testing::gamepad_id(resumed, 0), original_id);
EXPECT_EQ(runtime().active_device_count(), active_devices_before_gamepad + 1);
input::terminate_gamepads(session_id);
EXPECT_EQ(input::testing::gamepad_id(resumed, 0), -1);
EXPECT_EQ(runtime().active_device_count(), active_devices_before_gamepad);
auto replacement = input::alloc(std::make_shared<safe::mail_raw_t>(), session_id);
EXPECT_NE(replacement, resumed);
}
▸
tests/unit/test_mouse.cpp
+123
−43
@@ -4,22 +4,103 @@
*/
#include "../tests_common.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <optional>
#include <src/input.h>
#include <thread>
#ifdef _WIN32
#include <Windows.h>
#endif
#if defined(__APPLE__) && defined(__MACH__)
#include <ApplicationServices/ApplicationServices.h>
#endif
namespace {
constexpr double mouse_position_tolerance = 1.0;
constexpr auto mouse_input_wait = std::chrono::milliseconds(500);
constexpr auto mouse_input_poll_interval = std::chrono::milliseconds(10);
bool is_near(double actual, double expected) {
return std::abs(actual - expected) <= mouse_position_tolerance;
}
bool is_relative_axis_moved(double actual_delta, double requested_delta) {
if (requested_delta > 0.0) {
return actual_delta > 0.0;
}
if (requested_delta < 0.0) {
return actual_delta < 0.0;
}
return is_near(actual_delta, 0.0);
}
void expect_relative_axis_moved(double actual_delta, double requested_delta) {
EXPECT_TRUE(is_relative_axis_moved(actual_delta, requested_delta));
}
template<typename Predicate>
std::optional<util::point_t> wait_for_mouse_location(platf::input_t &input, Predicate predicate) {
const auto deadline = std::chrono::steady_clock::now() + mouse_input_wait;
auto location = platf::get_mouse_loc(input);
while ((!location || !predicate(*location)) && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(mouse_input_poll_interval);
location = platf::get_mouse_loc(input);
}
if (!location || !predicate(*location)) {
return std::nullopt;
}
return location;
}
struct MouseHIDTest: PlatformTestSuite, testing::WithParamInterface<util::point_t> {
void SetUp() override {
platf::touch_port_t absolute_mouse_test_port() {
BaseTest::SetUp();
#ifdef _WIN32
return platf::touch_port_t {
.offset_x = GetSystemMetrics(SM_XVIRTUALSCREEN),
.offset_y = GetSystemMetrics(SM_YVIRTUALSCREEN),
.width = std::max(1, GetSystemMetrics(SM_CXVIRTUALSCREEN)),
.height = std::max(1, GetSystemMetrics(SM_CYVIRTUALSCREEN)),
};
// TODO: Windows tests are failing, `get_mouse_loc` seems broken and `platf::abs_mouse` too
// the alternative `platf::abs_mouse` method seem to work better during tests,
// but I'm not sure about real work
GTEST_SKIP() << "TODO Windows";
#elif defined(__APPLE__) && defined(__MACH__)
const auto display_bounds = CGDisplayBounds(CGMainDisplayID());
return platf::touch_port_t {
.offset_x = static_cast<int>(display_bounds.origin.x),
.offset_y = static_cast<int>(display_bounds.origin.y),
.width = static_cast<int>(display_bounds.size.width),
.height = static_cast<int>(display_bounds.size.height),
};
#elif defined(__linux__) || defined(__FreeBSD__)
return platf::touch_port_t {
// TODO: Inputtino waiting https://github.com/games-on-whales/inputtino/issues/6 is resolved.
GTEST_SKIP() << "TODO Inputtino";
.offset_x = 0,
.offset_y = 0,
.width = 19200,
.height = 12000,
};
#else
return platf::touch_port_t {};
#endif
}
util::point_t expected_absolute_mouse_location(const util::point_t &mouse_pos, const platf::touch_port_t &touch_port) {
return {
static_cast<double>(touch_port.offset_x) + mouse_pos.x,
static_cast<double>(touch_port.offset_y) + mouse_pos.y,
};
}
} // namespace
struct MouseHIDTest: PlatformTestSuite, testing::WithParamInterface<util::point_t> {
void SetUp() override {
BaseTest::SetUp();
}
void TearDown() override {
std::this_thread::sleep_for(std::chrono::milliseconds(200));
BaseTest::TearDown();
@@ -46,18 +127,26 @@
BOOST_LOG(tests) << "MoveInputTest:: get current mouse loc";
auto old_loc = platf::get_mouse_loc(input);
BOOST_LOG(tests) << "MoveInputTest:: got current mouse loc: " << old_loc;
if (!old_loc) {
GTEST_SKIP() << "Mouse location is not observable in this environment";
}
BOOST_LOG(tests) << "MoveInputTest:: got current mouse loc: " << *old_loc;
BOOST_LOG(tests) << "MoveInputTest:: move: " << mouse_delta;
platf::move_mouse(input, mouse_delta.x, mouse_delta.y);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
BOOST_LOG(tests) << "MoveInputTest:: moved: " << mouse_delta;
BOOST_LOG(tests) << "MoveInputTest:: get updated mouse loc";
auto new_loc = wait_for_mouse_location(input, [old_loc = *old_loc, &mouse_delta](const auto &location) {
return is_relative_axis_moved(location.x - old_loc.x, mouse_delta.x) &&
is_relative_axis_moved(location.y - old_loc.y, mouse_delta.y);
});
if (!new_loc) {
auto new_loc = platf::get_mouse_loc(input);
BOOST_LOG(tests) << "MoveInputTest:: got updated mouse loc: " << new_loc;
GTEST_SKIP() << "Virtual mouse movement is not observable in this environment";
}
BOOST_LOG(tests) << "MoveInputTest:: got updated mouse loc: " << *new_loc;
bool has_input_moved = old_loc.x != new_loc.x && old_loc.y != new_loc.y;
bool has_input_moved = old_loc->x != new_loc->x && old_loc->y != new_loc->y;
if (!has_input_moved) {
BOOST_LOG(tests) << "MoveInputTest:: haven't moved";
@@ -67,9 +156,9 @@
EXPECT_TRUE(has_input_moved);
// Verify we moved as much as we requested
EXPECT_EQ(new_loc.x - old_loc.x, mouse_delta.x);
EXPECT_EQ(new_loc.y - old_loc.y, mouse_delta.y);
// Verify the OS moved in the requested direction. Relative pointer input can be accelerated by host settings.
expect_relative_axis_moved(new_loc->x - old_loc->x, mouse_delta.x);
expect_relative_axis_moved(new_loc->y - old_loc->y, mouse_delta.y);
}
TEST_P(MouseHIDTest, AbsMoveInputTest) {
@@ -81,35 +170,28 @@
BOOST_LOG(tests) << "AbsMoveInputTest:: get current mouse loc";
auto old_loc = platf::get_mouse_loc(input);
if (!old_loc) {
GTEST_SKIP() << "Mouse location is not observable in this environment";
BOOST_LOG(tests) << "AbsMoveInputTest:: got current mouse loc: " << old_loc;
}
BOOST_LOG(tests) << "AbsMoveInputTest:: got current mouse loc: " << *old_loc;
const auto abs_port = absolute_mouse_test_port();
const auto expected_pos = expected_absolute_mouse_location(mouse_pos, abs_port);
BOOST_LOG(tests) << "AbsMoveInputTest:: touch port: " << abs_port.offset_x << 'x' << abs_port.offset_y << ' ' << abs_port.width << 'x' << abs_port.height;
#ifdef _WIN32
platf::touch_port_t abs_port {
0,
0,
65535,
65535
};
#elif defined(__linux__) || defined(__FreeBSD__)
platf::touch_port_t abs_port {
0,
0,
19200,
12000
};
#else
platf::touch_port_t abs_port {};
#endif
BOOST_LOG(tests) << "AbsMoveInputTest:: move: " << mouse_pos;
platf::abs_mouse(input, abs_port, mouse_pos.x, mouse_pos.y);
std::this_thread::sleep_for(std::chrono::milliseconds(200));
BOOST_LOG(tests) << "AbsMoveInputTest:: moved: " << mouse_pos;
BOOST_LOG(tests) << "AbsMoveInputTest:: get updated mouse loc";
auto new_loc = platf::get_mouse_loc(input);
BOOST_LOG(tests) << "AbsMoveInputTest:: got updated mouse loc: " << new_loc;
auto new_loc = wait_for_mouse_location(input, [&expected_pos](const auto &location) {
return is_near(location.x, expected_pos.x) && is_near(location.y, expected_pos.y);
});
if (!new_loc) {
GTEST_SKIP() << "Absolute virtual mouse movement is not observable in this environment";
}
BOOST_LOG(tests) << "AbsMoveInputTest:: got updated mouse loc: " << *new_loc;
bool has_input_moved = old_loc->x != new_loc->x || old_loc->y != new_loc->y;
bool has_input_moved = old_loc.x != new_loc.x || old_loc.y != new_loc.y;
if (!has_input_moved) {
BOOST_LOG(tests) << "AbsMoveInputTest:: haven't moved";
@@ -117,9 +199,7 @@
BOOST_LOG(tests) << "AbsMoveInputTest:: moved";
}
EXPECT_TRUE(has_input_moved);
// Verify we moved to the absolute coordinate
EXPECT_NEAR(new_loc->x, expected_pos.x, mouse_position_tolerance);
EXPECT_NEAR(new_loc->y, expected_pos.y, mouse_position_tolerance);
EXPECT_EQ(new_loc.x, mouse_pos.x);
EXPECT_EQ(new_loc.y, mouse_pos.y);
}
▸
tests/unit/test_system_tray.cpp
+205
−11
@@ -9,6 +9,7 @@
#include <chrono>
#include <cstdlib>
#include <filesystem>
#include <format>
#include <optional>
#include <string>
#include <thread>
@@ -19,6 +20,9 @@
// lib includes
#include <tray.h>
#ifdef _WIN32
#include <libvirtualhid/license.hpp>
#endif
// local includes
#include <src/system_tray.h>
@@ -70,7 +74,43 @@
}
#endif
#ifdef _WIN32
/**
* @brief Verify the persistent Virtual HID Driver benefits submenu.
*
* @param benefits_menu Benefits submenu to verify.
*/
void verify_virtualhid_benefits_menu(const struct tray_menu *benefits_menu) {
ASSERT_NE(benefits_menu, nullptr);
EXPECT_STREQ(benefits_menu[0].text, "Xbox One, Xbox Series, DualSense (DS5), Switch Pro, and Generic");
EXPECT_EQ(benefits_menu[0].disabled, 1);
EXPECT_STREQ(benefits_menu[1].text, "Motion, touchpads, LEDs, and adaptive triggers where supported");
EXPECT_EQ(benefits_menu[1].disabled, 1);
EXPECT_STREQ(benefits_menu[2].text, "Actively developed and supported by LizardByte");
EXPECT_EQ(benefits_menu[2].disabled, 1);
EXPECT_STREQ(benefits_menu[3].text, "-");
EXPECT_STREQ(benefits_menu[4].text, "Open License Settings");
EXPECT_NE(benefits_menu[4].cb, nullptr);
EXPECT_EQ(benefits_menu[5].text, nullptr);
}
/**
* @brief Verify the shared action entries in a populated Virtual HID Driver menu.
*
* @param license_menu License submenu to verify.
*/
void verify_virtualhid_actions_menu(const struct tray_menu *license_menu) {
ASSERT_NE(license_menu, nullptr);
EXPECT_STREQ(license_menu[8].text, "Benefits over ViGEmBus");
EXPECT_EQ(license_menu[8].cb, nullptr);
verify_virtualhid_benefits_menu(license_menu[8].submenu);
EXPECT_STREQ(license_menu[9].text, "Download Virtual HID Driver");
EXPECT_NE(license_menu[9].cb, nullptr);
EXPECT_EQ(license_menu[10].text, nullptr);
}
#endif
/**
* @brief Verify the persistent menu exposed by Sunshine.
*/
void verify_menu() {
@@ -81,6 +121,37 @@
EXPECT_NE(tray_data.menu[0].cb, nullptr);
EXPECT_STREQ(tray_data.menu[1].text, "-");
EXPECT_EQ(tray_data.menu[1].cb, nullptr);
#ifdef _WIN32
EXPECT_STREQ(tray_data.menu[2].text, "Virtual HID Driver");
ASSERT_NE(tray_data.menu[2].submenu, nullptr);
EXPECT_STREQ(tray_data.menu[2].submenu[0].text, "Status: Checking");
EXPECT_EQ(tray_data.menu[2].submenu[0].disabled, 1);
EXPECT_STREQ(tray_data.menu[2].submenu[1].text, "-");
EXPECT_STREQ(tray_data.menu[2].submenu[2].text, "Open License Settings");
EXPECT_NE(tray_data.menu[2].submenu[2].cb, nullptr);
EXPECT_STREQ(tray_data.menu[2].submenu[3].text, "Benefits over ViGEmBus");
EXPECT_EQ(tray_data.menu[2].submenu[3].cb, nullptr);
verify_virtualhid_benefits_menu(tray_data.menu[2].submenu[3].submenu);
EXPECT_STREQ(tray_data.menu[2].submenu[4].text, "Download Virtual HID Driver");
EXPECT_NE(tray_data.menu[2].submenu[4].cb, nullptr);
EXPECT_EQ(tray_data.menu[2].submenu[5].text, nullptr);
EXPECT_STREQ(tray_data.menu[3].text, "-");
EXPECT_STREQ(tray_data.menu[4].text, "Donate");
ASSERT_NE(tray_data.menu[4].submenu, nullptr);
EXPECT_STREQ(tray_data.menu[4].submenu[0].text, "GitHub Sponsors");
EXPECT_STREQ(tray_data.menu[4].submenu[1].text, "Patreon");
EXPECT_STREQ(tray_data.menu[4].submenu[2].text, "PayPal");
EXPECT_EQ(tray_data.menu[4].submenu[3].text, nullptr);
EXPECT_STREQ(tray_data.menu[5].text, "-");
EXPECT_STREQ(tray_data.menu[6].text, "Reset Display Device Config");
EXPECT_NE(tray_data.menu[6].cb, nullptr);
EXPECT_STREQ(tray_data.menu[7].text, "Restart");
EXPECT_NE(tray_data.menu[7].cb, nullptr);
EXPECT_STREQ(tray_data.menu[8].text, "Quit");
EXPECT_NE(tray_data.menu[8].cb, nullptr);
EXPECT_EQ(tray_data.menu[9].text, nullptr);
#else
EXPECT_STREQ(tray_data.menu[2].text, "Donate");
ASSERT_NE(tray_data.menu[2].submenu, nullptr);
EXPECT_STREQ(tray_data.menu[2].submenu[0].text, "GitHub Sponsors");
@@ -88,16 +159,6 @@
EXPECT_STREQ(tray_data.menu[2].submenu[2].text, "PayPal");
EXPECT_EQ(tray_data.menu[2].submenu[3].text, nullptr);
EXPECT_STREQ(tray_data.menu[3].text, "-");
#ifdef _WIN32
EXPECT_STREQ(tray_data.menu[4].text, "Reset Display Device Config");
EXPECT_NE(tray_data.menu[4].cb, nullptr);
EXPECT_STREQ(tray_data.menu[5].text, "Restart");
EXPECT_NE(tray_data.menu[5].cb, nullptr);
EXPECT_STREQ(tray_data.menu[6].text, "Quit");
EXPECT_NE(tray_data.menu[6].cb, nullptr);
EXPECT_EQ(tray_data.menu[7].text, nullptr);
#else
EXPECT_STREQ(tray_data.menu[4].text, "Restart");
EXPECT_NE(tray_data.menu[4].cb, nullptr);
EXPECT_STREQ(tray_data.menu[5].text, "Quit");
@@ -235,6 +296,134 @@
EXPECT_EQ(system_tray::end_tray(), 0);
}
#ifdef _WIN32
TEST_F(SystemTrayTest, ResolvesDevelopmentTrayIconsFromExecutableDirectory) {
EXPECT_EQ(system_tray::resource_path_for_testing(nullptr), nullptr);
EXPECT_EQ(system_tray::resource_path_for_testing(""), nullptr);
const auto *sunshine_icon = system_tray::resource_path_for_testing("test_assets/web/images/logo-sunshine.svg");
ASSERT_NE(sunshine_icon, nullptr);
EXPECT_TRUE(std::filesystem::path {sunshine_icon}.is_absolute());
EXPECT_TRUE(std::filesystem::exists(sunshine_icon));
EXPECT_EQ(system_tray::resource_path_for_testing("test_assets/web/images/logo-sunshine.svg"), sunshine_icon);
const auto *virtualhid_icon = system_tray::resource_path_for_testing("test_assets/web/images/logo-libvirtualhid.svg");
ASSERT_NE(virtualhid_icon, nullptr);
EXPECT_TRUE(std::filesystem::path {virtualhid_icon}.is_absolute());
EXPECT_TRUE(std::filesystem::exists(virtualhid_icon));
}
TEST_F(SystemTrayTest, PreparesVirtualHidMenuFromCurrentLicenseStatus) {
system_tray::prepare_tray_virtualhid_license();
const auto &tray_data = system_tray::tray_data_for_testing();
ASSERT_NE(tray_data.menu[2].submenu, nullptr);
ASSERT_NE(tray_data.menu[2].submenu[0].text, nullptr);
EXPECT_STRNE(tray_data.menu[2].submenu[0].text, "Status: Checking");
}
TEST_F(SystemTrayTest, PreparesLicensedVirtualHidMenuBeforeInitialization) {
lvh::LicenseStatus license;
license.service_available = true;
license.state = lvh::LicenseState::licensed;
license.plan_name = "Yearly";
license.customer_email = "customer@example.com";
license.expires_at = "2027-08-10T00:00:00Z";
license.activation_usage = 2;
license.activation_limit = 5;
system_tray::update_tray_virtualhid_license(license, true);
const auto &tray_data = system_tray::tray_data_for_testing();
const auto *license_menu = tray_data.menu[2].submenu;
ASSERT_NE(license_menu, nullptr);
EXPECT_STREQ(license_menu[0].text, "Status: Licensed");
EXPECT_STREQ(license_menu[1].text, "Plan: Yearly");
EXPECT_STREQ(license_menu[2].text, "Customer: customer@example.com");
EXPECT_STREQ(license_menu[3].text, "Expires: 2027-08-10T00:00:00Z");
EXPECT_STREQ(license_menu[4].text, "Machine activations: 2 / 5");
EXPECT_STREQ(license_menu[5].text, "-");
EXPECT_STREQ(license_menu[6].text, "View License Details");
EXPECT_NE(license_menu[6].cb, nullptr);
EXPECT_STREQ(license_menu[7].text, "Manage License");
EXPECT_NE(license_menu[7].cb, nullptr);
verify_virtualhid_actions_menu(license_menu);
EXPECT_EQ(tray_data.notification_title, nullptr);
EXPECT_EQ(tray_data.notification_text, nullptr);
EXPECT_EQ(tray_data.notification_cb, nullptr);
license.plan_name.clear();
license.customer_email.clear();
license.expires_at.clear();
license.activation_usage = 0;
license.activation_limit = 0;
system_tray::update_tray_virtualhid_license(license, false);
EXPECT_STREQ(license_menu[1].text, "This machine is activated");
EXPECT_STREQ(license_menu[2].text, "Customer: Not reported");
EXPECT_STREQ(license_menu[3].text, "Expiration: Not reported");
EXPECT_STREQ(license_menu[4].text, "Machine activations: Not reported");
}
/**
* @brief License-state fixture for the Windows Virtual HID Driver tray menu.
*/
class UnlicensedVirtualHidTrayTest:
public SystemTrayTest,
public testing::WithParamInterface<std::tuple<lvh::LicenseState, const char *, const char *, bool>> {};
TEST_P(UnlicensedVirtualHidTrayTest, PreparesMenuAndStartupNotification) {
const auto &[state, state_label, state_detail, service_available] = GetParam();
lvh::LicenseStatus license;
license.service_available = service_available;
license.state = state;
system_tray::update_tray_virtualhid_license(license, true);
const auto &tray_data = system_tray::tray_data_for_testing();
const auto *license_menu = tray_data.menu[2].submenu;
ASSERT_NE(license_menu, nullptr);
EXPECT_STREQ(license_menu[0].text, std::format("Status: {}", state_label).c_str());
EXPECT_STREQ(license_menu[1].text, state_detail);
EXPECT_STREQ(license_menu[2].text, "Full virtual gamepad support is locked");
EXPECT_STREQ(license_menu[3].text, service_available ? "License service: Available" : "License service: Unavailable");
EXPECT_STREQ(license_menu[4].text, "Activate this machine to use Virtual HID Driver");
EXPECT_STREQ(license_menu[5].text, "-");
EXPECT_STREQ(license_menu[6].text, "Activate License");
EXPECT_NE(license_menu[6].cb, nullptr);
EXPECT_STREQ(license_menu[7].text, "Buy License");
EXPECT_NE(license_menu[7].cb, nullptr);
verify_virtualhid_actions_menu(license_menu);
EXPECT_STREQ(tray_data.notification_title, "Activate Virtual HID Driver");
EXPECT_STREQ(
tray_data.notification_text,
"Adds Xbox One/Series, DualSense (DS5), Switch Pro, and Generic gamepads beyond ViGEmBus. Actively maintained by LizardByte. Click to activate or buy a license; details remain in the tray menu."
);
EXPECT_STREQ(tray_data.notification_icon, tray_data.allIconPaths[4]);
EXPECT_NE(tray_data.notification_cb, nullptr);
system_tray::resolve_tray_icon_paths_for_testing();
EXPECT_STREQ(tray_data.notification_icon, tray_data.allIconPaths[4]);
system_tray::update_tray_virtualhid_license(license, false);
EXPECT_EQ(tray_data.notification_title, nullptr);
EXPECT_EQ(tray_data.notification_text, nullptr);
EXPECT_EQ(tray_data.notification_cb, nullptr);
}
INSTANTIATE_TEST_SUITE_P(
LicenseStates,
UnlicensedVirtualHidTrayTest,
testing::Values(
std::tuple {lvh::LicenseState::unavailable, "Unavailable", "The local license service is unavailable", false},
std::tuple {lvh::LicenseState::unlicensed, "Not Activated", "No license is active on this machine", true},
std::tuple {lvh::LicenseState::expired, "Expired", "The license on this machine has expired", true},
std::tuple {lvh::LicenseState::disabled, "Disabled", "The license on this machine is disabled", true},
std::tuple {lvh::LicenseState::invalid, "Invalid", "The license on this machine is invalid", true}
)
);
#endif
#ifndef _WIN32
TEST_F(SystemTrayTest, LifecycleMenuAndStateTransitions) {
if (const int result = initialize_tray(); result != 0) {
@@ -257,6 +446,7 @@
#ifdef _WIN32
TEST_F(SystemTrayTest, InitializesTrayForWorkflowConfiguration) {
std::string configure_tray_icons;
if (std::getenv("SUNSHINE_CONFIGURE_TRAY_ICONS") == nullptr) {
if (!lizardbyte::common::get_env("SUNSHINE_CONFIGURE_TRAY_ICONS", configure_tray_icons)) {
GTEST_SKIP() << "Only required while configuring Windows runner tray icon visibility";
}
@@ -338,7 +528,11 @@
}
const auto &tray_data = system_tray::tray_data_for_testing();
#ifdef _WIN32
ASSERT_EQ(tray_data.iconPathCount, 5);
#else
ASSERT_EQ(tray_data.iconPathCount, 4);
#endif
for (int icon_index = 0; icon_index < tray_data.iconPathCount; ++icon_index) {
ASSERT_NE(tray_data.allIconPaths[icon_index], nullptr);
ASSERT_TRUE(std::filesystem::is_regular_file(tray_data.allIconPaths[icon_index]))
▸
third-party/inputtino
+0
−0
▸
third-party/libvirtualhid
+0
−0