[fix] restore remote cursors on Windows without a physical mouse

This commit is contained in:
dijunkun
2026-09-16 22:20:36 +08:00
parent 1d9242515a
commit d9116200fe
13 changed files with 323 additions and 48 deletions
@@ -1584,6 +1584,10 @@ void GuiApplication::BindStreamCallbacks() {
return;
}
props->selected_display_ = index;
{
std::lock_guard lock(props->remote_cursor_state_mutex_);
props->cursor_presentation_ = {};
}
RemoteAction action{};
action.type = ControlType::display_id;
action.d = index;
@@ -1883,9 +1887,20 @@ void GuiApplication::ShareLocalCursorState() {
}
CursorState sampled{};
if (!cursor_state_provider_.Sample(devices_.display_info_list(),
selected_display_, &sampled)) {
return;
const bool sample_ok = cursor_state_provider_.Sample(
devices_.display_info_list(), selected_display_, &sampled);
if (!sample_ok) {
sampled.render_mode = CursorRenderMode::unknown;
if (sampled.hidden_reason == CursorHiddenReason::unspecified) {
sampled.hidden_reason = CursorHiddenReason::sampling_failed;
}
} else if (sampled.render_mode == CursorRenderMode::legacy) {
// Providers without platform-specific presentation metadata only need to
// sample visible/shape; normalize that common case once for every peer.
sampled.render_mode = sampled.visible ? CursorRenderMode::separate
: CursorRenderMode::hidden;
sampled.hidden_reason = sampled.visible ? CursorHiddenReason::unspecified
: CursorHiddenReason::system_hidden;
}
const auto now = std::chrono::steady_clock::now();
@@ -1924,21 +1939,23 @@ void GuiApplication::ShareLocalCursorState() {
const CursorState& previous = delivery.last_sent;
const bool shape_changed =
!delivery.has_sent || sampled.visible != previous.visible ||
sampled.shape != previous.shape ||
std::abs(sampled.visual_offset_x - previous.visual_offset_x) >
kCursorPositionEpsilon ||
std::abs(sampled.visual_offset_y - previous.visual_offset_y) >
kCursorPositionEpsilon;
!delivery.has_sent || sampled.render_mode != previous.render_mode ||
sampled.hidden_reason != previous.hidden_reason ||
(sample_ok &&
(sampled.visible != previous.visible || sampled.shape != previous.shape ||
std::abs(sampled.visual_offset_x - previous.visual_offset_x) >
kCursorPositionEpsilon ||
std::abs(sampled.visual_offset_y - previous.visual_offset_y) >
kCursorPositionEpsilon));
// Preserve sub-point cursor motion. At 10x client zoom, the old roughly
// one-logical-pixel threshold became several visible phone points.
const bool position_changed =
!delivery.has_sent ||
sampled.position_valid != previous.position_valid ||
sampled.display_id != previous.display_id ||
(sampled.position_valid && previous.position_valid &&
(std::abs(sampled.x - previous.x) > kCursorPositionEpsilon ||
std::abs(sampled.y - previous.y) > kCursorPositionEpsilon));
sample_ok &&
(!delivery.has_sent || sampled.position_valid != previous.position_valid ||
sampled.display_id != previous.display_id ||
(sampled.position_valid && previous.position_valid &&
(std::abs(sampled.x - previous.x) > kCursorPositionEpsilon ||
std::abs(sampled.y - previous.y) > kCursorPositionEpsilon)));
const bool position_update_due =
position_changed &&
(delivery.last_sent_time.time_since_epoch().count() == 0 ||
@@ -1946,16 +1963,12 @@ void GuiApplication::ShareLocalCursorState() {
const bool heartbeat_due =
delivery.last_sent_time.time_since_epoch().count() == 0 ||
now - delivery.last_sent_time >= kCursorStateHeartbeatInterval;
if (suppress_position) {
// Cursor appearance is independent of cursor position. Continue sending
// shape changes to every controller while withholding only the sampled
// position from the connection that originated recent input.
if (shape_changed || heartbeat_due) {
recipients.push_back({remote_id, false});
}
} else if (delivery.feedback_pending || shape_changed ||
position_update_due || heartbeat_due) {
recipients.push_back({remote_id, true});
// Position feedback waits for a good sample and the echo-suppression
// interval. Appearance changes and heartbeats are independent of both.
const bool include_position = sample_ok && !suppress_position;
if (shape_changed || heartbeat_due ||
(include_position && (delivery.feedback_pending || position_update_due))) {
recipients.push_back({remote_id, include_position});
}
}
if (recipients.empty()) return;
@@ -1964,6 +1977,18 @@ void GuiApplication::ShareLocalCursorState() {
for (const auto& recipient : recipients) {
auto& delivery = cursor_delivery_states_[recipient.remote_id];
CursorState outgoing = sampled;
if (!sample_ok) {
// Explicitly report unknown to new receivers, but retain the last
// presentation for peers that only understand visible/shape.
outgoing = delivery.has_sent ? delivery.last_sent : CursorState{};
if (!delivery.has_sent) {
outgoing.visible = true;
outgoing.shape = RemoteCursorShape::default_cursor;
}
outgoing.seq = sampled.seq;
outgoing.render_mode = sampled.render_mode;
outgoing.hidden_reason = sampled.hidden_reason;
}
outgoing.position_update = recipient.include_position;
if (!recipient.include_position) {
// Keep legacy receivers at their last acknowledged position as well.
@@ -1991,6 +2016,13 @@ void GuiApplication::ShareLocalCursorState() {
continue;
}
if (!delivery.has_sent ||
delivery.last_sent.render_mode != outgoing.render_mode ||
delivery.last_sent.hidden_reason != outgoing.hidden_reason) {
LOG_INFO("Send cursor presentation to [{}]: mode={}, reason={}",
recipient.remote_id, static_cast<int>(outgoing.render_mode),
static_cast<int>(outgoing.hidden_reason));
}
delivery.last_sent = outgoing;
delivery.has_sent = true;
if (recipient.include_position) {
@@ -2582,9 +2614,7 @@ void GuiApplication::SyncStreamWindow() {
props->remote_cursor_state_received_;
if (remote_cursor_active) {
remote_cursor_shape = static_cast<int>(
props->remote_cursor_state_.visible
? props->remote_cursor_state_.shape
: RemoteCursorShape::none);
props->cursor_presentation_.Resolve(SDL_GetTicks()));
}
}
(*ui_->stream)->set_remote_cursor_active(remote_cursor_active);
@@ -3267,6 +3297,10 @@ void GuiApplication::SendPointerInput(int button, int kind, float x, float y) {
controlled_remote_id_ = props->remote_id_;
const std::string message = action.to_json();
{
std::lock_guard lock(props->remote_cursor_state_mutex_);
props->cursor_presentation_.NoteInput(SDL_GetTicks());
}
SendDataFrame(props->peer_, message.c_str(), message.size(),
props->mouse_label_.c_str());
}
@@ -3305,6 +3339,10 @@ void GuiApplication::SendScrollInput(float delta_x, float delta_y, float x,
if (action.m.s == 0) {
return;
}
{
std::lock_guard lock(props->remote_cursor_state_mutex_);
props->cursor_presentation_.NoteInput(SDL_GetTicks());
}
controlled_remote_id_ = props->remote_id_;
const std::string message = action.to_json();
SendDataFrame(props->peer_, message.c_str(), message.size(),
@@ -438,6 +438,7 @@ void GuiRuntime::ResetRemoteSessionResources(
std::lock_guard<std::mutex> lock(props->remote_cursor_state_mutex_);
props->remote_cursor_state_ = {};
props->remote_cursor_state_received_ = false;
props->cursor_presentation_ = {};
}
}
@@ -0,0 +1,77 @@
/*
* @Author: DI JUNKUN
* @Date: 2026-09-16
* Copyright (c) 2026 by DI JUNKUN, All Rights Reserved.
*/
#ifndef _CURSOR_PRESENTATION_H_
#define _CURSOR_PRESENTATION_H_
#include <remote_action.h>
#include <cstdint>
#include <optional>
namespace crossdesk {
// All times use one monotonic millisecond clock. The owning session's cursor
// mutex protects this policy from the transport and UI threads.
class CursorPresentation {
public:
static constexpr uint64_t kStateTimeoutMs = 1500; // Three 500ms heartbeats.
static constexpr uint64_t kInputActivityMs = 1500;
void Observe(const CursorState& state, uint64_t now) {
if (state.render_mode == CursorRenderMode::unknown) {
// Repeated unavailable samples must not extend the recovery deadline.
if (!sample_unavailable_) stale_at_ = now + kStateTimeoutMs;
sample_unavailable_ = true;
return;
}
sample_unavailable_ = false;
stale_at_ = now + kStateTimeoutMs;
const bool separate =
state.render_mode == CursorRenderMode::separate ||
(state.render_mode == CursorRenderMode::legacy && state.visible);
// Old senders cannot distinguish embedded from hidden. Only explicit
// metadata from a new sender can establish the no-device compatibility
// case.
no_device_ = state.render_mode == CursorRenderMode::hidden &&
state.hidden_reason == CursorHiddenReason::no_pointing_device;
held_shape_ = separate ? state.shape : RemoteCursorShape::none;
if (held_shape_ != RemoteCursorShape::none) {
last_visible_shape_ = held_shape_;
}
if (!no_device_) fallback_engaged_ = false;
}
void NoteInput(uint64_t now) { last_input_ = now; }
RemoteCursorShape Resolve(uint64_t now) {
if (!stale_at_) return RemoteCursorShape::default_cursor;
const bool recent_input =
last_input_ && now - *last_input_ < kInputActivityMs;
const bool can_recover =
now >= *stale_at_ || (!sample_unavailable_ && no_device_);
if (can_recover && recent_input) {
fallback_engaged_ = true;
}
// Once needed, retain the locator while idle until a good non-compatibility
// sample arrives. Otherwise it would blink off whenever input pauses.
return fallback_engaged_ ? last_visible_shape_ : held_shape_;
}
private:
bool sample_unavailable_ = false;
bool no_device_ = false;
bool fallback_engaged_ = false;
std::optional<uint64_t> stale_at_;
std::optional<uint64_t> last_input_;
RemoteCursorShape held_shape_ = RemoteCursorShape::default_cursor;
RemoteCursorShape last_visible_shape_ = RemoteCursorShape::default_cursor;
};
} // namespace crossdesk
#endif
@@ -6,14 +6,16 @@
#include <windows.h>
#include "platform/windows/input/windows_cursor_shape.h"
#include "platform/windows/input/windows_cursor_state.h"
#include "platform/windows/screen_capturer/dxgi_cursor_state.h"
#include "platform/windows/screen_capturer/secure_desktop_cursor_state.h"
#include "runtime/cursor_position.h"
namespace crossdesk {
struct CursorStateProvider::Impl {};
struct CursorStateProvider::Impl {
WindowsCursorState cursor;
};
CursorStateProvider::CursorStateProvider() : impl_(std::make_unique<Impl>()) {}
CursorStateProvider::~CursorStateProvider() = default;
@@ -24,11 +26,17 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
SecureDesktopCursorSnapshot secure_cursor{};
if (SharedSecureDesktopCursorState().Get(&secure_cursor)) {
if (!secure_cursor.valid) return false;
if (!secure_cursor.valid) {
state->hidden_reason = CursorHiddenReason::secure_desktop_pending;
return false;
}
state->seq = 0;
NormalizeCursorPosition(secure_cursor.x, secure_cursor.y, displays,
preferred_display, state);
state->visible = secure_cursor.visible != 0;
state->render_mode = static_cast<CursorRenderMode>(secure_cursor.render_mode);
state->hidden_reason =
static_cast<CursorHiddenReason>(secure_cursor.hidden_reason);
state->shape = state->visible
? static_cast<RemoteCursorShape>(secure_cursor.shape)
: RemoteCursorShape::none;
@@ -45,10 +53,9 @@ bool CursorStateProvider::Sample(const std::vector<DisplayInfo>& displays,
void* cursor_monitor = state->position_valid
? displays[state->display_id].handle
: nullptr;
state->visible = SharedDxgiCursorState().ShouldDrawCursor(
(info.flags & CURSOR_SHOWING) != 0, cursor_monitor);
state->shape = state->visible ? ShapeFromWindowsCursor(info.hCursor)
: RemoteCursorShape::none;
const bool embedded = (info.flags & CURSOR_SHOWING) != 0 &&
!SharedDxgiCursorState().ShouldDrawCursor(true, cursor_monitor);
impl_->cursor.Sample(info, embedded, state);
return true;
}
@@ -234,7 +234,11 @@ void PeerEventHandler::OnReceiveDataBuffer(
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_.shape != remote_action.cs.shape ||
props->remote_cursor_state_.render_mode !=
remote_action.cs.render_mode ||
props->remote_cursor_state_.hidden_reason !=
remote_action.cs.hidden_reason;
CursorState merged = remote_action.cs;
if (!remote_action.cs.position_update &&
props->remote_cursor_state_received_) {
@@ -245,10 +249,14 @@ void PeerEventHandler::OnReceiveDataBuffer(
}
props->remote_cursor_state_ = merged;
props->remote_cursor_state_received_ = true;
props->cursor_presentation_.Observe(merged, SDL_GetTicks());
if (changed) {
LOG_INFO("Received cursor state: seq={}, visible={}, shape={}",
LOG_INFO("Received cursor state: seq={}, visible={}, shape={}, "
"mode={}, reason={}",
remote_action.cs.seq, remote_action.cs.visible,
static_cast<int>(remote_action.cs.shape));
static_cast<int>(remote_action.cs.shape),
static_cast<int>(remote_action.cs.render_mode),
static_cast<int>(remote_action.cs.hidden_reason));
}
}
return;
@@ -262,6 +262,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
std::lock_guard lock(props->remote_cursor_state_mutex_);
props->remote_cursor_state_ = {};
props->remote_cursor_state_received_ = false;
props->cursor_presentation_ = {};
}
{
RemoteAction remote_action{};
@@ -331,6 +332,7 @@ void PeerEventHandler::OnConnectionStatus(ConnectionStatus status,
std::lock_guard lock(props->remote_cursor_state_mutex_);
props->remote_cursor_state_ = {};
props->remote_cursor_state_received_ = false;
props->cursor_presentation_ = {};
}
std::shared_ptr<std::vector<unsigned char>> native_snapshot;
@@ -22,6 +22,7 @@
#include <remote_action.h>
#include <stream_names.h>
#include "display_info.h"
#include "cursor_presentation.h"
#include "minirtc.h"
namespace crossdesk { class PeerEventHandler; }
@@ -173,6 +174,7 @@ struct RemoteSession {
std::mutex remote_cursor_state_mutex_;
CursorState remote_cursor_state_{};
bool remote_cursor_state_received_ = false;
CursorPresentation cursor_presentation_;
// Shared by minirtc callbacks, Slint rendering and SDL audio callbacks.
std::atomic<ConnectionStatus> connection_status_ = ConnectionStatus::Closed;
TraversalMode traversal_mode_ = TraversalMode::UnknownMode;