--- a/app/streaming/session.cpp +++ b/app/streaming/session.cpp @@ -1,3 +1,4 @@ +#include "vdi_native_cursor.h" #include "session.h" #include "settings/streamingpreferences.h" #include "streaming/streamutils.h" @@ -1968,8 +1969,10 @@ VdiInputTiming vdiInputTiming(qEnvironmentVariableIntValue("VDI_INPUT_TIMING") == 1); SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "VDI input priority: %s", qEnvironmentVariableIntValue("VDI_INPUT_PRIORITY") == 1 ? "enabled" : "disabled"); + VdiNativeCursor vdiNativeCursor(m_Window, m_InputHandler); SDL_Event event; for (;;) { + if (!vdiNativeCursor.tick()) goto DispatchDeferredCleanup; if (!vdiInputPriority.takeDeferred(event, SDL_GetWindowID(m_Window))) { #if SDL_VERSION_ATLEAST(2, 0, 18) && !defined(STEAM_LINK) // SDL 2.0.18 has a proper wait event implementation that uses platform @@ -1981,7 +1984,7 @@ // NB: This behavior was introduced in SDL 2.0.16, but had a few critical // issues that could cause indefinite timeouts, delayed joystick detection, // and other problems. - if (!SDL_WaitEventTimeout(&event, 1000)) { + if (!SDL_WaitEventTimeout(&event, vdiNativeCursor.enabled() ? 4 : 1000)) { presence.runCallbacks(); continue; } @@ -2006,6 +2009,7 @@ continue; } } + vdiNativeCursor.observe(event); vdiInputTiming.observe(event, SDL_GetWindowID(m_Window)); switch (event.type) { case SDL_QUIT: @@ -2322,6 +2326,7 @@ } DispatchDeferredCleanup: + vdiNativeCursor.shutdown(); vdiInputTiming.report(vdiInputPriority.prioritized()); // Switch back to synchronous logging mode StreamUtils::exitAsyncLoggingMode(); --- a/app/streaming/input/input.h +++ b/app/streaming/input/input.h @@ -89,6 +89,12 @@ ~SdlInputHandler(); void setWindow(SDL_Window* window); + + bool isNativeCursorAbsoluteMode() const { return m_AbsoluteMouseMode; } + void enableNativeCursorVisibility() { + m_MouseCursorCapturedVisibilityState = SDL_ENABLE; + SDL_ShowCursor(SDL_ENABLE); + } void handleKeyEvent(SDL_KeyboardEvent* event); --- a/app/streaming/input/keyboard.cpp +++ b/app/streaming/input/keyboard.cpp @@ -68,6 +68,7 @@ break; case KeyComboToggleMouseMode: + if (SDL_getenv("VDI_NATIVE_CURSOR_STATE")) break; SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Detected mouse mode toggle combo"); @@ -82,6 +83,7 @@ break; case KeyComboToggleCursorHide: + if (SDL_getenv("VDI_NATIVE_CURSOR_STATE")) break; SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, "Detected show mouse combo"); --- /dev/null +++ b/app/streaming/vdi_native_cursor.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include +#include +#include "input/input.h" +#include "streamutils.h" + +// Linux X11 single-seat native cursor. Called only by the SDL main thread. +// The producer starts cursor-free before this session's first captured frame. +class VdiNativeCursor { +public: + VdiNativeCursor(SDL_Window* window, SdlInputHandler* input) : + m_Window(window), m_Input(input), m_Path(qgetenv("VDI_NATIVE_CURSOR_STATE")), + m_Epoch(qgetenv("VDI_NATIVE_CURSOR_EPOCH").toULongLong()) {} + + bool enabled() const { return !m_Path.isEmpty(); } + + void observe(const SDL_Event& event) { + if (event.type == SDL_MOUSEMOTION || event.type == SDL_MOUSEBUTTONDOWN || + event.type == SDL_MOUSEBUTTONUP) { + m_LastInput = nowNs(); + ++m_InputSequence; + } + } + + bool tick() { + if (!enabled()) return true; + if (QByteArray(SDL_GetCurrentVideoDriver()) != "x11") return fail("requires X11 cursor backend"); + const quint64 pollTime = nowNs(); + if (pollTime - m_LastPoll < 4000000) return true; + m_LastPoll = pollTime; + QFile f(QString::fromUtf8(m_Path)); + if (!f.open(QIODevice::ReadOnly) || f.size() < 76 || f.size() > 65608) + return fail("missing or oversized state"); + const QByteArray packet = f.readAll(); + // The producer may atomically replace state after pollTime. Compare + // against a timestamp taken AFTER the read, never before opening it. + const quint64 now = nowNs(); + const auto* p = reinterpret_cast(packet.constData()); + auto u32 = [p](int offset) { return qFromLittleEndian(p + offset); }; + auto u64 = [p](int offset) { return qFromLittleEndian(p + offset); }; + if (packet.left(8) != QByteArray("VDICUR1\0", 8) || u64(8) != m_Epoch || + u64(16) < m_Sequence || u64(24) > now || now - u64(24) > 2000000000) + return fail("stale or mismatched lease"); + const int w = u32(32), h = u32(36), hx = u32(40), hy = u32(44); + const int x = qint32(u32(48)), y = qint32(u32(52)); + const int sw = u32(56), sh = u32(60); + if (w < 1 || w > 128 || h < 1 || h > 128 || hx < 0 || hx >= w || + hy < 0 || hy >= h || sw != 2560 || sh != 1440 || + x < 0 || x >= sw || y < 0 || y >= sh || packet.size() != 72 + w*h*4 || + !m_Input->isNativeCursorAbsoluteMode()) + return fail("unsupported shape, display or input mode"); + m_Sequence = u64(16); + int ww, wh; + SDL_GetWindowSize(m_Window, &ww, &wh); + SDL_Rect src = {0, 0, sw, sh}, dst = {0, 0, ww, wh}; + StreamUtils::scaleSourceToDestinationSurface(&src, &dst); + if (dst.w <= 0 || dst.h <= 0) return fail("empty viewport"); + const int cw = qMax(1, qRound(double(w) * dst.w / sw)); + const int ch = qMax(1, qRound(double(h) * dst.h / sh)); + if (cw > 256 || ch > 256) return fail("oversized scaled cursor"); + const quint64 acknowledged = u64(64 + w*h*4); + const QByteArray pixels = packet.mid(64, w*h*4); + if (pixels != m_Pixels || w != m_Width || h != m_Height || hx != m_HotX || + hy != m_HotY || dst.w != m_ViewWidth || dst.h != m_ViewHeight) { + QImage image(reinterpret_cast(pixels.constData()), w, h, + w * 4, QImage::Format_RGBA8888); + image = image.scaled(cw, ch, Qt::IgnoreAspectRatio, Qt::FastTransformation); + // SDL2's X11 Xcursor backend copies pixels without premultiplying. + // XRender requires premultiplied channels, including antialiased edges. + image = image.convertToFormat(QImage::Format_RGBA8888_Premultiplied); + SDL_Surface* surface = SDL_CreateRGBSurfaceWithFormatFrom( + image.bits(), cw, ch, 32, image.bytesPerLine(), SDL_PIXELFORMAT_RGBA32); + if (!surface) return fail("SDL surface failed"); + SDL_Cursor* next = SDL_CreateColorCursor(surface, + qMin(cw - 1, qRound(double(hx) * dst.w / sw)), + qMin(ch - 1, qRound(double(hy) * dst.h / sh))); + SDL_FreeSurface(surface); + if (!next) return fail("SDL cursor failed"); + SDL_SetCursor(next); + if (m_Cursor) SDL_FreeCursor(m_Cursor); + m_Cursor = next; + m_Pixels = pixels; + m_Width = w; m_Height = h; m_HotX = hx; m_HotY = hy; + m_ViewWidth = dst.w; m_ViewHeight = dst.h; + SDL_LogInfo(SDL_LOG_CATEGORY_APPLICATION, + "VDI native shape: %dx%d hotspot=%d,%d sequence=%llu", + w, h, hx, hy, (unsigned long long)m_Sequence); + } + int mx, my; + const auto buttons = SDL_GetMouseState(&mx, &my); + const bool inside = mx >= dst.x && my >= dst.y && mx < dst.x + dst.w && my < dst.y + dst.h; + const bool focused = SDL_GetKeyboardFocus() == m_Window; + if (m_PublishedInput != m_InputSequence) { + QByteArray message(16, '\0'); + qToLittleEndian(m_InputSequence, reinterpret_cast(message.data())); + qToLittleEndian(qBound(0, (mx-dst.x)*sw/dst.w, sw-1), + reinterpret_cast(message.data()+8)); + qToLittleEndian(qBound(0, (my-dst.y)*sh/dst.h, sh-1), + reinterpret_cast(message.data()+12)); + QSaveFile outgoing(QString::fromUtf8(m_Path)+".input"); + if (!outgoing.open(QIODevice::WriteOnly) || outgoing.write(message) != 16 || !outgoing.commit()) + return fail("input acknowledgement file failed"); + m_PublishedInput = m_InputSequence; + } + m_Input->enableNativeCursorVisibility(); + SDL_SetCursor(focused && inside ? m_Cursor : SDL_GetDefaultCursor()); + // Initial alignment, and host-initiated warps only after local input has + // settled. Never pull the pointer back using delayed motion echoes. + if (focused && inside && !buttons && acknowledged == m_InputSequence && + now - m_LastInput > 500000000) { + const int tx = dst.x + qRound(double(x) * dst.w / sw); + const int ty = dst.y + qRound(double(y) * dst.h / sh); + if (qAbs(tx - mx) > 2 || qAbs(ty - my) > 2) { + // SDL2's X11 in-window warp is a no-op with pointer barriers + // (used by fullscreen absolute mode). Global warp honors this + // in-viewport target while actually moving the native pointer. + int wx, wy; + SDL_GetWindowPosition(m_Window, &wx, &wy); + if (SDL_WarpMouseGlobal(wx+tx, wy+ty) < 0) + return fail("host cursor warp failed"); + m_LastInput = now; + } + } + return true; + } + + void shutdown() { + if (m_Cursor) { + SDL_SetCursor(SDL_GetDefaultCursor()); + SDL_FreeCursor(m_Cursor); + m_Cursor = nullptr; + } + } + +private: + static quint64 nowNs() { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return quint64(t.tv_sec)*1000000000 + t.tv_nsec; + } + bool fail(const char* reason) { + SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "VDI native cursor ended: %s", reason); + shutdown(); + return false; + } + SDL_Window* m_Window; + SdlInputHandler* m_Input; + QByteArray m_Path, m_Pixels; + quint64 m_Epoch, m_Sequence = 0, m_LastPoll = 0, m_LastInput = nowNs(); + quint64 m_InputSequence = 0, m_PublishedInput = 0; + SDL_Cursor* m_Cursor = nullptr; + int m_Width = 0, m_Height = 0, m_HotX = 0, m_HotY = 0; + int m_ViewWidth = 0, m_ViewHeight = 0; +};