From 199d29a6dd29c8cf9e41c281cf34c371ffc9b5b4 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Thu, 29 Oct 2026 10:43:53 +0800 Subject: [PATCH] [fix] restore remote cursors on Linux without a pointing device, refs #130 --- .../src/common/shared_cursor_state.cpp | 13 +- apps/desktop/src/common/shared_cursor_state.h | 6 +- .../src/gui/runtime/cursor_state_provider.cpp | 31 +- .../linux/common/linux_pointer_presence.cpp | 292 ++++++++++++++++++ .../linux/common/linux_pointer_presence.h | 30 ++ .../screen_capturer_wayland.cpp | 4 +- .../screen_capturer_wayland_pipewire.cpp | 8 +- .../screen_capturer_wayland_portal.cpp | 17 +- apps/desktop/xmake/targets.lua | 1 + 9 files changed, 381 insertions(+), 21 deletions(-) create mode 100644 apps/desktop/src/platform/linux/common/linux_pointer_presence.cpp create mode 100644 apps/desktop/src/platform/linux/common/linux_pointer_presence.h diff --git a/apps/desktop/src/common/shared_cursor_state.cpp b/apps/desktop/src/common/shared_cursor_state.cpp index 9c742d2b..9891bcd3 100644 --- a/apps/desktop/src/common/shared_cursor_state.cpp +++ b/apps/desktop/src/common/shared_cursor_state.cpp @@ -13,11 +13,16 @@ bool g_cursor_state_available = false; } // namespace -void PublishSharedCursorState(bool visible, RemoteCursorShape shape) { +void PublishSharedCursorState(CursorRenderMode mode, RemoteCursorShape shape) { std::lock_guard lock(g_cursor_state_mutex); ++g_cursor_state.generation; - g_cursor_state.visible = visible; - g_cursor_state.shape = visible ? shape : RemoteCursorShape::none; + g_cursor_state.visible = mode == CursorRenderMode::separate; + g_cursor_state.shape = g_cursor_state.visible ? shape : RemoteCursorShape::none; + g_cursor_state.render_mode = mode; + g_cursor_state.hidden_reason = mode == CursorRenderMode::hidden + ? CursorHiddenReason::system_hidden + : mode == CursorRenderMode::unknown ? CursorHiddenReason::sampling_failed + : CursorHiddenReason::unspecified; g_cursor_state_available = true; } @@ -38,6 +43,8 @@ void ClearSharedCursorState() { ++g_cursor_state.generation; g_cursor_state.visible = false; g_cursor_state.shape = RemoteCursorShape::none; + g_cursor_state.render_mode = CursorRenderMode::unknown; + g_cursor_state.hidden_reason = CursorHiddenReason::sampling_failed; g_cursor_state_available = false; } diff --git a/apps/desktop/src/common/shared_cursor_state.h b/apps/desktop/src/common/shared_cursor_state.h index 98877b59..9182b039 100644 --- a/apps/desktop/src/common/shared_cursor_state.h +++ b/apps/desktop/src/common/shared_cursor_state.h @@ -3,7 +3,7 @@ #include -#include +#include namespace crossdesk { @@ -11,9 +11,11 @@ struct SharedCursorState { uint64_t generation = 0; bool visible = false; RemoteCursorShape shape = RemoteCursorShape::default_cursor; + CursorRenderMode render_mode = CursorRenderMode::unknown; + CursorHiddenReason hidden_reason = CursorHiddenReason::sampling_failed; }; -void PublishSharedCursorState(bool visible, RemoteCursorShape shape); +void PublishSharedCursorState(CursorRenderMode mode, RemoteCursorShape shape); bool GetSharedCursorState(SharedCursorState* state); void ClearSharedCursorState(); diff --git a/apps/desktop/src/gui/runtime/cursor_state_provider.cpp b/apps/desktop/src/gui/runtime/cursor_state_provider.cpp index f869be11..157e4c2d 100644 --- a/apps/desktop/src/gui/runtime/cursor_state_provider.cpp +++ b/apps/desktop/src/gui/runtime/cursor_state_provider.cpp @@ -71,6 +71,7 @@ bool CursorStateProvider::Sample(const std::vector& displays, #include #include "linux_cursor_shape.h" +#include "linux_pointer_presence.h" #include "platform.h" #include "shared_cursor_state.h" #include "runtime/cursor_position.h" @@ -90,7 +91,9 @@ bool CursorHasVisiblePixel(const XFixesCursorImage& image) { } // namespace struct CursorStateProvider::Impl { + const bool wayland = IsWaylandSession(); Display* display = XOpenDisplay(nullptr); + LinuxPointerPresence pointer_presence; ~Impl() { if (display) XCloseDisplay(display); @@ -103,18 +106,26 @@ CursorStateProvider::~CursorStateProvider() = default; bool CursorStateProvider::Sample(const std::vector& displays, int preferred_display, CursorState* state) { if (!state || !impl_) return false; + *state = {}; ResetCursorPosition(state); - if (IsWaylandSession()) { - SharedCursorState shared{}; - if (GetSharedCursorState(&shared)) { - state->seq = 0; - state->visible = shared.visible; - state->shape = shared.visible ? shared.shape : RemoteCursorShape::none; - return true; + SharedCursorState shared{}; + if (impl_->wayland && GetSharedCursorState(&shared)) { + // XWayland only knows about X11 surfaces, not the captured Wayland + // desktop. Missing native metadata must remain an unavailable sample. + state->visible = shared.visible; + state->shape = shared.shape; + state->render_mode = shared.render_mode; + state->hidden_reason = shared.hidden_reason; + if (state->render_mode == CursorRenderMode::hidden && + impl_->pointer_presence.NoPointingDevice(nullptr, true)) { + state->hidden_reason = CursorHiddenReason::no_pointing_device; } + return state->render_mode != CursorRenderMode::unknown; } + // Linux may use X11 capture even in a Wayland session. Only an active + // portal publishes shared state; preserve X11 sampling for that backend. if (!impl_->display) return false; XFixesCursorImage* image = XFixesGetCursorImage(impl_->display); @@ -126,6 +137,12 @@ bool CursorStateProvider::Sample(const std::vector& displays, state->visible = CursorHasVisiblePixel(*image); state->shape = state->visible ? ShapeFromLinuxCursorName(name) : RemoteCursorShape::none; + state->render_mode = state->visible ? CursorRenderMode::separate + : CursorRenderMode::hidden; + state->hidden_reason = state->visible ? CursorHiddenReason::unspecified + : impl_->pointer_presence.NoPointingDevice(impl_->display, false) + ? CursorHiddenReason::no_pointing_device + : CursorHiddenReason::system_hidden; NormalizeCursorPosition(image->x, image->y, displays, preferred_display, state); XFree(image); diff --git a/apps/desktop/src/platform/linux/common/linux_pointer_presence.cpp b/apps/desktop/src/platform/linux/common/linux_pointer_presence.cpp new file mode 100644 index 00000000..3e031ab8 --- /dev/null +++ b/apps/desktop/src/platform/linux/common/linux_pointer_presence.cpp @@ -0,0 +1,292 @@ +#include "linux_pointer_presence.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +namespace crossdesk { +namespace { + +enum class PointerPresence { unknown, present, absent }; + +PointerPresence QueryX11PointerPresence(Display* display) { + if (!display) return PointerPresence::unknown; + int opcode = 0, event = 0, error = 0; + int major = 2, minor = 0; + if (!XQueryExtension(display, "XInputExtension", &opcode, &event, &error) || + XIQueryVersion(display, &major, &minor) != Success || major < 2) { + return PointerPresence::unknown; + } + int count = 0; + XIDeviceInfo* devices = XIQueryDevice(display, XIAllDevices, &count); + if (!devices) return PointerPresence::unknown; + auto result = count > 0 ? PointerPresence::absent : PointerPresence::unknown; + for (int i = 0; i < count; ++i) { + const auto& device = devices[i]; + if (!device.enabled || device.use != XISlavePointer) continue; + // Xorg creates an XTEST slave for every master even without hardware. + // Match its generated name to its master, not a broad "virtual" filter: + // mice exposed by xrdp and other virtual desktops still count as pointers. + bool xtest = false; + for (int j = 0; j < count && device.name; ++j) { + const auto& master = devices[j]; + if (master.deviceid != device.attachment || + master.use != XIMasterPointer || !master.name) + continue; + const std::string name = master.name; + constexpr char suffix[] = " pointer"; + if (name.size() >= sizeof(suffix) - 1 && + name.compare(name.size() - (sizeof(suffix) - 1), sizeof(suffix) - 1, + suffix) == 0) { + xtest = name.substr(0, name.size() - (sizeof(suffix) - 1)) + + " XTEST pointer" == + device.name; + } + break; + } + if (!xtest) { + result = PointerPresence::present; + break; + } + } + XIFreeDeviceInfo(devices); + return result; +} + +// Load the public client ABI, as the Wayland activation helper does. This +// keeps X11-only installations free of a new build/runtime dependency. The +// protocol interfaces themselves come from libwayland-client. +struct WaylandInterface; +struct WaylandApi { + void* library = dlopen("libwayland-client.so.0", RTLD_NOW | RTLD_LOCAL); + void* (*connect)(const char*) = nullptr; + void (*disconnect)(void*) = nullptr; + int (*get_fd)(void*) = nullptr; + int (*dispatch_pending)(void*) = nullptr; + int (*prepare_read)(void*) = nullptr; + void (*cancel_read)(void*) = nullptr; + int (*read_events)(void*) = nullptr; + int (*flush)(void*) = nullptr; + void (*destroy_proxy)(void*) = nullptr; + void* (*construct)(void*, uint32_t, const WaylandInterface*, uint32_t, + ...) = nullptr; + int (*add_listener)(void*, void (**)(void), void*) = nullptr; + const WaylandInterface* registry_interface = nullptr; + const WaylandInterface* seat_interface = nullptr; + const WaylandInterface* callback_interface = nullptr; + bool available = false; + + template + bool Load(T& target, const char* name) { + target = reinterpret_cast(dlsym(library, name)); + return target != nullptr; + } + + WaylandApi() { + available = library && Load(connect, "wl_display_connect") && + Load(disconnect, "wl_display_disconnect") && + Load(get_fd, "wl_display_get_fd") && + Load(dispatch_pending, "wl_display_dispatch_pending") && + Load(prepare_read, "wl_display_prepare_read") && + Load(cancel_read, "wl_display_cancel_read") && + Load(read_events, "wl_display_read_events") && + Load(flush, "wl_display_flush") && + Load(destroy_proxy, "wl_proxy_destroy") && + Load(construct, "wl_proxy_marshal_constructor_versioned") && + Load(add_listener, "wl_proxy_add_listener") && + Load(registry_interface, "wl_registry_interface") && + Load(seat_interface, "wl_seat_interface") && + Load(callback_interface, "wl_callback_interface"); + } + ~WaylandApi() { + if (library) dlclose(library); + } +}; + +class WaylandPointerPresence { + public: + ~WaylandPointerPresence() { Reset(); } + + PointerPresence Query() { + if (!api_.available) return PointerPresence::unknown; + if ((!display_ && !Connect()) || !Pump() || failed_) { + Reset(); + return PointerPresence::unknown; + } + if (!registry_ready_) return PointerPresence::unknown; + bool pending = false; + for (const auto& [id, seat] : seats_) { + if (seat.presence == PointerPresence::present) + return PointerPresence::present; + pending = pending || seat.presence == PointerPresence::unknown; + } + return pending ? PointerPresence::unknown : PointerPresence::absent; + } + + private: + struct Seat { + void* proxy = nullptr; + PointerPresence presence = PointerPresence::unknown; + }; + + bool Connect() { + // A dedicated connection avoids consuming Slint's events or depending on + // which toolkit/display backend the local window uses. + display_ = api_.connect(nullptr); + if (!display_) return false; + registry_ = + api_.construct(display_, 1, api_.registry_interface, 1, nullptr); + sync_ = api_.construct(display_, 0, api_.callback_interface, 1, nullptr); + static void (*registry_listener[])(void) = { + reinterpret_cast(&Global), + reinterpret_cast(&GlobalRemove)}; + static void (*sync_listener[])(void) = { + reinterpret_cast(&Ready)}; + return registry_ && sync_ && + api_.add_listener(registry_, registry_listener, this) == 0 && + api_.add_listener(sync_, sync_listener, this) == 0; + } + + bool Pump() { + if (api_.dispatch_pending(display_) < 0) return false; + if (api_.prepare_read(display_) != 0) return false; + const int flushed = api_.flush(display_); + if (flushed < 0 && errno != EAGAIN) { + api_.cancel_read(display_); + return false; + } + pollfd fd{api_.get_fd(display_), POLLIN, 0}; + if (fd.fd < 0) { + api_.cancel_read(display_); + return false; + } + const int ready = poll(&fd, 1, 0); + if (ready < 0 || (fd.revents & (POLLERR | POLLHUP | POLLNVAL))) { + api_.cancel_read(display_); + return false; + } + if (!(fd.revents & POLLIN)) { + api_.cancel_read(display_); + return true; + } + // Never perform a blocking roundtrip on the UI's cursor-sampling tick. + return api_.read_events(display_) >= 0 && + api_.dispatch_pending(display_) >= 0; + } + + void Reset() { + for (auto& [id, seat] : seats_) { + if (seat.proxy) api_.destroy_proxy(seat.proxy); + } + seats_.clear(); + if (sync_) api_.destroy_proxy(sync_); + if (registry_) api_.destroy_proxy(registry_); + if (display_) api_.disconnect(display_); + display_ = registry_ = sync_ = nullptr; + registry_ready_ = failed_ = false; + } + + static void Global(void* data, void* registry, uint32_t id, + const char* interface, uint32_t version) { + auto& self = *static_cast(data); + if (std::strcmp(interface, "wl_seat") != 0) return; + if (version < 1 || self.seats_.count(id)) { + self.failed_ = true; + return; + } + auto& seat = self.seats_[id]; + seat.proxy = self.api_.construct(registry, 0, self.api_.seat_interface, 1, + id, "wl_seat", uint32_t{1}, nullptr); + // Version 1 only emits capabilities; seat names are not needed. + static void (*listener[])(void) = { + reinterpret_cast(&Capabilities)}; + if (!seat.proxy || + self.api_.add_listener(seat.proxy, listener, &seat) != 0) { + self.failed_ = true; + } + } + + static void GlobalRemove(void* data, void*, uint32_t id) { + auto& self = *static_cast(data); + const auto it = self.seats_.find(id); + if (it == self.seats_.end()) return; + if (it->second.proxy) self.api_.destroy_proxy(it->second.proxy); + self.seats_.erase(it); + } + + static void Capabilities(void* data, void*, uint32_t capabilities) { + // WL_SEAT_CAPABILITY_POINTER; keyboard/touch alone are not pointers. + static_cast(data)->presence = (capabilities & 1u) + ? PointerPresence::present + : PointerPresence::absent; + } + + static void Ready(void* data, void* callback, uint32_t) { + auto& self = *static_cast(data); + self.registry_ready_ = true; + self.api_.destroy_proxy(callback); + self.sync_ = nullptr; + } + + WaylandApi api_; + void* display_ = nullptr; + void* registry_ = nullptr; + void* sync_ = nullptr; + std::map seats_; + bool registry_ready_ = false; + bool failed_ = false; +}; + +} // namespace + +struct LinuxPointerPresence::Impl { + std::unique_ptr wayland; + std::chrono::steady_clock::time_point next_check{}; + void* source_display = nullptr; + bool source_wayland = false; + bool absent_before = false; + bool confirmed = false; +}; + +LinuxPointerPresence::LinuxPointerPresence() + : impl_(std::make_unique()) {} +LinuxPointerPresence::~LinuxPointerPresence() = default; + +bool LinuxPointerPresence::NoPointingDevice(void* x11_display, bool wayland) { + if (impl_->source_wayland != wayland || + impl_->source_display != x11_display) { + impl_->next_check = {}; + impl_->absent_before = impl_->confirmed = false; + impl_->source_display = x11_display; + impl_->source_wayland = wayland; + } + const auto now = std::chrono::steady_clock::now(); + if (now < impl_->next_check) return impl_->confirmed; + // Do not combine absence samples from unrelated, widely separated periods + // of cursor hiding. Device changes and failed queries break the sequence. + if (now > impl_->next_check + std::chrono::seconds(1)) + impl_->absent_before = false; + impl_->next_check = now + std::chrono::seconds(1); + PointerPresence presence; + if (wayland) { + if (!impl_->wayland) + impl_->wayland = std::make_unique(); + presence = impl_->wayland->Query(); + } else { + presence = QueryX11PointerPresence(static_cast(x11_display)); + } + const bool absent = presence == PointerPresence::absent; + impl_->confirmed = absent && impl_->absent_before; + impl_->absent_before = absent; + return impl_->confirmed; +} + +} // namespace crossdesk diff --git a/apps/desktop/src/platform/linux/common/linux_pointer_presence.h b/apps/desktop/src/platform/linux/common/linux_pointer_presence.h new file mode 100644 index 00000000..61f5fe17 --- /dev/null +++ b/apps/desktop/src/platform/linux/common/linux_pointer_presence.h @@ -0,0 +1,30 @@ +/* + * @Author: DI JUNKUN + * @Date: 2026-10-29 + * Copyright (c) 2026 by DI JUNKUN, All Rights Reserved. + */ + +#ifndef _LINUX_POINTER_PRESENCE_H_ +#define _LINUX_POINTER_PRESENCE_H_ + +#include + +namespace crossdesk { + +// Owned by the cursor sampler. Only confirmed absence permits a remote +// locator; unavailable device information must preserve normal hiding. +class LinuxPointerPresence { + public: + LinuxPointerPresence(); + ~LinuxPointerPresence(); + + bool NoPointingDevice(void* x11_display, bool wayland); + + private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace crossdesk + +#endif diff --git a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland.cpp b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland.cpp index cabfa7db..a95460c2 100644 --- a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland.cpp +++ b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland.cpp @@ -110,7 +110,9 @@ int ScreenCapturerWayland::Start(bool show_cursor) { cursor_metadata_enabled_ = false; cursor_metadata_seen_ = false; cursor_metadata_missing_buffers_ = 0; - ClearSharedCursorState(); + // Claim the native cursor source before portal negotiation. Unknown data + // must not fall through to an unrelated XWayland cursor while we wait. + PublishSharedCursorState(CursorRenderMode::unknown, RemoteCursorShape::none); paused_ = false; pipewire_node_id_ = 0; UpdateDisplayGeometry( diff --git a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_pipewire.cpp b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_pipewire.cpp index 859a2704..9c74d5a7 100644 --- a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_pipewire.cpp +++ b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_pipewire.cpp @@ -976,21 +976,23 @@ void ScreenCapturerWayland::HandlePipeWireBuffer() { const CursorBitmapResult cursor_result = ReadCursorBitmap( *cursor_metadata, *cursor, &cursor_argb, &cursor_view); if (cursor_result == CursorBitmapResult::kInvisible) { - PublishSharedCursorState(false, RemoteCursorShape::none); + PublishSharedCursorState(CursorRenderMode::hidden, + RemoteCursorShape::none); LOG_INFO("PipeWire cursor became invisible, id={}", cursor->id); } else if (cursor_result == CursorBitmapResult::kImage) { const RemoteCursorShape shape = cursor_theme_matcher_ ? cursor_theme_matcher_->Match(cursor_view) : RemoteCursorShape::default_cursor; - PublishSharedCursorState(true, shape); + PublishSharedCursorState(CursorRenderMode::separate, shape); LOG_INFO( "PipeWire cursor shape update: id={}, size={}x{}, " "hotspot=({},{}), shape={}", cursor->id, cursor_view.width, cursor_view.height, cursor_view.xhot, cursor_view.yhot, static_cast(shape)); } else if (cursor_result == CursorBitmapResult::kInvalid) { - PublishSharedCursorState(true, RemoteCursorShape::default_cursor); + PublishSharedCursorState(CursorRenderMode::separate, + RemoteCursorShape::default_cursor); LOG_WARN("Using default shape for malformed PipeWire cursor " "metadata, id={}", cursor->id); diff --git a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_portal.cpp b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_portal.cpp index 211e9dd8..2e68cbcc 100644 --- a/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_portal.cpp +++ b/apps/desktop/src/platform/linux/screen_capturer/screen_capturer_wayland_portal.cpp @@ -489,7 +489,7 @@ bool ScreenCapturerWayland::SelectPortalSource() { uint32_t available_cursor_modes = kCursorModeHidden | kCursorModeEmbedded; if (!GetAvailableCursorModes(&available_cursor_modes)) { LOG_WARN( - "Unable to query portal cursor modes; using embedded cursor fallback"); + "Unable to query portal cursor modes; using hidden/embedded fallback"); } uint32_t cursor_mode = 0; @@ -497,6 +497,10 @@ bool ScreenCapturerWayland::SelectPortalSource() { if (!show_cursor_ && (available_cursor_modes & kCursorModeMetadata) != 0) { cursor_mode = kCursorModeMetadata; cursor_metadata_enabled_ = true; + } else if (!show_cursor_ && (available_cursor_modes & kCursorModeHidden) != 0) { + // Native controllers can draw a default arrow. Without metadata, do not + // rely on an embedded pointer that may disappear with the last mouse. + cursor_mode = kCursorModeHidden; } else if ((available_cursor_modes & kCursorModeEmbedded) != 0) { cursor_mode = kCursorModeEmbedded; } else if ((available_cursor_modes & kCursorModeMetadata) != 0) { @@ -515,11 +519,14 @@ bool ScreenCapturerWayland::SelectPortalSource() { // When metadata is unavailable, embedding is the only way to preserve the // exact Wayland cursor. Hide the controller-side cursor to avoid a double // cursor over the video stream. - PublishSharedCursorState(false, RemoteCursorShape::none); + PublishSharedCursorState(CursorRenderMode::embedded, RemoteCursorShape::none); } else if (!cursor_metadata_enabled_) { - // A portal without metadata or embedded cursor support cannot expose the - // native Wayland shape. Keep a visible default cursor on the controller. - PublishSharedCursorState(true, RemoteCursorShape::default_cursor); + // Hidden capture provides no native shape. Keep a visible default cursor + // on the controller, independent of whether a local mouse is connected. + PublishSharedCursorState(CursorRenderMode::separate, + RemoteCursorShape::default_cursor); + } else { + PublishSharedCursorState(CursorRenderMode::unknown, RemoteCursorShape::none); } LOG_INFO("Wayland portal cursor mode: {}", diff --git a/apps/desktop/xmake/targets.lua b/apps/desktop/xmake/targets.lua index 52f83415..5fc4817e 100644 --- a/apps/desktop/xmake/targets.lua +++ b/apps/desktop/xmake/targets.lua @@ -96,6 +96,7 @@ function setup_targets() "apps/desktop/src/platform/linux/screen_lock.cpp", "apps/desktop/src/platform/linux/common/*.cpp") add_includedirs("apps/desktop/src/platform/linux/common") + add_syslinks("Xi", {public = true}) end add_includedirs("apps/desktop/src/common", {public = true})