diff --git a/scripts/linux/package_deb.sh b/scripts/linux/package_deb.sh index a76cca0..ca5eefc 100755 --- a/scripts/linux/package_deb.sh +++ b/scripts/linux/package_deb.sh @@ -158,7 +158,7 @@ Architecture: $DEBIAN_ARCH Maintainer: $MAINTAINER Description: $DESCRIPTION Depends: libc6 (>= 2.31), libstdc++6 (>= 10), libx11-6, libxext6, - libxrender1, libxft2, libxrandr2, libxfixes3, libxcb1, libxcb-randr0, + libxrender1, libxft2, libxrandr2, libxfixes3, libxcursor1, libxcb1, libxcb-randr0, libxcb-xtest0, libxcb-xinerama0, libxcb-shape0, libxcb-xkb1, libxcb-xfixes0, libxv1, libxtst6, $ALSA_RUNTIME_DEP, libsndio7.0, libxcb-shm0, libpulse0, libdrm2, libdbus-1-3, libgl1 diff --git a/src/common/linux_cursor_shape.cpp b/src/common/linux_cursor_shape.cpp new file mode 100644 index 0000000..2ef2e75 --- /dev/null +++ b/src/common/linux_cursor_shape.cpp @@ -0,0 +1,402 @@ +#include "linux_cursor_shape.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) && !defined(__APPLE__) +#include +#include +#endif + +namespace crossdesk { +namespace { + +constexpr LinuxCursorAlias kCursorAliases[] = { + {"help", RemoteCursorShape::help}, + {"question-arrow", RemoteCursorShape::help}, + {"left-ptr-help", RemoteCursorShape::help}, + {"whats-this", RemoteCursorShape::help}, + {"dnd-ask", RemoteCursorShape::help}, + {"pointer", RemoteCursorShape::pointer}, + {"hand", RemoteCursorShape::pointer}, + {"hand1", RemoteCursorShape::pointer}, + {"hand2", RemoteCursorShape::pointer}, + {"link", RemoteCursorShape::pointer}, + {"pointing-hand", RemoteCursorShape::pointer}, + {"progress", RemoteCursorShape::progress}, + {"left-ptr-watch", RemoteCursorShape::progress}, + {"half-busy", RemoteCursorShape::progress}, + {"wait", RemoteCursorShape::wait}, + {"watch", RemoteCursorShape::wait}, + {"crosshair", RemoteCursorShape::crosshair}, + {"cross", RemoteCursorShape::crosshair}, + {"cross-reverse", RemoteCursorShape::crosshair}, + {"tcross", RemoteCursorShape::crosshair}, + {"cell", RemoteCursorShape::crosshair}, + {"plus", RemoteCursorShape::crosshair}, + {"target", RemoteCursorShape::crosshair}, + {"dotbox", RemoteCursorShape::crosshair}, + {"dot-box-mask", RemoteCursorShape::crosshair}, + {"text", RemoteCursorShape::text}, + {"xterm", RemoteCursorShape::text}, + {"ibeam", RemoteCursorShape::text}, + {"vertical-text", RemoteCursorShape::text}, + {"alias", RemoteCursorShape::alias}, + {"dnd-link", RemoteCursorShape::alias}, + {"copy", RemoteCursorShape::copy}, + {"dnd-copy", RemoteCursorShape::copy}, + {"move", RemoteCursorShape::move}, + {"fleur", RemoteCursorShape::move}, + {"size-all", RemoteCursorShape::move}, + {"all-scroll", RemoteCursorShape::move}, + {"dnd-move", RemoteCursorShape::move}, + {"no-drop", RemoteCursorShape::no_drop}, + {"dnd-no-drop", RemoteCursorShape::no_drop}, + {"dnd-none", RemoteCursorShape::no_drop}, + {"not-allowed", RemoteCursorShape::not_allowed}, + {"forbidden", RemoteCursorShape::not_allowed}, + {"crossed-circle", RemoteCursorShape::not_allowed}, + {"circle", RemoteCursorShape::not_allowed}, + {"pirate", RemoteCursorShape::not_allowed}, + {"grab", RemoteCursorShape::grab}, + {"openhand", RemoteCursorShape::grab}, + {"open-hand", RemoteCursorShape::grab}, + {"grabbing", RemoteCursorShape::grabbing}, + {"closedhand", RemoteCursorShape::grabbing}, + {"closed-hand", RemoteCursorShape::grabbing}, + {"col-resize", RemoteCursorShape::col_resize}, + {"row-resize", RemoteCursorShape::row_resize}, + {"n-resize", RemoteCursorShape::n_resize}, + {"top-side", RemoteCursorShape::n_resize}, + {"top-tee", RemoteCursorShape::n_resize}, + {"sb-up-arrow", RemoteCursorShape::n_resize}, + {"e-resize", RemoteCursorShape::e_resize}, + {"right-side", RemoteCursorShape::e_resize}, + {"right-tee", RemoteCursorShape::e_resize}, + {"sb-right-arrow", RemoteCursorShape::e_resize}, + {"s-resize", RemoteCursorShape::s_resize}, + {"bottom-side", RemoteCursorShape::s_resize}, + {"bottom-tee", RemoteCursorShape::s_resize}, + {"sb-down-arrow", RemoteCursorShape::s_resize}, + {"w-resize", RemoteCursorShape::w_resize}, + {"left-side", RemoteCursorShape::w_resize}, + {"left-tee", RemoteCursorShape::w_resize}, + {"sb-left-arrow", RemoteCursorShape::w_resize}, + {"ne-resize", RemoteCursorShape::ne_resize}, + {"top-right-corner", RemoteCursorShape::ne_resize}, + {"ur-angle", RemoteCursorShape::ne_resize}, + {"nw-resize", RemoteCursorShape::nw_resize}, + {"top-left-corner", RemoteCursorShape::nw_resize}, + {"ul-angle", RemoteCursorShape::nw_resize}, + {"se-resize", RemoteCursorShape::se_resize}, + {"bottom-right-corner", RemoteCursorShape::se_resize}, + {"lr-angle", RemoteCursorShape::se_resize}, + {"sw-resize", RemoteCursorShape::sw_resize}, + {"bottom-left-corner", RemoteCursorShape::sw_resize}, + {"ll-angle", RemoteCursorShape::sw_resize}, + {"ew-resize", RemoteCursorShape::ew_resize}, + {"sb-h-double-arrow", RemoteCursorShape::ew_resize}, + {"h-double-arrow", RemoteCursorShape::ew_resize}, + {"size-hor", RemoteCursorShape::ew_resize}, + {"split-h", RemoteCursorShape::ew_resize}, + {"exchange", RemoteCursorShape::ew_resize}, + {"ns-resize", RemoteCursorShape::ns_resize}, + {"sb-v-double-arrow", RemoteCursorShape::ns_resize}, + {"v-double-arrow", RemoteCursorShape::ns_resize}, + {"double-arrow", RemoteCursorShape::ns_resize}, + {"size-ver", RemoteCursorShape::ns_resize}, + {"split-v", RemoteCursorShape::ns_resize}, + {"nesw-resize", RemoteCursorShape::nesw_resize}, + {"fd-double-arrow", RemoteCursorShape::nesw_resize}, + {"size-bdiag", RemoteCursorShape::nesw_resize}, + {"nwse-resize", RemoteCursorShape::nwse_resize}, + {"bd-double-arrow", RemoteCursorShape::nwse_resize}, + {"size-fdiag", RemoteCursorShape::nwse_resize}, + {"default", RemoteCursorShape::default_cursor}, + {"left-ptr", RemoteCursorShape::default_cursor}, + {"left-arrow", RemoteCursorShape::default_cursor}, + {"arrow", RemoteCursorShape::default_cursor}, + {"top-left-arrow", RemoteCursorShape::default_cursor}, + {"right-ptr", RemoteCursorShape::default_cursor}, + {"center-ptr", RemoteCursorShape::default_cursor}, + {"context-menu", RemoteCursorShape::default_cursor}, +}; + +std::string NormalizeCursorName(const std::string& raw_name) { + std::string normalized; + normalized.reserve(raw_name.size()); + for (unsigned char ch : raw_name) { + if (ch == '_' || std::isspace(ch)) { + normalized.push_back('-'); + } else { + normalized.push_back(static_cast(std::tolower(ch))); + } + } + return normalized; +} + +#if defined(__linux__) && !defined(__APPLE__) + +struct CursorCandidate { + RemoteCursorShape shape = RemoteCursorShape::default_cursor; + uint32_t width = 0; + uint32_t height = 0; + uint32_t xhot = 0; + uint32_t yhot = 0; + std::vector argb; +}; + +uint8_t Alpha(uint32_t argb) { + return static_cast((argb >> 24U) & 0xffU); +} + +double BilinearAlpha(const CursorCandidate& candidate, double x, double y) { + if (candidate.width == 0 || candidate.height == 0) { + return 0.0; + } + + x = std::max(0.0, std::min(x, static_cast(candidate.width - 1))); + y = std::max(0.0, std::min(y, static_cast(candidate.height - 1))); + const uint32_t x0 = static_cast(std::floor(x)); + const uint32_t y0 = static_cast(std::floor(y)); + const uint32_t x1 = std::min(x0 + 1, candidate.width - 1); + const uint32_t y1 = std::min(y0 + 1, candidate.height - 1); + const double fx = x - x0; + const double fy = y - y0; + + const auto sample = [&](uint32_t px, uint32_t py) { + return static_cast( + Alpha(candidate.argb[static_cast(py) * candidate.width + px])); + }; + const double top = sample(x0, y0) * (1.0 - fx) + sample(x1, y0) * fx; + const double bottom = sample(x0, y1) * (1.0 - fx) + sample(x1, y1) * fx; + return top * (1.0 - fy) + bottom * fy; +} + +double SimilarityError(const LinuxCursorImageView& image, + const CursorCandidate& candidate) { + if (!image.argb || image.width == 0 || image.height == 0 || + candidate.argb.empty()) { + return std::numeric_limits::infinity(); + } + + double alpha_error = 0.0; + for (uint32_t y = 0; y < image.height; ++y) { + const double source_y = + (static_cast(y) + 0.5) * candidate.height / image.height - 0.5; + for (uint32_t x = 0; x < image.width; ++x) { + const double source_x = (static_cast(x) + 0.5) * + candidate.width / image.width - + 0.5; + const double expected = BilinearAlpha(candidate, source_x, source_y); + const double actual = static_cast( + Alpha(image.argb[static_cast(y) * image.width + x])); + alpha_error += std::abs(actual - expected) / 255.0; + } + } + alpha_error /= static_cast(image.width) * image.height; + + const double observed_hot_x = + static_cast(image.xhot) / std::max(1u, image.width); + const double observed_hot_y = + static_cast(image.yhot) / std::max(1u, image.height); + const double candidate_hot_x = + static_cast(candidate.xhot) / std::max(1u, candidate.width); + const double candidate_hot_y = + static_cast(candidate.yhot) / std::max(1u, candidate.height); + const double hotspot_error = + std::abs(observed_hot_x - candidate_hot_x) + + std::abs(observed_hot_y - candidate_hot_y); + + const double observed_aspect = + static_cast(image.width) / image.height; + const double candidate_aspect = + static_cast(candidate.width) / candidate.height; + const double aspect_error = + std::abs(std::log(observed_aspect / candidate_aspect)); + + return alpha_error + hotspot_error * 0.12 + aspect_error * 0.08; +} + +bool SameImage(const LinuxCursorImageView& image, + const CursorCandidate& candidate) { + if (image.width != candidate.width || image.height != candidate.height || + image.xhot != candidate.xhot || image.yhot != candidate.yhot) { + return false; + } + const size_t pixel_count = + static_cast(image.width) * static_cast(image.height); + return std::equal(image.argb, image.argb + pixel_count, + candidate.argb.begin()); +} + +#endif + +} // namespace + +const LinuxCursorAlias* LinuxCursorAliases(size_t* count) { + if (count) { + *count = sizeof(kCursorAliases) / sizeof(kCursorAliases[0]); + } + return kCursorAliases; +} + +RemoteCursorShape ShapeFromLinuxCursorName(const std::string& raw_name) { + const std::string name = NormalizeCursorName(raw_name); + for (const auto& alias : kCursorAliases) { + if (name == alias.name) { + return alias.shape; + } + } + + // Some toolkits prefix a theme or toolkit name to the standard cursor name. + // Restrict the fallback to separators so names such as "pointer-progress" + // still prefer their exact semantic alias above. + for (const auto& alias : kCursorAliases) { + const std::string suffix = std::string("-") + alias.name; + if (name.size() > suffix.size() && + name.compare(name.size() - suffix.size(), suffix.size(), suffix) == 0) { + return alias.shape; + } + } + return RemoteCursorShape::default_cursor; +} + +#if defined(__linux__) && !defined(__APPLE__) + +struct LinuxCursorThemeMatcher::Impl { + Display* display = XOpenDisplay(nullptr); + std::string theme; + int default_size = 24; + std::unordered_map> candidates_by_size; + + Impl() { + if (!display) { + return; + } + if (const char* active_theme = XcursorGetTheme(display)) { + theme = active_theme; + } + const int active_size = XcursorGetDefaultSize(display); + if (active_size > 0) { + default_size = active_size; + } + } + + ~Impl() { + if (display) { + XCloseDisplay(display); + } + } + + const std::vector& Candidates(int requested_size) { + requested_size = std::max(1, requested_size); + auto existing = candidates_by_size.find(requested_size); + if (existing != candidates_by_size.end()) { + return existing->second; + } + + std::vector candidates; + const char* theme_name = theme.empty() ? nullptr : theme.c_str(); + for (const auto& alias : kCursorAliases) { + XcursorImages* images = + XcursorLibraryLoadImages(alias.name, theme_name, requested_size); + if (!images) { + continue; + } + for (int index = 0; index < images->nimage; ++index) { + const XcursorImage* image = images->images[index]; + if (!image || !image->pixels || image->width == 0 || + image->height == 0) { + continue; + } + + CursorCandidate candidate; + candidate.shape = alias.shape; + candidate.width = image->width; + candidate.height = image->height; + candidate.xhot = image->xhot; + candidate.yhot = image->yhot; + const size_t pixel_count = static_cast(image->width) * + static_cast(image->height); + candidate.argb.assign(image->pixels, image->pixels + pixel_count); + + const bool duplicate = + std::any_of(candidates.begin(), candidates.end(), + [&](const CursorCandidate& current) { + return current.shape == candidate.shape && + current.width == candidate.width && + current.height == candidate.height && + current.xhot == candidate.xhot && + current.yhot == candidate.yhot && + current.argb == candidate.argb; + }); + if (!duplicate) { + candidates.push_back(std::move(candidate)); + } + } + XcursorImagesDestroy(images); + } + + return candidates_by_size + .emplace(requested_size, std::move(candidates)) + .first->second; + } +}; + +#else + +struct LinuxCursorThemeMatcher::Impl {}; + +#endif + +LinuxCursorThemeMatcher::LinuxCursorThemeMatcher() + : impl_(std::make_unique()) {} + +LinuxCursorThemeMatcher::~LinuxCursorThemeMatcher() = default; + +RemoteCursorShape LinuxCursorThemeMatcher::Match( + const LinuxCursorImageView& image) { +#if defined(__linux__) && !defined(__APPLE__) + if (!impl_ || !image.argb || image.width == 0 || image.height == 0) { + return RemoteCursorShape::default_cursor; + } + + const int requested_size = + static_cast(std::max(image.width, image.height)); + const auto& candidates = impl_->Candidates(requested_size); + for (const auto& candidate : candidates) { + if (SameImage(image, candidate)) { + return candidate.shape; + } + } + + double best_error = std::numeric_limits::infinity(); + RemoteCursorShape best_shape = RemoteCursorShape::default_cursor; + for (const auto& candidate : candidates) { + const double error = SimilarityError(image, candidate); + if (error < best_error) { + best_error = error; + best_shape = candidate.shape; + } + } + + // Scaled theme cursors normally remain well below this threshold, while an + // unrelated custom application cursor should fall back to the default. + return best_error <= 0.20 ? best_shape + : RemoteCursorShape::default_cursor; +#else + (void)image; + return RemoteCursorShape::default_cursor; +#endif +} + +} // namespace crossdesk diff --git a/src/common/linux_cursor_shape.h b/src/common/linux_cursor_shape.h new file mode 100644 index 0000000..5724abf --- /dev/null +++ b/src/common/linux_cursor_shape.h @@ -0,0 +1,49 @@ +#ifndef CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_ +#define CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_ + +#include +#include +#include +#include + +#include "remote_cursor_shape.h" + +namespace crossdesk { + +struct LinuxCursorAlias { + const char* name; + RemoteCursorShape shape; +}; + +const LinuxCursorAlias* LinuxCursorAliases(size_t* count); +RemoteCursorShape ShapeFromLinuxCursorName(const std::string& name); + +struct LinuxCursorImageView { + uint32_t width = 0; + uint32_t height = 0; + uint32_t xhot = 0; + uint32_t yhot = 0; + const uint32_t* argb = nullptr; +}; + +// Matches a compositor-provided cursor bitmap against the active Xcursor +// theme. This is needed on Wayland, where XFixes only observes XWayland +// cursors and cannot report cursors owned by native Wayland surfaces. +class LinuxCursorThemeMatcher { + public: + LinuxCursorThemeMatcher(); + ~LinuxCursorThemeMatcher(); + + LinuxCursorThemeMatcher(const LinuxCursorThemeMatcher&) = delete; + LinuxCursorThemeMatcher& operator=(const LinuxCursorThemeMatcher&) = delete; + + RemoteCursorShape Match(const LinuxCursorImageView& image); + + private: + struct Impl; + std::unique_ptr impl_; +}; + +} // namespace crossdesk + +#endif // CROSSDESK_COMMON_LINUX_CURSOR_SHAPE_H_ diff --git a/src/common/remote_cursor_shape.h b/src/common/remote_cursor_shape.h new file mode 100644 index 0000000..906b710 --- /dev/null +++ b/src/common/remote_cursor_shape.h @@ -0,0 +1,45 @@ +#ifndef CROSSDESK_COMMON_REMOTE_CURSOR_SHAPE_H_ +#define CROSSDESK_COMMON_REMOTE_CURSOR_SHAPE_H_ + +#include + +namespace crossdesk { + +// Keep these values aligned with Slint's MouseCursor enum. The wire protocol +// intentionally carries a semantic cursor instead of a platform handle so a +// Windows, macOS or Linux host can control a different desktop platform. +enum class RemoteCursorShape : uint8_t { + default_cursor = 0, + none, + help, + pointer, + progress, + wait, + crosshair, + text, + alias, + copy, + move, + no_drop, + not_allowed, + grab, + grabbing, + col_resize, + row_resize, + n_resize, + e_resize, + s_resize, + w_resize, + ne_resize, + nw_resize, + se_resize, + sw_resize, + ew_resize, + ns_resize, + nesw_resize, + nwse_resize, +}; + +} // namespace crossdesk + +#endif // CROSSDESK_COMMON_REMOTE_CURSOR_SHAPE_H_ diff --git a/src/common/shared_cursor_state.cpp b/src/common/shared_cursor_state.cpp new file mode 100644 index 0000000..87b1229 --- /dev/null +++ b/src/common/shared_cursor_state.cpp @@ -0,0 +1,42 @@ +#include "shared_cursor_state.h" + +#include + +namespace crossdesk { +namespace { + +std::mutex g_cursor_state_mutex; +SharedCursorState g_cursor_state; +bool g_cursor_state_available = false; + +} // namespace + +void PublishSharedCursorState(bool visible, 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_available = true; +} + +bool GetSharedCursorState(SharedCursorState* state) { + if (!state) { + return false; + } + std::lock_guard lock(g_cursor_state_mutex); + if (!g_cursor_state_available) { + return false; + } + *state = g_cursor_state; + return true; +} + +void ClearSharedCursorState() { + std::lock_guard lock(g_cursor_state_mutex); + ++g_cursor_state.generation; + g_cursor_state.visible = false; + g_cursor_state.shape = RemoteCursorShape::none; + g_cursor_state_available = false; +} + +} // namespace crossdesk diff --git a/src/common/shared_cursor_state.h b/src/common/shared_cursor_state.h new file mode 100644 index 0000000..a88cffe --- /dev/null +++ b/src/common/shared_cursor_state.h @@ -0,0 +1,22 @@ +#ifndef CROSSDESK_COMMON_SHARED_CURSOR_STATE_H_ +#define CROSSDESK_COMMON_SHARED_CURSOR_STATE_H_ + +#include + +#include "remote_cursor_shape.h" + +namespace crossdesk { + +struct SharedCursorState { + uint64_t generation = 0; + bool visible = false; + RemoteCursorShape shape = RemoteCursorShape::default_cursor; +}; + +void PublishSharedCursorState(bool visible, RemoteCursorShape shape); +bool GetSharedCursorState(SharedCursorState* state); +void ClearSharedCursorState(); + +} // namespace crossdesk + +#endif // CROSSDESK_COMMON_SHARED_CURSOR_STATE_H_ diff --git a/src/device_controller/device_controller.h b/src/device_controller/device_controller.h index 80cae5d..9264d41 100644 --- a/src/device_controller/device_controller.h +++ b/src/device_controller/device_controller.h @@ -15,6 +15,7 @@ #include #include "display_info.h" +#include "remote_cursor_shape.h" using json = nlohmann::json; namespace crossdesk { @@ -71,41 +72,6 @@ typedef struct { KeyboardStateKey pressed_keys[kMaxKeyboardStateKeys]; } KeyboardState; -// Keep these values aligned with Slint's MouseCursor enum. The wire protocol -// intentionally carries a semantic cursor instead of a platform handle so a -// Windows, macOS or Linux host can control a different desktop platform. -enum class RemoteCursorShape : uint8_t { - default_cursor = 0, - none, - help, - pointer, - progress, - wait, - crosshair, - text, - alias, - copy, - move, - no_drop, - not_allowed, - grab, - grabbing, - col_resize, - row_resize, - n_resize, - e_resize, - s_resize, - w_resize, - ne_resize, - nw_resize, - se_resize, - sw_resize, - ew_resize, - ns_resize, - nesw_resize, - nwse_resize, -}; - typedef struct { uint32_t seq; bool visible; diff --git a/src/gui/application/gui_application.cpp b/src/gui/application/gui_application.cpp index d05a840..5090aea 100644 --- a/src/gui/application/gui_application.cpp +++ b/src/gui/application/gui_application.cpp @@ -1853,6 +1853,11 @@ void GuiApplication::ShareLocalCursorState() { return; } + if (changed) { + LOG_INFO("Sent cursor state: seq={}, visible={}, shape={}", sampled.seq, + sampled.visible, static_cast(sampled.shape)); + } + last_shared_cursor_state_ = sampled; has_shared_cursor_state_ = true; last_cursor_state_share_time_ = now; diff --git a/src/gui/runtime/cursor_state_provider.cpp b/src/gui/runtime/cursor_state_provider.cpp index 4f06e3b..243e984 100644 --- a/src/gui/runtime/cursor_state_provider.cpp +++ b/src/gui/runtime/cursor_state_provider.cpp @@ -60,83 +60,15 @@ bool CursorStateProvider::Sample(CursorState* state) { #include #include -#include -#include #include +#include "linux_cursor_shape.h" +#include "platform.h" +#include "shared_cursor_state.h" + namespace crossdesk { namespace { -std::string Lowercase(std::string value) { - std::transform(value.begin(), value.end(), value.begin(), - [](unsigned char ch) { return std::tolower(ch); }); - return value; -} - -bool Contains(const std::string& value, const char* token) { - return value.find(token) != std::string::npos; -} - -RemoteCursorShape ShapeFromXCursorName(const std::string& raw_name) { - const std::string name = Lowercase(raw_name); - if (Contains(name, "left_ptr_watch") || Contains(name, "progress")) - return RemoteCursorShape::progress; - if (Contains(name, "watch") || Contains(name, "wait")) - return RemoteCursorShape::wait; - if (Contains(name, "question") || Contains(name, "help")) - return RemoteCursorShape::help; - if (Contains(name, "xterm") || Contains(name, "vertical-text") || - name == "text") - return RemoteCursorShape::text; - if (Contains(name, "crosshair") || name == "cross" || name == "tcross") - return RemoteCursorShape::crosshair; - if (Contains(name, "closedhand") || Contains(name, "grabbing")) - return RemoteCursorShape::grabbing; - if (Contains(name, "openhand") || Contains(name, "grab")) - return RemoteCursorShape::grab; - if (Contains(name, "dnd-link") || name == "alias") - return RemoteCursorShape::alias; - if (Contains(name, "hand") || Contains(name, "pointer") || - Contains(name, "link")) - return RemoteCursorShape::pointer; - if (Contains(name, "dnd-copy") || name == "copy") - return RemoteCursorShape::copy; - if (Contains(name, "no-drop")) return RemoteCursorShape::no_drop; - if (Contains(name, "not-allowed") || Contains(name, "crossed_circle")) - return RemoteCursorShape::not_allowed; - if (name == "fleur" || Contains(name, "size_all") || name == "move") - return RemoteCursorShape::move; - if (Contains(name, "top_left_corner") || - Contains(name, "bottom_right_corner") || - Contains(name, "nwse-resize") || Contains(name, "size_fdiag")) - return RemoteCursorShape::nwse_resize; - if (Contains(name, "top_right_corner") || - Contains(name, "bottom_left_corner") || - Contains(name, "nesw-resize") || Contains(name, "size_bdiag")) - return RemoteCursorShape::nesw_resize; - if (Contains(name, "sb_h_double_arrow") || Contains(name, "ew-resize") || - Contains(name, "size_hor")) - return RemoteCursorShape::ew_resize; - if (Contains(name, "sb_v_double_arrow") || Contains(name, "ns-resize") || - Contains(name, "size_ver")) - return RemoteCursorShape::ns_resize; - if (Contains(name, "col-resize")) return RemoteCursorShape::col_resize; - if (Contains(name, "row-resize")) return RemoteCursorShape::row_resize; - if (Contains(name, "ne-resize")) return RemoteCursorShape::ne_resize; - if (Contains(name, "nw-resize")) return RemoteCursorShape::nw_resize; - if (Contains(name, "se-resize")) return RemoteCursorShape::se_resize; - if (Contains(name, "sw-resize")) return RemoteCursorShape::sw_resize; - if (Contains(name, "top_side") || name == "n-resize") - return RemoteCursorShape::n_resize; - if (Contains(name, "right_side") || name == "e-resize") - return RemoteCursorShape::e_resize; - if (Contains(name, "bottom_side") || name == "s-resize") - return RemoteCursorShape::s_resize; - if (Contains(name, "left_side") || name == "w-resize") - return RemoteCursorShape::w_resize; - return RemoteCursorShape::default_cursor; -} - bool CursorHasVisiblePixel(const XFixesCursorImage& image) { const size_t pixel_count = static_cast(image.width) * static_cast(image.height); @@ -160,7 +92,19 @@ CursorStateProvider::CursorStateProvider() : impl_(std::make_unique()) {} CursorStateProvider::~CursorStateProvider() = default; bool CursorStateProvider::Sample(CursorState* state) { - if (!state || !impl_ || !impl_->display) return false; + if (!state || !impl_) return false; + + if (IsWaylandSession()) { + SharedCursorState shared{}; + if (GetSharedCursorState(&shared)) { + state->seq = 0; + state->visible = shared.visible; + state->shape = shared.visible ? shared.shape : RemoteCursorShape::none; + return true; + } + } + + if (!impl_->display) return false; XFixesCursorImage* image = XFixesGetCursorImage(impl_->display); if (!image) return false; @@ -169,7 +113,7 @@ bool CursorStateProvider::Sample(CursorState* state) { state->seq = 0; state->visible = CursorHasVisiblePixel(*image); - state->shape = state->visible ? ShapeFromXCursorName(name) + state->shape = state->visible ? ShapeFromLinuxCursorName(name) : RemoteCursorShape::none; XFree(image); return true; diff --git a/src/gui/runtime/peer_data_callbacks.cpp b/src/gui/runtime/peer_data_callbacks.cpp index b852270..fd3cb6e 100644 --- a/src/gui/runtime/peer_data_callbacks.cpp +++ b/src/gui/runtime/peer_data_callbacks.cpp @@ -183,8 +183,17 @@ void PeerEventHandler::OnReceiveDataBuffer( !props->remote_cursor_state_received_ || static_cast(remote_action.cs.seq - previous_seq) > 0; if (is_newer) { + const bool changed = + !props->remote_cursor_state_received_ || + props->remote_cursor_state_.visible != remote_action.cs.visible || + props->remote_cursor_state_.shape != remote_action.cs.shape; props->remote_cursor_state_ = remote_action.cs; props->remote_cursor_state_received_ = true; + if (changed) { + LOG_INFO("Received cursor state: seq={}, visible={}, shape={}", + remote_action.cs.seq, remote_action.cs.visible, + static_cast(remote_action.cs.shape)); + } } return; } diff --git a/src/screen_capturer/linux/screen_capturer_wayland.cpp b/src/screen_capturer/linux/screen_capturer_wayland.cpp index ed00da6..cabfa7d 100644 --- a/src/screen_capturer/linux/screen_capturer_wayland.cpp +++ b/src/screen_capturer/linux/screen_capturer_wayland.cpp @@ -12,8 +12,10 @@ #include #include +#include "linux_cursor_shape.h" #include "platform.h" #include "rd_log.h" +#include "shared_cursor_state.h" #include "wayland_portal_shared.h" namespace crossdesk { @@ -35,7 +37,8 @@ constexpr auto kPipeWireCloseSettleDelay = std::chrono::milliseconds(200); } // namespace -ScreenCapturerWayland::ScreenCapturerWayland() {} +ScreenCapturerWayland::ScreenCapturerWayland() + : cursor_theme_matcher_(std::make_unique()) {} ScreenCapturerWayland::~ScreenCapturerWayland() { Destroy(); } @@ -66,6 +69,10 @@ int ScreenCapturerWayland::Init(const int fps, cb_desktop_data cb) { callback_ = cb; pointer_granted_ = false; shared_session_registered_ = false; + cursor_metadata_enabled_ = false; + cursor_metadata_seen_ = false; + cursor_metadata_missing_buffers_ = 0; + ClearSharedCursorState(); display_info_list_.clear(); display_info_list_.push_back( DisplayInfo(display_name_, 0, 0, kFallbackWidth, kFallbackHeight)); @@ -100,6 +107,10 @@ int ScreenCapturerWayland::Start(bool show_cursor) { } show_cursor_ = show_cursor; + cursor_metadata_enabled_ = false; + cursor_metadata_seen_ = false; + cursor_metadata_missing_buffers_ = 0; + ClearSharedCursorState(); paused_ = false; pipewire_node_id_ = 0; UpdateDisplayGeometry( @@ -119,6 +130,10 @@ int ScreenCapturerWayland::Stop() { thread_.join(); } pipewire_node_id_ = 0; + cursor_metadata_enabled_ = false; + cursor_metadata_seen_ = false; + cursor_metadata_missing_buffers_ = 0; + ClearSharedCursorState(); UpdateDisplayGeometry( logical_width_ > 0 ? logical_width_ : kFallbackWidth, logical_height_ > 0 ? logical_height_ : kFallbackHeight); diff --git a/src/screen_capturer/linux/screen_capturer_wayland.h b/src/screen_capturer/linux/screen_capturer_wayland.h index 2def26e..824ef5b 100644 --- a/src/screen_capturer/linux/screen_capturer_wayland.h +++ b/src/screen_capturer/linux/screen_capturer_wayland.h @@ -15,6 +15,7 @@ struct pw_thread_loop; #include #include +#include #include #include #include @@ -23,6 +24,8 @@ struct pw_thread_loop; namespace crossdesk { +class LinuxCursorThemeMatcher; + class ScreenCapturerWayland : public ScreenCapturer { public: enum class PipeWireConnectMode { kTargetObject, kNodeId, kAny }; @@ -51,6 +54,7 @@ class ScreenCapturerWayland : public ScreenCapturer { bool CreatePortalSession(); bool SelectPortalDevices(); bool SelectPortalSource(); + bool GetAvailableCursorModes(uint32_t* modes) const; bool StartPortalSession(); bool EnsurePipeWireRuntimeAvailable() const; bool OpenPipeWireRemote(); @@ -95,6 +99,9 @@ class ScreenCapturerWayland : public ScreenCapturer { bool pipewire_thread_loop_started_ = false; bool pointer_granted_ = false; bool shared_session_registered_ = false; + bool cursor_metadata_enabled_ = false; + bool cursor_metadata_seen_ = false; + uint32_t cursor_metadata_missing_buffers_ = 0; bool portal_has_logical_size_ = false; uint32_t spa_video_format_ = 0; int frame_width_ = 0; @@ -104,6 +111,7 @@ class ScreenCapturerWayland : public ScreenCapturer { int portal_stream_height_ = 0; int logical_width_ = 0; int logical_height_ = 0; + std::unique_ptr cursor_theme_matcher_; std::vector y_plane_; std::vector uv_plane_; diff --git a/src/screen_capturer/linux/screen_capturer_wayland_pipewire.cpp b/src/screen_capturer/linux/screen_capturer_wayland_pipewire.cpp index 9ca9329..7f12ec7 100644 --- a/src/screen_capturer/linux/screen_capturer_wayland_pipewire.cpp +++ b/src/screen_capturer/linux/screen_capturer_wayland_pipewire.cpp @@ -17,7 +17,9 @@ #include "display_stream_id.h" #include "libyuv.h" +#include "linux_cursor_shape.h" #include "rd_log.h" +#include "shared_cursor_state.h" namespace crossdesk { @@ -199,6 +201,145 @@ int64_t NowMs() { .count(); } +constexpr uint32_t kMaxCursorDimension = 512; +constexpr int kCursorMetadataSize = + static_cast(sizeof(spa_meta_cursor) + sizeof(spa_meta_bitmap) + + 64u + kMaxCursorDimension * kMaxCursorDimension * 4u); + +enum class CursorBitmapResult { kNoUpdate, kInvisible, kImage, kInvalid }; + +CursorBitmapResult ReadCursorBitmap(const spa_meta& metadata, + const spa_meta_cursor& cursor, + std::vector* argb, + LinuxCursorImageView* view) { + if (!metadata.data || !argb || !view || cursor.bitmap_offset == 0) { + return CursorBitmapResult::kNoUpdate; + } + + const size_t bitmap_offset = cursor.bitmap_offset; + if (bitmap_offset > metadata.size || + metadata.size - bitmap_offset < sizeof(spa_meta_bitmap)) { + return CursorBitmapResult::kInvalid; + } + + const auto* metadata_bytes = static_cast(metadata.data); + const auto* bitmap = reinterpret_cast( + metadata_bytes + bitmap_offset); + if (bitmap->format == 0) { + return CursorBitmapResult::kNoUpdate; + } + if (bitmap->offset == 0) { + return CursorBitmapResult::kInvisible; + } + + const uint32_t width = bitmap->size.width; + const uint32_t height = bitmap->size.height; + if (width == 0 || height == 0 || width > kMaxCursorDimension || + height > kMaxCursorDimension || bitmap->stride < 0 || + static_cast(bitmap->stride) < width * 4u) { + return CursorBitmapResult::kInvalid; + } + + const size_t pixel_offset = bitmap_offset + bitmap->offset; + const size_t required_size = + static_cast(bitmap->stride) * (height - 1u) + width * 4u; + if (pixel_offset > metadata.size || + required_size > metadata.size - pixel_offset) { + return CursorBitmapResult::kInvalid; + } + + enum class ChannelOrder { kBgra, kRgba, kArgb, kAbgr, kBgrx, kRgbx }; + ChannelOrder channel_order; + switch (bitmap->format) { + case SPA_VIDEO_FORMAT_BGRA: + channel_order = ChannelOrder::kBgra; + break; + case SPA_VIDEO_FORMAT_RGBA: + channel_order = ChannelOrder::kRgba; + break; + case SPA_VIDEO_FORMAT_ARGB: + channel_order = ChannelOrder::kArgb; + break; + case SPA_VIDEO_FORMAT_ABGR: + channel_order = ChannelOrder::kAbgr; + break; + case SPA_VIDEO_FORMAT_BGRx: + channel_order = ChannelOrder::kBgrx; + break; + case SPA_VIDEO_FORMAT_RGBx: + channel_order = ChannelOrder::kRgbx; + break; + default: + return CursorBitmapResult::kInvalid; + } + + argb->resize(static_cast(width) * height); + const uint8_t* pixels = metadata_bytes + pixel_offset; + bool has_visible_pixel = false; + for (uint32_t y = 0; y < height; ++y) { + const uint8_t* row = pixels + static_cast(y) * bitmap->stride; + for (uint32_t x = 0; x < width; ++x) { + const uint8_t* pixel = row + static_cast(x) * 4u; + uint8_t red = 0; + uint8_t green = 0; + uint8_t blue = 0; + uint8_t alpha = 0xff; + switch (channel_order) { + case ChannelOrder::kBgra: + blue = pixel[0]; + green = pixel[1]; + red = pixel[2]; + alpha = pixel[3]; + break; + case ChannelOrder::kRgba: + red = pixel[0]; + green = pixel[1]; + blue = pixel[2]; + alpha = pixel[3]; + break; + case ChannelOrder::kArgb: + alpha = pixel[0]; + red = pixel[1]; + green = pixel[2]; + blue = pixel[3]; + break; + case ChannelOrder::kAbgr: + alpha = pixel[0]; + blue = pixel[1]; + green = pixel[2]; + red = pixel[3]; + break; + case ChannelOrder::kBgrx: + blue = pixel[0]; + green = pixel[1]; + red = pixel[2]; + break; + case ChannelOrder::kRgbx: + red = pixel[0]; + green = pixel[1]; + blue = pixel[2]; + break; + } + has_visible_pixel = has_visible_pixel || alpha != 0; + (*argb)[static_cast(y) * width + x] = + (static_cast(alpha) << 24U) | + (static_cast(red) << 16U) | + (static_cast(green) << 8U) | blue; + } + } + + if (!has_visible_pixel) { + return CursorBitmapResult::kInvisible; + } + + view->width = width; + view->height = height; + view->xhot = static_cast(std::max(0, cursor.hotspot.x)); + view->yhot = static_cast(std::max(0, cursor.hotspot.y)); + view->argb = argb->data(); + return CursorBitmapResult::kImage; +} + double SnapLikelyFractionalScale(double observed_scale) { static constexpr double kCandidates[] = { 1.0, 1.25, 1.3333333333, 1.5, 1.6666666667, 1.75, 2.0, 2.25, 2.5, 3.0}; @@ -500,7 +641,7 @@ bool ScreenCapturerWayland::SetupPipeWireStream(bool relaxed_connect, uint8_t buffer[1024]; spa_pod_builder builder = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer)); - const spa_pod* params[2]; + const spa_pod* params[3]; uint32_t param_count = 0; params[param_count++] = @@ -525,10 +666,29 @@ bool ScreenCapturerWayland::SetupPipeWireStream(bool relaxed_connect, CROSSDESK_SPA_PARAM_META_SIZE, SPA_POD_Int(sizeof(struct spa_meta_header)))); + if (self->cursor_metadata_enabled_) { + params[param_count++] = + reinterpret_cast(spa_pod_builder_add_object( + &builder, SPA_TYPE_OBJECT_ParamMeta, SPA_PARAM_Meta, + CROSSDESK_SPA_PARAM_META_TYPE, SPA_POD_Id(SPA_META_Cursor), + CROSSDESK_SPA_PARAM_META_SIZE, + SPA_POD_CHOICE_RANGE_Int( + kCursorMetadataSize, + static_cast(sizeof(struct spa_meta_cursor)), + kCursorMetadataSize))); + } + if (self->pw_stream_) { const PipeWireDynamicApi* pipewire = GetPipeWireApi(); if (pipewire) { - pipewire->stream_update_params(self->pw_stream_, params, param_count); + const int update_result = pipewire->stream_update_params( + self->pw_stream_, params, param_count); + LOG_INFO( + "PipeWire buffer parameters updated: cursor_metadata={}, " + "cursor_meta_max_size={}, result={}", + self->cursor_metadata_enabled_, + self->cursor_metadata_enabled_ ? kCursorMetadataSize : 0, + update_result); } } self->pipewire_format_ready_.store(true); @@ -786,6 +946,57 @@ void ScreenCapturerWayland::HandlePipeWireBuffer() { return; } + if (cursor_metadata_enabled_) { + spa_meta* cursor_metadata = + spa_buffer_find_meta(spa_buffer, SPA_META_Cursor); + if (!cursor_metadata) { + ++cursor_metadata_missing_buffers_; + if (cursor_metadata_missing_buffers_ == 120) { + LOG_WARN( + "PipeWire stream has not attached SPA_META_Cursor after 120 " + "buffers; cursor metadata negotiation was not accepted"); + } + } else { + cursor_metadata_missing_buffers_ = 0; + if (!cursor_metadata_seen_) { + cursor_metadata_seen_ = true; + LOG_INFO("PipeWire cursor metadata attached, size={}", + cursor_metadata->size); + } + } + if (cursor_metadata && cursor_metadata->data && + cursor_metadata->size >= sizeof(spa_meta_cursor)) { + const auto* cursor = + static_cast(cursor_metadata->data); + if (spa_meta_cursor_is_valid(cursor)) { + std::vector cursor_argb; + LinuxCursorImageView cursor_view; + const CursorBitmapResult cursor_result = ReadCursorBitmap( + *cursor_metadata, *cursor, &cursor_argb, &cursor_view); + if (cursor_result == CursorBitmapResult::kInvisible) { + PublishSharedCursorState(false, 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); + 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); + LOG_WARN("Using default shape for malformed PipeWire cursor " + "metadata, id={}", + cursor->id); + } + } + } + } + const spa_data& data = spa_buffer->datas[0]; if (!data.chunk) { requeue(); diff --git a/src/screen_capturer/linux/screen_capturer_wayland_portal.cpp b/src/screen_capturer/linux/screen_capturer_wayland_portal.cpp index 2157bf6..32de28f 100644 --- a/src/screen_capturer/linux/screen_capturer_wayland_portal.cpp +++ b/src/screen_capturer/linux/screen_capturer_wayland_portal.cpp @@ -12,6 +12,7 @@ #include #include "rd_log.h" +#include "shared_cursor_state.h" namespace crossdesk { @@ -35,6 +36,7 @@ constexpr const char* kPortalSessionPathPrefix = constexpr uint32_t kScreenCastSourceMonitor = 1u; constexpr uint32_t kCursorModeHidden = 1u; constexpr uint32_t kCursorModeEmbedded = 2u; +constexpr uint32_t kCursorModeMetadata = 4u; constexpr uint32_t kRemoteDesktopDevicePointer = 2u; std::string MakeToken(const char* prefix) { @@ -482,6 +484,47 @@ bool ScreenCapturerWayland::SelectPortalSource() { return false; } + uint32_t available_cursor_modes = kCursorModeHidden | kCursorModeEmbedded; + if (!GetAvailableCursorModes(&available_cursor_modes)) { + LOG_WARN( + "Unable to query portal cursor modes; using embedded cursor fallback"); + } + + uint32_t cursor_mode = 0; + cursor_metadata_enabled_ = false; + if (!show_cursor_ && (available_cursor_modes & kCursorModeMetadata) != 0) { + cursor_mode = kCursorModeMetadata; + cursor_metadata_enabled_ = true; + } else if ((available_cursor_modes & kCursorModeEmbedded) != 0) { + cursor_mode = kCursorModeEmbedded; + } else if ((available_cursor_modes & kCursorModeMetadata) != 0) { + cursor_mode = kCursorModeMetadata; + cursor_metadata_enabled_ = true; + } else if ((available_cursor_modes & kCursorModeHidden) != 0) { + cursor_mode = kCursorModeHidden; + } + + if (cursor_mode == 0) { + LOG_ERROR("Wayland portal reports no supported cursor mode"); + return false; + } + + if (cursor_mode == kCursorModeEmbedded) { + // 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); + } 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); + } + + LOG_INFO("Wayland portal cursor mode: {}", + cursor_mode == kCursorModeMetadata + ? "metadata" + : (cursor_mode == kCursorModeEmbedded ? "embedded" : "hidden")); + const char* session_handle = session_handle_.c_str(); return SendPortalRequestAndHandleResponse( dbus_connection_, kPortalScreenCastInterface, "SelectSources", @@ -496,9 +539,7 @@ bool ScreenCapturerWayland::SelectPortalSource() { &options); AppendDictEntryUint32(&options, "types", kScreenCastSourceMonitor); AppendDictEntryBool(&options, "multiple", false); - AppendDictEntryUint32( - &options, "cursor_mode", - show_cursor_ ? kCursorModeEmbedded : kCursorModeHidden); + AppendDictEntryUint32(&options, "cursor_mode", cursor_mode); AppendDictEntryString(&options, "handle_token", MakeToken("crossdesk_req")); dbus_message_iter_close_container(&iter, &options); @@ -515,6 +556,50 @@ bool ScreenCapturerWayland::SelectPortalSource() { }); } +bool ScreenCapturerWayland::GetAvailableCursorModes(uint32_t* modes) const { + if (!dbus_connection_ || !modes) { + return false; + } + + DBusMessage* message = dbus_message_new_method_call( + kPortalBusName, kPortalObjectPath, "org.freedesktop.DBus.Properties", + "Get"); + if (!message) { + return false; + } + + const char* interface_name = kPortalScreenCastInterface; + const char* property_name = "AvailableCursorModes"; + dbus_message_append_args(message, DBUS_TYPE_STRING, &interface_name, + DBUS_TYPE_STRING, &property_name, + DBUS_TYPE_INVALID); + + DBusError error; + dbus_error_init(&error); + DBusMessage* reply = dbus_connection_send_with_reply_and_block( + dbus_connection_, message, -1, &error); + dbus_message_unref(message); + if (!reply) { + LogDbusError("Get AvailableCursorModes", &error); + dbus_error_free(&error); + return false; + } + + DBusMessageIter iter; + bool parsed = false; + if (dbus_message_iter_init(reply, &iter) && + dbus_message_iter_get_arg_type(&iter) == DBUS_TYPE_VARIANT) { + DBusMessageIter variant; + dbus_message_iter_recurse(&iter, &variant); + if (dbus_message_iter_get_arg_type(&variant) == DBUS_TYPE_UINT32) { + dbus_message_iter_get_basic(&variant, modes); + parsed = true; + } + } + dbus_message_unref(reply); + return parsed; +} + bool ScreenCapturerWayland::SelectPortalDevices() { if (!dbus_connection_ || session_handle_.empty()) { return false; diff --git a/xmake/platform.lua b/xmake/platform.lua index 7183b91..dfc56ca 100644 --- a/xmake/platform.lua +++ b/xmake/platform.lua @@ -37,7 +37,7 @@ function setup_platform_settings() add_requires("libyuv") add_syslinks("pthread", "dl") add_links("asound", "X11", "Xext", "Xrender", "Xft", "Xtst", - "Xrandr", "Xfixes") + "Xrandr", "Xfixes", "Xcursor") add_existing_include_dirs({ "/usr/include/freetype2", "/usr/local/include/freetype2"