diff --git a/cmake/compile_definitions/windows.cmake b/cmake/compile_definitions/windows.cmake index 8e79406d829..996b3b24f88 100644 --- a/cmake/compile_definitions/windows.cmake +++ b/cmake/compile_definitions/windows.cmake @@ -67,6 +67,8 @@ set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/misc.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/input.cpp" + "${CMAKE_SOURCE_DIR}/src/platform/windows/session_input.h" + "${CMAKE_SOURCE_DIR}/src/platform/windows/session_input.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/display.h" "${CMAKE_SOURCE_DIR}/src/platform/windows/display_base.cpp" "${CMAKE_SOURCE_DIR}/src/platform/windows/display_vram.cpp" diff --git a/docs/api.md b/docs/api.md index 9ba23241589..7b870672f53 100644 --- a/docs/api.md +++ b/docs/api.md @@ -58,6 +58,9 @@ curl -u user:pass -H "X-CSRF-Token: your_token_here" \ ## POST /api/clients/unpair @copydoc confighttp::unpair() +## GET /api/streams +@copydoc confighttp::getActiveStreams() + ## POST /api/clients/unpair-all @copydoc confighttp::unpairAll() diff --git a/src/confighttp.cpp b/src/confighttp.cpp index 0e22f58d59c..89966c351e1 100644 --- a/src/confighttp.cpp +++ b/src/confighttp.cpp @@ -1338,6 +1338,41 @@ namespace confighttp { send_response(response, output_tree); } + /** + * @brief Get how many clients are streaming right now. + * @param response The HTTP response object. + * @param request The HTTP request object. + * + * The response counts Moonlight sessions and Moonlight WebRTC TV sessions separately: + * @code{.json} + * { + * "moonlight": 1, + * "webrtc": 0, + * "total": 1, + * "status": true + * } + * @endcode + * + * @api_examples{/api/streams|:| GET|:| null} + */ + void getActiveStreams(const resp_https_t &response, const req_https_t &request) { + if (!authenticate(response, request)) { + return; + } + + print_req(request); + + const int moonlight = rtsp_stream::session_count(); + const int webrtc = webrtc_stream::session_count(); + + nlohmann::json output_tree; + output_tree["moonlight"] = moonlight; + output_tree["webrtc"] = webrtc; + output_tree["total"] = moonlight + webrtc; + output_tree["status"] = true; + send_response(response, output_tree); + } + /** * @brief Enable or disable a client. * @param response The HTTP response object. @@ -2525,6 +2560,7 @@ namespace confighttp { server.resource["^/api/apps/([0-9]+)$"]["DELETE"] = deleteApp; server.resource["^/api/apps/close$"]["POST"] = closeApp; server.resource["^/api/clients/list$"]["GET"] = getClients; + server.resource["^/api/streams$"]["GET"] = getActiveStreams; server.resource["^/api/clients/unpair$"]["POST"] = unpair; server.resource["^/api/clients/unpair-all$"]["POST"] = unpairAll; server.resource["^/api/clients/update$"]["POST"] = updateClient; diff --git a/src/confighttp.h b/src/confighttp.h index 1b8e9b2ff56..acd43d8f85b 100644 --- a/src/confighttp.h +++ b/src/confighttp.h @@ -182,6 +182,14 @@ namespace confighttp { */ void getPermissions(const resp_https_t &response, const req_https_t &request); + /** + * @brief Report how many clients are streaming right now. + * + * @param response HTTPS response receiving the stream counts JSON. + * @param request Authenticated HTTPS request. + */ + void getActiveStreams(const resp_https_t &response, const req_https_t &request); + /** * @brief Initiate a native permission request from the Web UI. * diff --git a/src/platform/virtualhid_input.cpp b/src/platform/virtualhid_input.cpp index 7e255df5231..dc6e592b65c 100644 --- a/src/platform/virtualhid_input.cpp +++ b/src/platform/virtualhid_input.cpp @@ -23,6 +23,10 @@ #include "src/logging.h" #include "virtualhid_input.h" +#ifdef _WIN32 + #include "windows/session_input.h" +#endif + using namespace std::literals; namespace platf::virtualhid { @@ -1152,34 +1156,82 @@ namespace platf { #endif void move_mouse(input_t &input, int deltaX, int deltaY) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::move_mouse(deltaX, deltaY); + return; + } +#endif virtualhid::move_mouse(virtualhid::get_input_context(input), deltaX, deltaY); } void move_mouse(input_t &input, const touch_port_t &touch_port, int deltaX, int deltaY) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::move_mouse(deltaX, deltaY); + return; + } +#endif virtualhid::move_mouse(virtualhid::get_input_context(input), touch_port, deltaX, deltaY); } void abs_mouse(input_t &input, const touch_port_t &touch_port, float x, float y) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::abs_mouse(touch_port, x, y); + return; + } +#endif virtualhid::abs_mouse(virtualhid::get_input_context(input), touch_port, x, y); } void button_mouse(input_t &input, int button, bool release) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::button_mouse(button, release); + return; + } +#endif virtualhid::button_mouse(virtualhid::get_input_context(input), button, release); } void scroll(input_t &input, int high_res_distance) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::scroll(high_res_distance); + return; + } +#endif virtualhid::scroll(virtualhid::get_input_context(input), high_res_distance); } void hscroll(input_t &input, int high_res_distance) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::hscroll(high_res_distance); + return; + } +#endif virtualhid::hscroll(virtualhid::get_input_context(input), high_res_distance); } void keyboard_update(input_t &input, uint16_t modcode, bool release, uint8_t flags, bool extended) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::keyboard_update(modcode, release, flags, extended); + return; + } +#endif virtualhid::keyboard_update(virtualhid::get_input_context(input), modcode, release, flags, extended); } void unicode(input_t &input, const char *utf8, int size) { +#ifdef _WIN32 + if (session_input::should_use()) { + session_input::unicode(utf8, size); + return; + } +#endif virtualhid::unicode(virtualhid::get_input_context(input), utf8, size); } diff --git a/src/platform/windows/session_input.cpp b/src/platform/windows/session_input.cpp new file mode 100644 index 00000000000..1ea2e432ca7 --- /dev/null +++ b/src/platform/windows/session_input.cpp @@ -0,0 +1,249 @@ +/** + * @file src/platform/windows/session_input.cpp + * @brief Session-local keyboard and mouse injection for non-console sessions. + */ +// standard includes +#include +#include +#include +#include +#include +#include +#include + +// platform includes +#include + +// local includes +#include "misc.h" +#include "session_input.h" +#include "src/config.h" +#include "src/logging.h" +#include "third-party/libvirtualhid/src/platform/windows/keylayout.hpp" + +using namespace std::literals; + +namespace platf::session_input { + namespace { + /** + * @brief Input desktop last bound to this thread, used to retry SendInput after a desktop switch. + */ + thread_local HDESK last_input_desktop = nullptr; + + /** + * @brief Call SendInput() and retry once on the new input desktop (UAC, lock screen). + * + * @param inputs Events to inject. + */ + void send(std::span inputs) { + const auto count = static_cast(inputs.size()); + if (SendInput(count, inputs.data(), sizeof(INPUT)) == count) { + return; + } + + if (const auto desktop = syncThreadDesktop(); desktop != last_input_desktop) { + last_input_desktop = desktop; + if (SendInput(count, inputs.data(), sizeof(INPUT)) == count) { + return; + } + } + + BOOST_LOG(error) << "Couldn't send session-local input: "sv << GetLastError(); + } + + void send(INPUT input) { + std::array inputs {input}; + send(std::span {inputs}); + } + + INPUT mouse_input(DWORD flags, LONG dx = 0, LONG dy = 0, DWORD data = 0) { + INPUT input {}; + input.type = INPUT_MOUSE; + input.mi.dwFlags = flags; + input.mi.dx = dx; + input.mi.dy = dy; + input.mi.mouseData = data; + return input; + } + + /** + * @brief Scale a coordinate inside [0, dimension] to the 0..65535 range used by MOUSEEVENTF_ABSOLUTE. + */ + LONG scale_absolute_axis(float value, int dimension) { + if (dimension <= 1) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(dimension)); + return static_cast(std::lround(clamped * static_cast(std::numeric_limits::max()) / static_cast(dimension))); + } + + /** + * @brief Map a viewport-relative coordinate into the virtual desktop (same math as libvirtualhid's Win32 mouse). + */ + LONG scale_to_desktop(float value, int viewport_offset, int viewport_dimension, int desktop_offset, int desktop_dimension) { + if (viewport_dimension <= 0 || desktop_dimension <= 0) { + return 0; + } + + const auto clamped = std::clamp(value, 0.0F, static_cast(viewport_dimension)); + const auto span = static_cast(std::max(viewport_dimension - 1, 0)); + const auto target = static_cast(viewport_offset - desktop_offset) + clamped * span / static_cast(viewport_dimension); + return scale_absolute_axis(target, desktop_dimension); + } + + bool extended_key(std::uint16_t key_code) { + switch (key_code) { + 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: + return true; + default: + return false; + } + } + } // namespace + + bool should_use() { + static const DWORD own_session = [] { + DWORD session = 0; + ProcessIdToSessionId(GetCurrentProcessId(), &session); + return session; + }(); + + // The console can move between sessions while streaming (e.g. someone logs + // in locally), so compare on every event. 0xFFFFFFFF means "no console + // session right now": SendInput is still the right choice then. + const bool bypass = WTSGetActiveConsoleSessionId() != own_session; + + static std::atomic logged {-1}; + if (const int state = bypass ? 1 : 0; logged.exchange(state) != state) { + if (bypass) { + BOOST_LOG(info) << "Sunshine runs outside the console session ("sv << own_session + << "): mouse and keyboard use SendInput instead of Virtual HID devices"sv; + } else { + BOOST_LOG(info) << "Sunshine runs in the console session: mouse and keyboard use libvirtualhid"sv; + } + } + return bypass; + } + + void move_mouse(int delta_x, int delta_y) { + send(mouse_input(MOUSEEVENTF_MOVE, delta_x, delta_y)); + } + + void abs_mouse(const touch_port_t &touch_port, float x, float y) { + // Same desktop/viewport split as virtualhid::pointer_viewports(): the + // desktop is the whole virtual screen, the viewport the streamed display. + const bool has_logical = touch_port.logical_width > 0 && touch_port.logical_height > 0; + const int viewport_x = has_logical ? touch_port.offset_x : touch_port.env_offset_x; + const int viewport_y = has_logical ? touch_port.offset_y : touch_port.env_offset_y; + const int viewport_w = has_logical ? touch_port.logical_width : touch_port.width; + const int viewport_h = has_logical ? touch_port.logical_height : touch_port.height; + + const auto dx = scale_to_desktop(x - static_cast(viewport_x), viewport_x, viewport_w, touch_port.env_offset_x, touch_port.width); + const auto dy = scale_to_desktop(y - static_cast(viewport_y), viewport_y, viewport_h, touch_port.env_offset_y, touch_port.height); + send(mouse_input(MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK, dx, dy)); + } + + void button_mouse(int button, bool release) { + switch (button) { + case BUTTON_LEFT: + send(mouse_input(release ? MOUSEEVENTF_LEFTUP : MOUSEEVENTF_LEFTDOWN)); + return; + case BUTTON_MIDDLE: + send(mouse_input(release ? MOUSEEVENTF_MIDDLEUP : MOUSEEVENTF_MIDDLEDOWN)); + return; + case BUTTON_RIGHT: + send(mouse_input(release ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_RIGHTDOWN)); + return; + case BUTTON_X1: + send(mouse_input(release ? MOUSEEVENTF_XUP : MOUSEEVENTF_XDOWN, 0, 0, XBUTTON1)); + return; + case BUTTON_X2: + send(mouse_input(release ? MOUSEEVENTF_XUP : MOUSEEVENTF_XDOWN, 0, 0, XBUTTON2)); + return; + default: + BOOST_LOG(warning) << "Unknown mouse button: "sv << button; + return; + } + } + + void scroll(int high_res_distance) { + send(mouse_input(MOUSEEVENTF_WHEEL, 0, 0, static_cast(high_res_distance))); + } + + void hscroll(int high_res_distance) { + send(mouse_input(MOUSEEVENTF_HWHEEL, 0, 0, static_cast(high_res_distance))); + } + + void keyboard_update(std::uint16_t modcode, bool release, std::uint8_t flags, bool extended) { + INPUT input {}; + input.type = INPUT_KEYBOARD; + auto &ki = input.ki; + ki.wVk = modcode; + + // Same rules as libvirtualhid's Win32 keyboard: normalized VK codes map + // through the US English layout; otherwise only map when asked to. + if (!(flags & SS_KBE_FLAG_NON_NORMALIZED)) { + ki.wScan = lvh::detail::windows_us_english_scan_code(modcode); + } else if (config::input.always_send_scancodes && modcode != VK_LWIN && modcode != VK_RWIN && modcode != VK_PAUSE) { + ki.wScan = static_cast(MapVirtualKeyW(modcode, MAPVK_VK_TO_VSC)); + } + + if (ki.wScan != 0) { + ki.wVk = 0; + ki.dwFlags |= KEYEVENTF_SCANCODE; + } + if (extended || extended_key(modcode)) { + ki.dwFlags |= KEYEVENTF_EXTENDEDKEY; + } + if (release) { + ki.dwFlags |= KEYEVENTF_KEYUP; + } + + send(input); + } + + void unicode(const char *utf8, int size) { + if (!utf8 || size <= 0) { + return; + } + + const auto chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, size, nullptr, 0); + if (chars <= 0) { + return; + } + + std::vector wide(static_cast(chars)); + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, utf8, size, wide.data(), chars) <= 0) { + return; + } + + std::vector inputs; + inputs.reserve(wide.size() * 2); + for (const auto release : {false, true}) { + for (const auto ch : wide) { + INPUT input {}; + input.type = INPUT_KEYBOARD; + input.ki.wScan = ch; + input.ki.dwFlags = KEYEVENTF_UNICODE | (release ? KEYEVENTF_KEYUP : 0); + inputs.push_back(input); + } + } + send(std::span {inputs}); + } +} // namespace platf::session_input diff --git a/src/platform/windows/session_input.h b/src/platform/windows/session_input.h new file mode 100644 index 00000000000..c3444153fbb --- /dev/null +++ b/src/platform/windows/session_input.h @@ -0,0 +1,36 @@ +/** + * @file src/platform/windows/session_input.h + * @brief Session-local keyboard and mouse injection for non-console sessions. + * + * libvirtualhid prefers a driver-backed HID mouse and keyboard when the + * Virtual HID Driver is installed and licensed. Windows routes physical (HID) + * input to the session attached to the console, so when Sunshine runs inside + * an RDP session the host console receives the client's mouse and keyboard. + * SendInput() injects into the caller's own session, so these helpers bypass + * libvirtualhid for mouse and keyboard whenever Sunshine is not running in + * the active console session. + */ +#pragma once + +// standard includes +#include + +// local includes +#include "src/platform/common.h" + +namespace platf::session_input { + /** + * @brief Whether mouse and keyboard must bypass driver-backed HID devices. + * + * @return true when the current process is not in the active console session. + */ + bool should_use(); + + void move_mouse(int delta_x, int delta_y); + void abs_mouse(const touch_port_t &touch_port, float x, float y); + void button_mouse(int button, bool release); + void scroll(int high_res_distance); + void hscroll(int high_res_distance); + void keyboard_update(std::uint16_t modcode, bool release, std::uint8_t flags, bool extended); + void unicode(const char *utf8, int size); +} // namespace platf::session_input diff --git a/tests/unit/test_confighttp.cpp b/tests/unit/test_confighttp.cpp index 2f7319b6922..3cb9a915140 100644 --- a/tests/unit/test_confighttp.cpp +++ b/tests/unit/test_confighttp.cpp @@ -337,6 +337,7 @@ class ConfigHttpTest: public BaseTest { // NOSONAR(cpp:S3656): protected member server->resource["^/virtual-input-license-test$"]["GET"] = confighttp::getVirtualInputLicense; server->resource["^/virtual-input-license-test$"]["POST"] = confighttp::updateVirtualInputLicense; server->resource["^/permissions-test$"]["GET"] = confighttp::getPermissions; + server->resource["^/streams-test$"]["GET"] = confighttp::getActiveStreams; server->resource["^/permissions-test$"]["POST"] = confighttp::requestPermission; server->resource["^/pairing-test$"]["DELETE"] = confighttp::cancelPairing; server->resource["^/pairing-test$"]["GET"] = confighttp::getPendingPairings; @@ -512,6 +513,7 @@ INSTANTIATE_TEST_SUITE_P( endpoint_request_t {"CsrfToken", "GET", "/csrf-token-test", ""}, endpoint_request_t {"BrowseDirectory", "GET", "/browse-test", ""}, endpoint_request_t {"PairingList", "GET", "/pairing-test", ""}, + endpoint_request_t {"ActiveStreams", "GET", "/streams-test", ""}, endpoint_request_t {"PortalTokenReset", "POST", "/portal-token-reset-test", ""}, endpoint_request_t {"VirtualInputStatus", "GET", "/virtual-input-status-test", ""}, endpoint_request_t {"VirtualInputLicense", "GET", "/virtual-input-license-test", ""}, @@ -1759,6 +1761,20 @@ TEST_F(ConfigHttpTest, PermissionsEndpointReturnsRequiredAndOptionalStatuses) { EXPECT_FALSE(body.at("permissions").at(1).at("required").get()); } +TEST_F(ConfigHttpTest, ActiveStreamsEndpointReportsMoonlightAndWebRtcCounts) { + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Authorization", create_auth_header("testuser", "testpass")); + + const auto response = client->request("GET", "/streams-test", "", headers); + ASSERT_EQ(response->status_code, "200 OK"); + assert_security_headers(response); + const auto body = nlohmann::json::parse(response->content.string()); + EXPECT_TRUE(body.at("status").get()); + EXPECT_EQ(body.at("moonlight").get(), 0); + EXPECT_EQ(body.at("webrtc").get(), 0); + EXPECT_EQ(body.at("total").get(), 0); +} + TEST_F(ConfigHttpTest, PermissionsEndpointRejectsMalformedAndUnknownRequests) { SimpleWeb::CaseInsensitiveMultimap headers; headers.emplace("Content-Type", "application/json");