ref:9f7daa5e5a804b8eb09586d39b00e3e9bc176200

fix(input): track/release client keycode for modifiers (#5558)

Co-authored-by: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
SHA: 9f7daa5e5a804b8eb09586d39b00e3e9bc176200
Author: Seth Moore <sethdmoore@users.noreply.github.com>
Date: 2026-09-01 23:07
Parents: f111d6a
3 files changed +1110 -22
Type
src/input.cpp +153 −22
@@ -165,6 +165,17 @@
static std::array<std::uint8_t, 5> mouse_press {};
static platf::input_t platf_input;
#ifdef SUNSHINE_TESTS
/**
* @brief Recorder that unit tests install in place of the platform keyboard.
*
* @return Mutable reference to the recorder, empty when no test installed one.
*/
std::function<void(const testing::keyboard_event_t &)> &keyboard_sink() {
static std::function<void(const testing::keyboard_event_t &)> sink;
return sink;
}
#endif
static std::bitset<platf::MAX_GAMEPADS> gamepadMask {};
/**
@@ -218,6 +229,24 @@
};
/**
* @brief Tracks the side-specific client keys that contribute to one modifier flag.
*/
struct modifier_state_t {
/**
* @brief Return whether any key for this modifier remains pressed.
*
* @return `true` when the generic, left, or right key is pressed.
*/
[[nodiscard]] bool any_pressed() const {
return generic_pressed || left_pressed || right_pressed;
}
bool generic_pressed = false; ///< Whether the side-less modifier key is pressed.
bool left_pressed = false; ///< Whether the left modifier key is pressed.
bool right_pressed = false; ///< Whether the right modifier key is pressed.
};
/**
* @brief Input emulation settings loaded from configuration.
*/
struct input_t {
@@ -255,8 +284,9 @@
// Keep track of alt+ctrl+shift key combo
int shortcutFlags; ///< Shortcut flags.
modifier_state_t shift_keys; ///< Client Shift keys contributing to the aggregate Shift flag.
modifier_state_t control_keys; ///< Client Control keys contributing to the aggregate Control flag.
bool left_alt_pressed = false; ///< Tracks whether the left Alt key is currently pressed.
bool right_alt_pressed = false; ///< Tracks whether the right Alt key is currently pressed.
modifier_state_t alt_keys; ///< Client Alt keys contributing to the aggregate Alt flag.
std::vector<gamepad_t> gamepads; ///< Virtual gamepad slots tracked for the stream.
std::unique_ptr<platf::client_input_t> client_context; ///< Client context.
@@ -917,19 +947,64 @@
}
/**
* @brief Update the side-specific state for a client modifier key.
*
* @param input Input context tracking the modifier keys.
* @param key_code Moonlight keyboard packet key code.
* @param release Whether the key event is a release.
*/
void update_modifier_state(input_t &input, short key_code, bool release) {
const bool pressed = !release;
switch (key_code) {
case VKEY_SHIFT:
input.shift_keys.generic_pressed = pressed;
break;
case VKEY_LSHIFT:
input.shift_keys.left_pressed = pressed;
break;
case VKEY_RSHIFT:
input.shift_keys.right_pressed = pressed;
break;
case VKEY_CONTROL:
input.control_keys.generic_pressed = pressed;
break;
case VKEY_LCONTROL:
input.control_keys.left_pressed = pressed;
break;
case VKEY_RCONTROL:
input.control_keys.right_pressed = pressed;
break;
case VKEY_MENU:
input.alt_keys.generic_pressed = pressed;
break;
case VKEY_LMENU:
input.alt_keys.left_pressed = pressed;
break;
case VKEY_RMENU:
input.alt_keys.right_pressed = pressed;
break;
default:
break;
}
}
/**
* @brief Update flags for keyboard shortcut combo's
*
* @param flags Bit flags that modify the requested operation.
* @param input Input context tracking which side-specific modifier keys are held.
* @param keyCode Moonlight keyboard packet key code.
* @param release Whether the key or button event is a release.
*/
inline void update_shortcutFlags(input_t &input, short keyCode, bool release) {
inline void update_shortcutFlags(int *flags, short keyCode, bool release) {
int *flags = &input.shortcutFlags;
switch (keyCode) {
case VKEY_SHIFT:
case VKEY_LSHIFT:
case VKEY_RSHIFT:
if (release) {
if (!input.shift_keys.any_pressed()) {
*flags &= ~input_t::SHIFT;
*flags &= ~input_t::SHIFT;
}
} else {
*flags |= input_t::SHIFT;
}
@@ -938,7 +1013,9 @@
case VKEY_LCONTROL:
case VKEY_RCONTROL:
if (release) {
if (!input.control_keys.any_pressed()) {
*flags &= ~input_t::CTRL;
}
*flags &= ~input_t::CTRL;
} else {
*flags |= input_t::CTRL;
}
@@ -946,8 +1023,13 @@
case VKEY_MENU:
case VKEY_LMENU:
case VKEY_RMENU:
// Left, right, and side-less Alt all set the same aggregate ALT bit, so releasing
// one of them must not clear it while another is still held (e.g. Right Alt mapped
// to Meta via key_rightalt_to_key_win, released while Left Alt remains down).
if (release) {
if (!input.alt_keys.any_pressed()) {
*flags &= ~input_t::ALT;
*flags &= ~input_t::ALT;
}
} else {
*flags |= input_t::ALT;
}
@@ -979,6 +1061,25 @@
}
/**
* @brief Deliver one keyboard event to the platform backend.
*
* Test builds can divert the event to a recorder so unit tests never type into the host.
*
* @param key_code Platform keycode to emit.
* @param release Whether the key event is a release.
* @param flags Bit flags that modify the requested operation.
*/
void emit_keyboard_update(uint16_t key_code, bool release, uint8_t flags) {
#ifdef SUNSHINE_TESTS
if (keyboard_sink()) {
keyboard_sink()(testing::keyboard_event_t {key_code, release, flags});
return;
}
#endif
platf::keyboard_update(platf_input, key_code, release, flags);
}
/**
* @brief Send key and modifiers.
*
* @param key_code Moonlight keyboard packet key code.
@@ -990,28 +1091,28 @@
if (!release) {
// Press any synthetic modifiers required for this key
if (synthetic_modifiers & MODIFIER_SHIFT) {
emit_keyboard_update(VKEY_SHIFT, false, flags);
platf::keyboard_update(platf_input, VKEY_SHIFT, false, flags);
}
if (synthetic_modifiers & MODIFIER_CTRL) {
emit_keyboard_update(VKEY_CONTROL, false, flags);
platf::keyboard_update(platf_input, VKEY_CONTROL, false, flags);
}
if (synthetic_modifiers & MODIFIER_ALT) {
platf::keyboard_update(platf_input, VKEY_MENU, false, flags);
emit_keyboard_update(VKEY_MENU, false, flags);
}
}
platf::keyboard_update(platf_input, map_keycode(key_code), release, flags);
emit_keyboard_update(map_keycode(key_code), release, flags);
if (!release) {
// Raise any synthetic modifier keys we pressed
if (synthetic_modifiers & MODIFIER_SHIFT) {
platf::keyboard_update(platf_input, VKEY_SHIFT, true, flags);
emit_keyboard_update(VKEY_SHIFT, true, flags);
}
if (synthetic_modifiers & MODIFIER_CTRL) {
emit_keyboard_update(VKEY_CONTROL, true, flags);
platf::keyboard_update(platf_input, VKEY_CONTROL, true, flags);
}
if (synthetic_modifiers & MODIFIER_ALT) {
platf::keyboard_update(platf_input, VKEY_MENU, true, flags);
emit_keyboard_update(VKEY_MENU, true, flags);
}
}
}
@@ -1049,14 +1150,10 @@
auto release = util::endian::little(packet->header.magic) == KEY_UP_EVENT_MAGIC;
auto keyCode = packet->keyCode & 0x00FF;
update_modifier_state(*input, keyCode, release);
if (keyCode == VKEY_LMENU) {
input->left_alt_pressed = !release;
} else if (keyCode == VKEY_RMENU) {
input->right_alt_pressed = !release;
}
// Right-alt maps to meta, so it must not also register as ALT
int modifiers = packet->modifiers;
if (config::input.key_rightalt_to_key_win && input->right_alt_pressed && !input->left_alt_pressed) {
if (config::input.key_rightalt_to_key_win && input->alt_keys.right_pressed && !input->alt_keys.left_pressed) {
modifiers &= ~MODIFIER_ALT;
}
@@ -1105,7 +1202,10 @@
send_key_and_modifiers(keyCode, release, packet->flags, synthetic_modifiers);
// Track the modifier state the client is holding, not the remapped host key.
// This is compared against packet->modifiers above, which is client-side, so a
// keybinding that moves Alt off VKEY_*MENU must not clear the ALT bit here.
update_shortcutFlags(*input, keyCode, release);
update_shortcutFlags(&input->shortcutFlags, map_keycode(keyCode), release);
}
/**
@@ -1966,7 +2066,10 @@
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));
// key_press is keyed on the client's unmapped virtual-key code, but the press was
// emitted through map_keycode(). Release the host key that actually went down,
// otherwise a remapped modifier stays latched after the client disconnects.
emit_keyboard_update(map_keycode(vk_from_kpid(key) & 0x00FF), true, flags_from_kpid(key));
pressed = false;
}
}
@@ -2160,6 +2263,34 @@
return -1;
}
return input->gamepads[client_index].id;
}
void set_keyboard_sink(std::function<void(const keyboard_event_t &)> sink) {
keyboard_sink() = std::move(sink);
}
void send_keyboard_packet(std::shared_ptr<input_t> &input, std::uint16_t key_code, std::uint8_t modifiers, std::uint8_t flags, bool release) {
const std::uint32_t magic = release ? KEY_UP_EVENT_MAGIC : KEY_DOWN_EVENT_MAGIC;
NV_KEYBOARD_PACKET packet {};
packet.header.size = util::endian::big<std::uint32_t>(sizeof(packet) - sizeof(packet.header.size));
packet.header.magic = util::endian::little(magic);
packet.keyCode = static_cast<short>(key_code);
packet.modifiers = static_cast<char>(modifiers);
packet.flags = static_cast<char>(flags);
// Keyboard packets are never batched, so this matches passthrough_next_message().
::input::passthrough(input, &packet);
}
void reset_keyboard_state() {
task_pool.cancel(key_press_repeat_id);
key_press_repeat_id = nullptr;
key_press.clear();
}
void release_held_keys() {
reset_keyboard_keys();
}
} // namespace testing
#endif
src/input.h +41 −0
@@ -106,5 +106,46 @@
* @return Assigned global gamepad slot, or -1 when unallocated.
*/
int gamepad_id(const std::shared_ptr<input_t> &input, std::uint8_t client_index);
/**
* @brief Keyboard event Sunshine emitted toward the platform backend.
*/
struct keyboard_event_t {
std::uint16_t key_code; ///< Platform keycode after the configured keybinding remap.
bool release; ///< Whether the event releases the key.
std::uint8_t flags; ///< Bit flags carried by the client keyboard packet.
};
/**
* @brief Redirect keyboard output away from the host operating system.
*
* Tests must install a sink before emitting keys, otherwise the events are typed into the
* machine running the test suite.
*
* @param sink Recorder invoked in place of platf::keyboard_update, or empty to restore
* delivery to the platform backend.
*/
void set_keyboard_sink(std::function<void(const keyboard_event_t &)> sink);
/**
* @brief Process one client keyboard packet on the calling thread.
*
* @param input Retained input state.
* @param key_code Windows virtual-key code sent by the client.
* @param modifiers Client modifier bitmask carried by the packet.
* @param flags Bit flags carried by the client keyboard packet.
* @param release Whether the packet releases the key.
*/
void send_keyboard_packet(std::shared_ptr<input_t> &input, std::uint16_t key_code, std::uint8_t modifiers, std::uint8_t flags, bool release);
/**
* @brief Forget every key Sunshine tracks as pressed and cancel any pending key repeat.
*/
void reset_keyboard_state();
/**
* @brief Release every key Sunshine tracks as pressed, as a disconnect does.
*/
void release_held_keys();
} // namespace testing
#endif
tests/unit/test_keyboard.cpp +916 −0
@@ -1,0 +1,916 @@
/**
* @file tests/unit/test_keyboard.cpp
* @brief Tests for keyboard packet handling in src/input.*.
*
* Moonlight always sends Windows virtual-key codes on the wire, whatever platform the client
* runs on. Sunshine is responsible for the platform-independent half of the translation:
* applying the `keybindings` remap, tracking which modifiers the client holds, and injecting
* synthetic modifier presses when the client reports a modifier Sunshine does not believe is
* held. Translating a virtual-key code into a native keycode is libvirtualhid's job, and each
* of its backend tables is covered by that project's own per-platform test job (see
* `TranslatesKeyboardKeys` in libvirtualhid's `tests/unit/test_*_backend.cpp`). The tables are
* not reachable from here: they live in anonymous namespaces inside backend translation units
* that only compile on their own platform.
*
* These tests record keyboard output instead of delivering it, so they never type into the
* machine running the suite. One test deliberately clears the recorder to prove the real
* delivery path still reaches the virtual keyboard.
*/
// standard includes
#include <algorithm>
#include <array>
#include <chrono>
#include <cstdint>
#include <format>
#include <memory>
#include <set>
#include <string>
#include <utility>
#include <vector>
// local includes
#include "../tests_common.h"
#include "src/config.h"
#include "src/input.h"
#include "src/platform/virtualhid_input.h"
namespace {
using namespace std::chrono_literals;
// Windows virtual-key codes as they arrive from the client.
constexpr std::uint16_t VKEY_BACK = 0x08;
constexpr std::uint16_t VKEY_TAB = 0x09;
constexpr std::uint16_t VKEY_RETURN = 0x0D;
constexpr std::uint16_t VKEY_SHIFT = 0x10;
constexpr std::uint16_t VKEY_CONTROL = 0x11;
constexpr std::uint16_t VKEY_MENU = 0x12;
constexpr std::uint16_t VKEY_ESCAPE = 0x1B;
constexpr std::uint16_t VKEY_SPACE = 0x20;
constexpr std::uint16_t VKEY_A = 0x41;
constexpr std::uint16_t VKEY_B = 0x42;
constexpr std::uint16_t VKEY_Z = 0x5A;
constexpr std::uint16_t VKEY_LWIN = 0x5B;
constexpr std::uint16_t VKEY_RWIN = 0x5C;
constexpr std::uint16_t VKEY_F1 = 0x70;
constexpr std::uint16_t VKEY_LSHIFT = 0xA0;
constexpr std::uint16_t VKEY_RSHIFT = 0xA1;
constexpr std::uint16_t VKEY_LCONTROL = 0xA2;
constexpr std::uint16_t VKEY_RCONTROL = 0xA3;
constexpr std::uint16_t VKEY_LMENU = 0xA4;
constexpr std::uint16_t VKEY_RMENU = 0xA5;
/**
* @brief A key on the US QWERTY layout and the two ASCII characters it produces.
*/
struct ascii_key_t {
std::uint16_t key_code; ///< Windows virtual-key code Moonlight sends for this key.
char plain; ///< Character produced without shift.
char shifted; ///< Character produced with shift.
};
/**
* @brief Every US QWERTY key that produces a printable ASCII character.
*/
constexpr std::array<ascii_key_t, 48> qwerty_ascii_keys {{
{VKEY_SPACE, ' ', ' '},
{0x30, '0', ')'},
{0x31, '1', '!'},
{0x32, '2', '@'},
{0x33, '3', '#'},
{0x34, '4', '$'},
{0x35, '5', '%'},
{0x36, '6', '^'},
{0x37, '7', '&'},
{0x38, '8', '*'},
{0x39, '9', '('},
{0x41, 'a', 'A'},
{0x42, 'b', 'B'},
{0x43, 'c', 'C'},
{0x44, 'd', 'D'},
{0x45, 'e', 'E'},
{0x46, 'f', 'F'},
{0x47, 'g', 'G'},
{0x48, 'h', 'H'},
{0x49, 'i', 'I'},
{0x4A, 'j', 'J'},
{0x4B, 'k', 'K'},
{0x4C, 'l', 'L'},
{0x4D, 'm', 'M'},
{0x4E, 'n', 'N'},
{0x4F, 'o', 'O'},
{0x50, 'p', 'P'},
{0x51, 'q', 'Q'},
{0x52, 'r', 'R'},
{0x53, 's', 'S'},
{0x54, 't', 'T'},
{0x55, 'u', 'U'},
{0x56, 'v', 'V'},
{0x57, 'w', 'W'},
{0x58, 'x', 'X'},
{0x59, 'y', 'Y'},
{0x5A, 'z', 'Z'},
{0xBA, ';', ':'},
{0xBB, '=', '+'},
{0xBC, ',', '<'},
{0xBD, '-', '_'},
{0xBE, '.', '>'},
{0xBF, '/', '?'},
{0xC0, '`', '~'},
{0xDB, '[', '{'},
{0xDC, '\\', '|'},
{0xDD, ']', '}'},
{0xDE, '\'', '"'},
}};
/**
* @brief Non-character keys a client can send that Sunshine must forward verbatim.
*/
constexpr std::array<std::uint16_t, 43> non_character_keys {{
VKEY_BACK,
VKEY_TAB,
0x0C, // clear
VKEY_RETURN,
VKEY_ESCAPE,
0x14, // caps lock
0x21, // page up
0x22, // page down
0x23, // end
0x24, // home
0x25, // left
0x26, // up
0x27, // right
0x28, // down
0x2C, // print screen
0x2D, // insert
0x2E, // delete
0x5D, // apps
0x60, // numpad 0
0x61, // numpad 1
0x62, // numpad 2
0x63, // numpad 3
0x64, // numpad 4
0x65, // numpad 5
0x66, // numpad 6
0x67, // numpad 7
0x68, // numpad 8
0x69, // numpad 9
0x6A, // numpad multiply
0x6B, // numpad add
0x6D, // numpad subtract
0x6E, // numpad decimal
0x6F, // numpad divide
0x70, // f1
0x71, // f2
0x72, // f3
0x73, // f4
0x74, // f5
0x75, // f6
0x7B, // f12
0x90, // num lock
0x91, // scroll lock
0xFE, // clear (OEM)
}};
/**
* @brief Every virtual-key code Sunshine recognizes as a modifier key.
*/
constexpr std::array<std::uint16_t, 9> modifier_keys {{
VKEY_SHIFT,
VKEY_CONTROL,
VKEY_MENU,
VKEY_LSHIFT,
VKEY_RSHIFT,
VKEY_LCONTROL,
VKEY_RCONTROL,
VKEY_LMENU,
VKEY_RMENU,
}};
/**
* @brief One modifier the client can report, with the keys that hold it down.
*/
struct modifier_t {
unsigned bit; ///< Modifier bit carried in the keyboard packet.
std::uint16_t left; ///< Left-hand virtual-key code.
std::uint16_t right; ///< Right-hand virtual-key code.
std::uint16_t generic; ///< Side-agnostic virtual-key code.
const char *name; ///< Human-readable modifier name.
};
constexpr std::array<modifier_t, 4> modifiers {{
{MODIFIER_SHIFT, VKEY_LSHIFT, VKEY_RSHIFT, VKEY_SHIFT, "shift"},
{MODIFIER_CTRL, VKEY_LCONTROL, VKEY_RCONTROL, VKEY_CONTROL, "ctrl"},
{MODIFIER_ALT, VKEY_LMENU, VKEY_RMENU, VKEY_MENU, "alt"},
// Meta has no side-agnostic code and Sunshine never synthesizes it.
{MODIFIER_META, VKEY_LWIN, VKEY_RWIN, VKEY_LWIN, "meta"},
}};
/**
* @brief Which virtual-key code holds a modifier down for a given side.
*/
enum class side_e {
left, ///< Left-hand modifier keys.
right, ///< Right-hand modifier keys.
generic ///< Side-agnostic modifier keys.
};
/**
* @brief Select the virtual-key code for a modifier and side.
*
* @param modifier Modifier being held.
* @param side Which physical side the client reports.
* @return Virtual-key code the client sends.
*/
std::uint16_t key_for_side(const modifier_t &modifier, side_e side) {
using enum side_e;
switch (side) {
case left:
return modifier.left;
case right:
return modifier.right;
case generic:
default:
return modifier.generic;
}
}
/**
* @brief One modifier combination the client holds down, in press order.
*/
struct held_modifiers_t {
std::vector<std::pair<std::uint16_t, unsigned>> keys; ///< Key code and the mask reported with it.
unsigned claimed = 0; ///< Full mask once every key is down.
std::string label; ///< Combination name for assertion traces.
};
/**
* @brief Select the modifier keys named by a combination bitmask.
*
* Each key carries the mask accumulated so far, which matches a client that presses the
* modifiers one at a time.
*
* @param combination Bit per entry of the modifier table.
* @param side Which physical side the client reports.
* @return Keys to hold, the resulting mask, and a trace label.
*/
held_modifiers_t select_modifiers(unsigned combination, side_e side) {
held_modifiers_t held;
for (std::size_t index = 0; index < modifiers.size(); ++index) {
if (!(combination & (1u << index))) {
continue;
}
held.claimed |= modifiers[index].bit;
held.keys.emplace_back(key_for_side(modifiers[index], side), held.claimed);
held.label += (held.label.empty() ? "" : "+");
held.label += modifiers[index].name;
}
return held;
}
/**
* @brief Render a value as a hexadecimal literal for assertion traces.
*
* @param value Value to render.
* @return Hexadecimal literal.
*/
std::string hex(unsigned value) {
return std::format("0x{:X}", value);
}
/**
* @brief Render a recorded keyboard event as a compact token.
*
* `+0x41` is a press of virtual-key 0x41, `-0x41` its release. Comparing vectors of these
* tokens keeps assertion failures readable, which matters most for the synthetic modifier
* regressions: a spurious real Alt shows up as an unexpected `+0x12` in the diff.
*
* @param event Recorded keyboard event.
* @return Token describing the event.
*/
std::string describe(const input::testing::keyboard_event_t &event) {
return std::format("{}0x{:02X}", event.release ? '-' : '+', event.key_code);
}
/**
* @brief Build the token for a press of a virtual-key code.
*
* @param key_code Virtual-key code emitted toward the platform.
* @return Press token.
*/
std::string pressed(std::uint16_t key_code) {
return describe({key_code, false, 0});
}
/**
* @brief Build the token for a release of a virtual-key code.
*
* @param key_code Virtual-key code emitted toward the platform.
* @return Release token.
*/
std::string released(std::uint16_t key_code) {
return describe({key_code, true, 0});
}
/**
* @brief Fixture that records keyboard output instead of delivering it to the host.
*/
class KeyboardPassthroughTest: public ::testing::Test {
protected:
/**
* @brief Install a fake platform backend, a keyboard recorder, and a stream.
*/
void SetUp() override {
original_input_ = config::input;
config::input.keyboard = true;
config::input.keybindings.clear();
config::input.key_rightalt_to_key_win = false;
// Keep every emission on the calling thread so the recorded order is deterministic.
config::input.key_repeat_delay = 0ms;
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};
context_ = &context;
ASSERT_NE(context_->keyboard, nullptr);
input::testing::set_platform_input(std::move(platform_input));
input::testing::reset_keyboard_state();
input::testing::set_keyboard_sink([this](const input::testing::keyboard_event_t &event) {
events_.push_back(event);
});
static int session = 0;
stream_ = input::alloc(std::make_shared<safe::mail_raw_t>(), std::format("keyboard-test-{}", ++session));
ASSERT_NE(stream_, nullptr);
}
/**
* @brief Stop recording, drop tracked key state, and restore configuration.
*/
void TearDown() override {
input::testing::set_keyboard_sink({});
input::testing::reset_keyboard_state();
stream_.reset();
context_ = nullptr;
input::testing::set_platform_input({});
config::input = std::move(original_input_);
}
/**
* @brief Deliver a key press packet.
*
* @param key_code Windows virtual-key code sent by the client.
* @param client_modifiers Modifier bitmask the client reports as held.
* @param flags Bit flags carried by the packet.
*/
void press(std::uint16_t key_code, unsigned client_modifiers = 0, unsigned flags = 0) {
input::testing::send_keyboard_packet(
stream_,
key_code,
static_cast<std::uint8_t>(client_modifiers),
static_cast<std::uint8_t>(flags),
false
);
}
/**
* @brief Deliver a key release packet.
*
* @param key_code Windows virtual-key code sent by the client.
* @param client_modifiers Modifier bitmask the client reports as held.
* @param flags Bit flags carried by the packet.
*/
void release(std::uint16_t key_code, unsigned client_modifiers = 0, unsigned flags = 0) {
input::testing::send_keyboard_packet(
stream_,
key_code,
static_cast<std::uint8_t>(client_modifiers),
static_cast<std::uint8_t>(flags),
true
);
}
/**
* @brief Take the recorded events as comparable tokens.
*
* @return Tokens for everything recorded since the last call.
*/
std::vector<std::string> taken() {
std::vector<std::string> tokens;
tokens.reserve(events_.size());
for (const auto &event : events_) {
tokens.push_back(describe(event));
}
events_.clear();
return tokens;
}
/**
* @brief Take the recorded events without converting them.
*
* @return Events recorded since the last call.
*/
std::vector<input::testing::keyboard_event_t> taken_events() {
return std::exchange(events_, {});
}
/**
* @brief Access the fake virtual keyboard installed for the current test.
*
* @return Fake libvirtualhid keyboard.
*/
lvh::Keyboard &fake_keyboard() const {
return *context_->keyboard;
}
private:
std::shared_ptr<input::input_t> stream_; ///< Stream input state under test.
std::vector<input::testing::keyboard_event_t> events_; ///< Keyboard output recorded for the current test.
platf::virtualhid::input_context_t *context_ = nullptr; ///< Fake input context installed in the global backend.
config::input_t original_input_; ///< Input configuration restored after each test.
};
} // namespace
TEST_F(KeyboardPassthroughTest, ForwardsEveryUnmodifiedKeyUnchanged) {
for (const auto &key : qwerty_ascii_keys) {
SCOPED_TRACE("character key " + hex(key.key_code));
press(key.key_code);
release(key.key_code);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(key.key_code), released(key.key_code)}));
}
for (const auto key_code : non_character_keys) {
SCOPED_TRACE("non-character key " + hex(key_code));
press(key_code);
release(key_code);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(key_code), released(key_code)}));
}
}
TEST_F(KeyboardPassthroughTest, CoversTheWholePrintableAsciiRange) {
// Guard the table itself: every printable ASCII character must be reachable, exactly once.
std::set<char> covered;
for (const auto &key : qwerty_ascii_keys) {
if (key.plain != key.shifted) {
EXPECT_TRUE(covered.insert(key.plain).second) << "duplicate plain character " << key.plain;
}
EXPECT_TRUE(covered.insert(key.shifted).second) << "duplicate shifted character " << key.shifted;
}
for (char character = 0x20; character < 0x7F; ++character) {
EXPECT_TRUE(covered.contains(character)) << "no key produces '" << character << "'";
}
EXPECT_EQ(covered.size(), 0x7Fu - 0x20u);
// A client types a shifted character by holding a real shift key, so Sunshine must forward
// the shift key it was given and must not add a synthetic one on top.
for (const auto &key : qwerty_ascii_keys) {
SCOPED_TRACE(std::string {"'"} + key.plain + "' / '" + key.shifted + "'");
press(key.key_code);
release(key.key_code);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(key.key_code), released(key.key_code)}));
press(VKEY_LSHIFT, MODIFIER_SHIFT);
press(key.key_code, MODIFIER_SHIFT);
release(key.key_code, MODIFIER_SHIFT);
release(VKEY_LSHIFT, 0);
EXPECT_EQ(
taken(),
(std::vector<std::string> {
pressed(VKEY_LSHIFT),
pressed(key.key_code),
released(key.key_code),
released(VKEY_LSHIFT)
})
);
}
}
TEST_F(KeyboardPassthroughTest, ForwardsModifierKeysWithoutSyntheticInjection) {
// A modifier key event never injects a second modifier around itself, even when the client
// claims modifiers Sunshine does not think are held.
for (const auto key_code : modifier_keys) {
SCOPED_TRACE("modifier key " + hex(key_code));
const std::uint8_t claimed = MODIFIER_SHIFT | MODIFIER_CTRL | MODIFIER_ALT | MODIFIER_META;
press(key_code, claimed);
release(key_code, claimed);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(key_code), released(key_code)}));
}
}
TEST_F(KeyboardPassthroughTest, HoldsEveryModifierCombinationWithoutSyntheticInjection) {
constexpr std::array<side_e, 3> sides {side_e::left, side_e::right, side_e::generic};
for (const auto side : sides) {
// Bit 0 selects shift, bit 1 ctrl, bit 2 alt, bit 3 meta: all 15 non-empty combinations of
// single modifiers, pairs, triples, and the full quad.
for (unsigned combination = 1; combination < (1u << modifiers.size()); ++combination) {
const auto held = select_modifiers(combination, side);
SCOPED_TRACE(std::format("{} (side {})", held.label, static_cast<int>(side)));
std::vector<std::string> expected;
for (const auto &[key_code, reported] : held.keys) {
press(key_code, reported);
expected.push_back(pressed(key_code));
}
press(VKEY_A, held.claimed);
release(VKEY_A, held.claimed);
expected.push_back(pressed(VKEY_A));
expected.push_back(released(VKEY_A));
for (auto key = held.keys.rbegin(); key != held.keys.rend(); ++key) {
release(key->first, held.claimed);
expected.push_back(released(key->first));
}
EXPECT_EQ(taken(), expected);
}
}
}
TEST_F(KeyboardPassthroughTest, InjectsSyntheticModifiersTheClientHoldsWithoutSendingKeys) {
// Moonlight can report a modifier without ever sending its key event, for example when the
// client's own compositor swallowed it. Sunshine wraps the key in a real modifier press.
struct injectable_t {
unsigned bit;
std::uint16_t key_code;
};
constexpr std::array<injectable_t, 3> injectable {{
{MODIFIER_SHIFT, VKEY_SHIFT},
{MODIFIER_CTRL, VKEY_CONTROL},
{MODIFIER_ALT, VKEY_MENU},
}};
for (unsigned combination = 1; combination < (1u << injectable.size()); ++combination) {
std::vector<std::string> expected;
unsigned claimed = 0;
for (std::size_t index = 0; index < injectable.size(); ++index) {
if (combination & (1u << index)) {
claimed |= injectable[index].bit;
expected.push_back(pressed(injectable[index].key_code));
}
}
SCOPED_TRACE("claimed modifiers " + hex(claimed));
expected.push_back(pressed(VKEY_B));
for (std::size_t index = 0; index < injectable.size(); ++index) {
if (combination & (1u << index)) {
expected.push_back(released(injectable[index].key_code));
}
}
press(VKEY_B, claimed);
EXPECT_EQ(taken(), expected);
// Releases are never wrapped, and the release clears the tracked press.
release(VKEY_B, claimed);
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_B)}));
}
}
TEST_F(KeyboardPassthroughTest, NeverSynthesizesTheMetaModifier) {
// MODIFIER_META has no synthetic path, so a client-reported meta must not add any key.
press(VKEY_A, MODIFIER_META);
release(VKEY_A, MODIFIER_META);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_A), released(VKEY_A)}));
}
TEST_F(KeyboardPassthroughTest, PropagatesPacketFlagsToEverySyntheticEvent) {
constexpr std::uint8_t flags = SS_KBE_FLAG_NON_NORMALIZED;
press(VKEY_A, MODIFIER_SHIFT | MODIFIER_ALT, flags);
const auto events = taken_events();
ASSERT_EQ(events.size(), 5u);
for (const auto &event : events) {
EXPECT_EQ(event.flags, flags);
}
EXPECT_EQ(events[0].key_code, VKEY_SHIFT);
EXPECT_EQ(events[1].key_code, VKEY_MENU);
EXPECT_EQ(events[2].key_code, VKEY_A);
EXPECT_EQ(events[3].key_code, VKEY_SHIFT);
EXPECT_EQ(events[4].key_code, VKEY_MENU);
}
TEST_F(KeyboardPassthroughTest, IgnoresRepeatedPressesAndUnmatchedReleases) {
release(VKEY_Z);
EXPECT_TRUE(taken().empty());
press(VKEY_Z);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_Z)}));
press(VKEY_Z);
EXPECT_TRUE(taken().empty());
release(VKEY_Z);
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_Z)}));
release(VKEY_Z);
EXPECT_TRUE(taken().empty());
}
TEST_F(KeyboardPassthroughTest, EmitsRemappedKeycodesFromKeybindings) {
config::input.keybindings = {
{VKEY_LWIN, VKEY_LMENU},
{VKEY_RWIN, VKEY_RMENU},
{VKEY_LMENU, VKEY_LWIN},
{VKEY_RMENU, VKEY_RWIN},
};
const std::array<std::pair<std::uint16_t, std::uint16_t>, 4> expected {{
{VKEY_LWIN, VKEY_LMENU},
{VKEY_RWIN, VKEY_RMENU},
{VKEY_LMENU, VKEY_LWIN},
{VKEY_RMENU, VKEY_RWIN},
}};
for (const auto &[client, host] : expected) {
SCOPED_TRACE(hex(client));
press(client);
release(client);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(host), released(host)}));
}
// Unmapped keys are untouched.
press(VKEY_A);
release(VKEY_A);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_A), released(VKEY_A)}));
}
TEST_F(KeyboardPassthroughTest, DoesNotInjectSyntheticAltWhenAltIsRemappedToMeta) {
// Regression test for the Cmd+Space report: a macOS host swaps Super and Alt so a PC keyboard
// gets positional Option and Command. shortcutFlags is compared against the client's modifier
// bitmask, so it has to track the client keycode. Tracking the remapped host keycode instead
// left the ALT bit clear and wrapped every following key in a real VKEY_MENU press.
config::input.keybindings = {
{VKEY_LWIN, VKEY_LMENU},
{VKEY_RWIN, VKEY_RMENU},
{VKEY_LMENU, VKEY_LWIN},
{VKEY_RMENU, VKEY_RWIN},
};
press(VKEY_LMENU, MODIFIER_ALT);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_LWIN)}));
// Cmd+Space must stay Cmd+Space; a spurious real Alt shows up here as +0x12 / -0x12.
press(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_SPACE, MODIFIER_ALT);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_SPACE), released(VKEY_SPACE)}));
// Every other key while the remapped modifier is held behaves the same way.
for (const auto &key : qwerty_ascii_keys) {
SCOPED_TRACE("character key " + hex(key.key_code));
press(key.key_code, MODIFIER_ALT);
release(key.key_code, MODIFIER_ALT);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(key.key_code), released(key.key_code)}));
}
release(VKEY_LMENU, 0);
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_LWIN)}));
// Releasing the remapped Alt clears the tracked ALT bit, so a client that still claims Alt
// gets the synthetic modifier back.
press(VKEY_A, MODIFIER_ALT);
EXPECT_EQ(
taken(),
(std::vector<std::string> {pressed(VKEY_MENU), pressed(VKEY_A), released(VKEY_MENU)})
);
}
TEST_F(KeyboardPassthroughTest, DoesNotInjectSyntheticAltWhenRightAltIsRemappedToMeta) {
config::input.keybindings = {{VKEY_RMENU, VKEY_RWIN}};
press(VKEY_RMENU, MODIFIER_ALT);
press(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_RMENU, 0);
EXPECT_EQ(
taken(),
(std::vector<std::string> {
pressed(VKEY_RWIN),
pressed(VKEY_SPACE),
released(VKEY_SPACE),
released(VKEY_RWIN)
})
);
}
TEST_F(KeyboardPassthroughTest, DoesNotInjectSyntheticAltForKeyRightaltToKeyWin) {
// config.cpp installs this keybinding when the option is enabled.
config::input.key_rightalt_to_key_win = true;
config::input.keybindings = {{VKEY_RMENU, VKEY_LWIN}};
press(VKEY_RMENU, MODIFIER_ALT);
press(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_RMENU, 0);
EXPECT_EQ(
taken(),
(std::vector<std::string> {
pressed(VKEY_LWIN),
pressed(VKEY_SPACE),
released(VKEY_SPACE),
released(VKEY_LWIN)
})
);
}
TEST_F(KeyboardPassthroughTest, KeepsRealAltWhileRightAltMapsToKeyWin) {
config::input.key_rightalt_to_key_win = true;
config::input.keybindings = {{VKEY_RMENU, VKEY_LWIN}};
// Left Alt is a real Alt, so the ALT bit must not be masked away while it is held.
press(VKEY_LMENU, MODIFIER_ALT);
press(VKEY_RMENU, MODIFIER_ALT);
press(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_SPACE, MODIFIER_ALT);
EXPECT_EQ(
taken(),
(std::vector<std::string> {
pressed(VKEY_LMENU),
pressed(VKEY_LWIN),
pressed(VKEY_SPACE),
released(VKEY_SPACE)
})
);
// Right Alt (mapped to key_win) goes up while left Alt is still held. The shared ALT bit
// is only shared, not owned by either side, so it must stay set: the next key must reach
// the host as-is, with no synthetic Alt press/release wrapped around it.
release(VKEY_RMENU, MODIFIER_ALT);
press(VKEY_B, MODIFIER_ALT);
release(VKEY_B, MODIFIER_ALT);
EXPECT_EQ(
taken(),
(std::vector<std::string> {released(VKEY_LWIN), pressed(VKEY_B), released(VKEY_B)})
);
release(VKEY_LMENU, 0);
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_LMENU)}));
// With right Alt released and left Alt gone too, the client's ALT claim is honored again.
press(VKEY_A, MODIFIER_ALT);
EXPECT_EQ(
taken(),
(std::vector<std::string> {pressed(VKEY_MENU), pressed(VKEY_A), released(VKEY_MENU)})
);
}
TEST_F(KeyboardPassthroughTest, KeepsShiftAndControlSetWhileTheirOtherSideRemainsHeld) {
/**
* @brief Modifier key codes and client bit used by one test case.
*/
struct modifier_case_t {
std::uint16_t generic; ///< Side-less modifier key code used for synthetic events.
std::uint16_t left; ///< Left modifier key code that remains held.
std::uint16_t right; ///< Right modifier key code remapped away from the modifier.
unsigned bit; ///< Client modifier bit reported with keyboard packets.
const char *name; ///< Modifier name used in assertion traces.
};
constexpr std::array cases {
modifier_case_t {VKEY_SHIFT, VKEY_LSHIFT, VKEY_RSHIFT, MODIFIER_SHIFT, "shift"},
modifier_case_t {VKEY_CONTROL, VKEY_LCONTROL, VKEY_RCONTROL, MODIFIER_CTRL, "control"},
};
for (const auto &modifier : cases) {
SCOPED_TRACE(modifier.name);
config::input.keybindings = {{modifier.right, VKEY_LWIN}};
press(modifier.left, modifier.bit);
press(modifier.right, modifier.bit);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(modifier.left), pressed(VKEY_LWIN)}));
// Releasing the remapped right key must not clear the aggregate flag while the left key
// remains held, otherwise the next key is wrapped in a synthetic modifier press/release.
release(modifier.right, modifier.bit);
press(VKEY_B, modifier.bit);
release(VKEY_B, modifier.bit);
EXPECT_EQ(
taken(),
(std::vector<std::string> {released(VKEY_LWIN), pressed(VKEY_B), released(VKEY_B)})
);
release(modifier.left, 0);
EXPECT_EQ(taken(), (std::vector<std::string> {released(modifier.left)}));
// Once every real modifier key is released, a client-only modifier claim is synthetic.
press(VKEY_A, modifier.bit);
EXPECT_EQ(
taken(),
(std::vector<std::string> {pressed(modifier.generic), pressed(VKEY_A), released(modifier.generic)})
);
release(VKEY_A, modifier.bit);
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_A)}));
}
}
TEST_F(KeyboardPassthroughTest, SwallowsDisplaySwitchShortcutWhileAllModifiersAreHeld) {
press(VKEY_LCONTROL, MODIFIER_CTRL);
press(VKEY_LMENU, MODIFIER_CTRL | MODIFIER_ALT);
press(VKEY_LSHIFT, MODIFIER_CTRL | MODIFIER_ALT | MODIFIER_SHIFT);
EXPECT_EQ(
taken(),
(std::vector<std::string> {pressed(VKEY_LCONTROL), pressed(VKEY_LMENU), pressed(VKEY_LSHIFT)})
);
const std::uint8_t all = MODIFIER_CTRL | MODIFIER_ALT | MODIFIER_SHIFT;
// F1 is the display-switch shortcut and must not reach the host.
press(VKEY_F1, all);
EXPECT_TRUE(taken().empty());
// A key with no shortcut still passes through.
press(VKEY_A, all);
release(VKEY_A, all);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_A), released(VKEY_A)}));
}
TEST_F(KeyboardPassthroughTest, DeliversKeysThroughTheVirtualKeyboard) {
// Drop the recorder so the events travel the real path into libvirtualhid's fake backend.
// This keeps the recorder honest: if platf::keyboard_update stopped being reached, every
// other test here would still pass.
input::testing::set_keyboard_sink({});
auto &keyboard = fake_keyboard();
const auto submitted = keyboard.submit_count();
press(VKEY_A);
EXPECT_EQ(keyboard.submit_count(), submitted + 1);
EXPECT_EQ(keyboard.last_submitted_event().key_code, VKEY_A);
EXPECT_TRUE(keyboard.last_submitted_event().pressed);
release(VKEY_A);
EXPECT_EQ(keyboard.submit_count(), submitted + 2);
EXPECT_EQ(keyboard.last_submitted_event().key_code, VKEY_A);
EXPECT_FALSE(keyboard.last_submitted_event().pressed);
config::input.keybindings = {{VKEY_LMENU, VKEY_LWIN}};
press(VKEY_LMENU, MODIFIER_ALT);
EXPECT_EQ(keyboard.submit_count(), submitted + 3);
EXPECT_EQ(keyboard.last_submitted_event().key_code, VKEY_LWIN);
// While the remapped Alt is held, a spurious synthetic Alt would add a press and a release
// around the key, so both the count and the last event change if the regression comes back.
press(VKEY_SPACE, MODIFIER_ALT);
EXPECT_EQ(keyboard.submit_count(), submitted + 4);
EXPECT_EQ(keyboard.last_submitted_event().key_code, VKEY_SPACE);
EXPECT_TRUE(keyboard.last_submitted_event().pressed);
release(VKEY_SPACE, MODIFIER_ALT);
release(VKEY_LMENU, 0);
EXPECT_EQ(keyboard.submit_count(), submitted + 6);
// With no Alt held, the client's Alt claim is honored with a real synthetic modifier.
press(VKEY_B, MODIFIER_ALT);
EXPECT_EQ(keyboard.submit_count(), submitted + 9);
EXPECT_EQ(keyboard.last_submitted_event().key_code, VKEY_MENU);
EXPECT_FALSE(keyboard.last_submitted_event().pressed);
}
TEST_F(KeyboardPassthroughTest, ReleasesHeldKeysAsThemselvesWithoutKeybindings) {
press(VKEY_LMENU, MODIFIER_ALT);
press(VKEY_A, MODIFIER_ALT);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_LMENU), pressed(VKEY_A)}));
input::testing::release_held_keys();
// key_press is an unordered_map, so the release order is unspecified.
auto released_keys = taken();
std::ranges::sort(released_keys);
std::vector expected {released(VKEY_LMENU), released(VKEY_A)};
std::ranges::sort(expected);
EXPECT_EQ(released_keys, expected);
}
TEST_F(KeyboardPassthroughTest, ReleasesTheRemappedHostKeyWhenTheClientDisconnects) {
config::input.keybindings = {{VKEY_LMENU, VKEY_LWIN}};
// The press goes down as the remapped host key.
press(VKEY_LMENU, MODIFIER_ALT);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_LWIN)}));
input::testing::release_held_keys();
// key_press is keyed on the unmapped 0xA4, so releasing that would leave the host holding
// the remapped key down forever.
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_LWIN)}));
}
TEST_F(KeyboardPassthroughTest, ReleasesTheRemappedRightAltForKeyRightaltToKeyWin) {
// Mirrors what config parsing installs for the shipped key_rightalt_to_key_win option.
config::input.key_rightalt_to_key_win = true;
config::input.keybindings = {{VKEY_RMENU, VKEY_LWIN}};
press(VKEY_RMENU, MODIFIER_ALT);
EXPECT_EQ(taken(), (std::vector<std::string> {pressed(VKEY_LWIN)}));
input::testing::release_held_keys();
EXPECT_EQ(taken(), (std::vector<std::string> {released(VKEY_LWIN)}));
}