feat: Add relative mouse mode (#13928)

* feat: Add relative mouse mode

- Add "Relative Mouse Mode" toggle in desktop toolbar and bind to InputModel
- Implement relative mouse movement path: Flutter pointer deltas -> `type: move_relative` -> new `MOUSE_TYPE_MOVE_RELATIVE` in Rust
- In server input service, simulate relative movement via Enigo and keep latest cursor position in sync
- Track pointer-lock center in Flutter (local widget + screen coordinates) and re-center OS cursor after each relative move
- Update pointer-lock center on window move/resize/restore/maximize and when remote display geometry changes
- Hide local cursor when relative mouse mode is active (both Flutter cursor and OS cursor), restore on leave/disable
- On Windows, clip OS cursor to the window rect while in relative mode and release clip when leaving/turning off
- Implement platform helpers: `get_cursor_pos`, `set_cursor_pos`, `show_cursor`, `clip_cursor` (no-op clip/hide on Linux for now)
- Add keyboard shortcut Ctrl+Alt+Shift+M to toggle relative mode (enabled by default, works on all platforms)
- Remove `enable-relative-mouse-shortcut` config option - shortcut is now always available when keyboard permission is granted
- Handle window blur/focus/minimize events to properly release/restore cursor constraints
- Add MOUSE_TYPE_MASK constant and unit tests for mouse event constants

Note: Relative mouse mode state is NOT persisted to config (session-only).
Note: On Linux, show_cursor and clip_cursor are no-ops; cursor hiding is handled by Flutter side.

Signed-off-by: fufesou <linlong1266@gmail.com>

* feat(mouse): relative mouse mode, exit hint

Signed-off-by: fufesou <linlong1266@gmail.com>

* refact(relative mouse): shortcut

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-01-09 10:03:14 +08:00
committed by GitHub
parent 3a9084006f
commit 998b75856d
90 changed files with 3089 additions and 165 deletions

View File

@@ -1215,6 +1215,66 @@ pub fn main_set_input_source(session_id: SessionID, value: String) {
}
}
/// Set cursor position (for pointer lock re-centering).
///
/// # Returns
/// - `true`: cursor position was successfully set
/// - `false`: operation failed or not supported
///
/// # Platform behavior
/// - Windows/macOS/Linux: attempts to move the cursor to (x, y)
/// - Android/iOS: no-op, always returns `false`
pub fn main_set_cursor_position(x: i32, y: i32) -> SyncReturn<bool> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
SyncReturn(crate::set_cursor_pos(x, y))
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
let _ = (x, y);
SyncReturn(false)
}
}
/// Clip cursor to a rectangle (for pointer lock).
///
/// When `enable` is true, the cursor is clipped to the rectangle defined by
/// `left`, `top`, `right`, `bottom`. When `enable` is false, the rectangle
/// values are ignored and the cursor is unclipped.
///
/// # Returns
/// - `true`: operation succeeded or no-op completed
/// - `false`: operation failed
///
/// # Platform behavior
/// - Windows: uses ClipCursor API to confine cursor to the specified rectangle
/// - macOS: uses CGAssociateMouseAndMouseCursorPosition for pointer lock effect;
/// the rect coordinates are ignored (only Some/None matters)
/// - Linux: no-op, always returns `true`; use pointer warping for similar effect
/// - Android/iOS: no-op, always returns `false`
pub fn main_clip_cursor(
left: i32,
top: i32,
right: i32,
bottom: i32,
enable: bool,
) -> SyncReturn<bool> {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
let rect = if enable {
Some((left, top, right, bottom))
} else {
None
};
SyncReturn(crate::clip_cursor(rect))
}
#[cfg(any(target_os = "android", target_os = "ios"))]
{
let _ = (left, top, right, bottom, enable);
SyncReturn(false)
}
}
pub fn main_get_my_id() -> String {
get_id()
}
@@ -1748,8 +1808,99 @@ pub fn session_send_pointer(session_id: SessionID, msg: String) {
super::flutter::session_send_pointer(session_id, msg);
}
/// Send mouse event from Flutter to the remote peer.
///
/// # Relative Mouse Mode Message Contract
///
/// When the message contains a `relative_mouse_mode` field, this function validates
/// and filters activation/deactivation markers.
///
/// **Mode Authority:**
/// The Flutter InputModel is authoritative for relative mouse mode activation/deactivation.
/// The server (via `input_service.rs`) only consumes forwarded delta movements and tracks
/// relative movement processing state, but does NOT control mode activation/deactivation.
///
/// **Deactivation Markers are Local-Only:**
/// Deactivation markers (`relative_mouse_mode: "0"`) are NEVER forwarded to the server.
/// They are handled entirely on the client side to reset local UI state (cursor visibility,
/// pointer lock, etc.). The server does not rely on deactivation markers and should not
/// expect to receive them.
///
/// **Contract (Flutter side MUST adhere to):**
/// 1. `relative_mouse_mode` field is ONLY present on activation/deactivation marker messages,
/// NEVER on normal pointer events (move, button, scroll).
/// 2. Deactivation marker: `{"relative_mouse_mode": "0"}` - local-only, never forwarded.
/// 3. Activation marker: `{"relative_mouse_mode": "1", "type": "move_relative", "x": "0", "y": "0"}`
/// - MUST use `type="move_relative"` with `x="0"` and `y="0"` (safe no-op).
/// - Any other combination is dropped to prevent accidental cursor movement.
///
/// If these assumptions are violated (e.g., `relative_mouse_mode` is added to normal events),
/// legitimate mouse events may be silently dropped by the early-return logic below.
pub fn session_send_mouse(session_id: SessionID, msg: String) {
if let Ok(m) = serde_json::from_str::<HashMap<String, String>>(&msg) {
// Relative mouse mode marker validation (Flutter-only).
// This only validates and filters markers; the server tracks per-connection
// relative-movement processing state but not mode activation/deactivation.
// See doc comment above for the message contract.
if let Some(v) = m.get("relative_mouse_mode") {
let active = matches!(v.as_str(), "1" | "Y" | "on");
// Disable marker: local-only, never forwarded to the server.
// The server does not track mode deactivation; it simply stops receiving
// relative move events when the client exits relative mouse mode.
if !active {
#[cfg(not(any(target_os = "android", target_os = "ios")))]
crate::keyboard::set_relative_mouse_mode_state(false);
return;
}
// Enable marker: validate BEFORE setting state to avoid desync.
// This ensures we only mark as active if the marker will actually be forwarded.
// Enable marker is allowed to go through only if it's a safe no-op relative move.
// This avoids accidentally moving the remote cursor (e.g. if type/x/y are missing).
let msg_type = m.get("type").map(|t| t.as_str());
if msg_type != Some("move_relative") {
log::warn!(
"relative_mouse_mode activation marker has invalid type: {:?}, expected 'move_relative'. Dropping.",
msg_type
);
return;
}
let x_marker = m
.get("x")
.map(|x| x.parse::<i32>().unwrap_or(0))
.unwrap_or(0);
let y_marker = m
.get("y")
.map(|y| y.parse::<i32>().unwrap_or(0))
.unwrap_or(0);
if x_marker != 0 || y_marker != 0 {
log::warn!(
"relative_mouse_mode activation marker has non-zero coordinates: x={}, y={}. Dropping.",
x_marker, y_marker
);
return;
}
// Guard against unexpected fields that could turn this no-op into a real event.
if m.contains_key("buttons")
|| m.contains_key("alt")
|| m.contains_key("ctrl")
|| m.contains_key("shift")
|| m.contains_key("command")
{
log::warn!(
"relative_mouse_mode activation marker contains unexpected fields (buttons/alt/ctrl/shift/command). Dropping."
);
return;
}
// All validation passed - marker will be forwarded as a no-op relative move.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
crate::keyboard::set_relative_mouse_mode_state(true);
}
let alt = m.get("alt").is_some();
let ctrl = m.get("ctrl").is_some();
let shift = m.get("shift").is_some();
@@ -1769,6 +1920,7 @@ pub fn session_send_mouse(session_id: SessionID, msg: String) {
"up" => MOUSE_TYPE_UP,
"wheel" => MOUSE_TYPE_WHEEL,
"trackpad" => MOUSE_TYPE_TRACKPAD,
"move_relative" => MOUSE_TYPE_MOVE_RELATIVE,
_ => 0,
};
}