diff --git a/lib/features/desktop/desktop_client.dart b/lib/features/desktop/desktop_client.dart index 2dc63d86..284f214b 100644 --- a/lib/features/desktop/desktop_client.dart +++ b/lib/features/desktop/desktop_client.dart @@ -32,6 +32,14 @@ Map desktopOfferParams({ }; } +bool desktopShouldDropInputEvent( + Map event, { + required int bufferedAmount, + int bufferedAmountLimit = 64 * 1024, +}) { + return event['type'] == 'mouse_move' && bufferedAmount > bufferedAmountLimit; +} + String desktopSessionId() { return 'remote-desktop-${randomIdInternal()}'; } @@ -326,6 +334,10 @@ class DesktopClient { final channel = _dataChannel; if (channel != null && channel.state == RTCDataChannelState.RTCDataChannelOpen) { + final bufferedAmount = channel.bufferedAmount ?? 0; + if (desktopShouldDropInputEvent(event, bufferedAmount: bufferedAmount)) { + return; + } final jsonStr = jsonEncode(event); channel.send(RTCDataChannelMessage(jsonStr)); } diff --git a/lib/features/desktop/desktop_input_handler.dart b/lib/features/desktop/desktop_input_handler.dart index b2575717..15b46ee2 100644 --- a/lib/features/desktop/desktop_input_handler.dart +++ b/lib/features/desktop/desktop_input_handler.dart @@ -2,11 +2,22 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; +typedef DesktopInputClock = int Function(); + class DesktopInputHandler { - DesktopInputHandler({required this.onSendInput}); + DesktopInputHandler({ + required this.onSendInput, + int moveIntervalMs = 16, + DesktopInputClock? nowMillis, + }) : moveIntervalMs = moveIntervalMs < 0 ? 0 : moveIntervalMs, + _nowMillis = nowMillis ?? (() => DateTime.now().millisecondsSinceEpoch); final void Function(Map event) onSendInput; + final int moveIntervalMs; + final DesktopInputClock _nowMillis; int _lastPressedButton = 1; // Default to left click + int? _lastMoveSentAtMs; + Offset? _lastMovePosition; void handlePointerMove( PointerEvent event, @@ -20,7 +31,7 @@ class DesktopInputHandler { ); if (position == null) return; - onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy}); + _sendPointerMove(position); } void handlePointerDown( @@ -36,7 +47,7 @@ class DesktopInputHandler { if (position == null) return; // Send move event first to ensure click hits the exact coordinates - onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy}); + _sendPointerMove(position, force: true); _lastPressedButton = _mapPointerButtons(event.buttons); @@ -79,6 +90,25 @@ class DesktopInputHandler { if (buttons & 2 != 0) return 3; // right click return 1; } + + void _sendPointerMove(Offset position, {bool force = false}) { + final lastPosition = _lastMovePosition; + if (!force && + lastPosition != null && + (position - lastPosition).distance < 0.001) { + return; + } + + final now = _nowMillis(); + final lastSentAt = _lastMoveSentAtMs; + if (!force && lastSentAt != null && now - lastSentAt < moveIntervalMs) { + return; + } + + _lastMoveSentAtMs = now; + _lastMovePosition = position; + onSendInput({'type': 'mouse_move', 'x': position.dx, 'y': position.dy}); + } } String? desktopKeyName(LogicalKeyboardKey key) { @@ -97,7 +127,7 @@ String? desktopKeyName(LogicalKeyboardKey key) { if (key == LogicalKeyboardKey.end) return 'End'; if (key == LogicalKeyboardKey.pageUp) return 'Page_Up'; if (key == LogicalKeyboardKey.pageDown) return 'Page_Down'; - + if (key == LogicalKeyboardKey.shiftLeft) return 'Shift_L'; if (key == LogicalKeyboardKey.shiftRight) return 'Shift_R'; if (key == LogicalKeyboardKey.controlLeft) return 'Control_L'; @@ -162,7 +192,9 @@ Offset? desktopContentPosition( }) { if (viewportSize.width <= 0 || viewportSize.height <= 0) return null; - if (contentSize == null || contentSize.width <= 0 || contentSize.height <= 0) { + if (contentSize == null || + contentSize.width <= 0 || + contentSize.height <= 0) { return Offset( (localPosition.dx / viewportSize.width).clamp(0.0, 1.0), (localPosition.dy / viewportSize.height).clamp(0.0, 1.0), diff --git a/test/features/desktop/desktop_client_test.dart b/test/features/desktop/desktop_client_test.dart index 34ebf6ee..1a104d3f 100644 --- a/test/features/desktop/desktop_client_test.dart +++ b/test/features/desktop/desktop_client_test.dart @@ -143,14 +143,38 @@ void main() { expect(params['height'], 720); }); - test('generates distinct desktop session ids for parallel app instances', () { - final first = desktopSessionId(); - final second = desktopSessionId(); + test( + 'generates distinct desktop session ids for parallel app instances', + () { + final first = desktopSessionId(); + final second = desktopSessionId(); - expect(first, startsWith('remote-desktop-')); - expect(second, startsWith('remote-desktop-')); - expect(first, isNot(second)); - expect(first, isNot('remote-desktop-session')); + expect(first, startsWith('remote-desktop-')); + expect(second, startsWith('remote-desktop-')); + expect(first, isNot(second)); + expect(first, isNot('remote-desktop-session')); + }, + ); + + test('drops only stale mouse moves when data channel is backed up', () { + expect( + desktopShouldDropInputEvent({ + 'type': 'mouse_move', + }, bufferedAmount: 80 * 1024), + isTrue, + ); + expect( + desktopShouldDropInputEvent({ + 'type': 'mouse_down', + }, bufferedAmount: 80 * 1024), + isFalse, + ); + expect( + desktopShouldDropInputEvent({ + 'type': 'mouse_move', + }, bufferedAmount: 1024), + isFalse, + ); }); test('uses bridge-provided remote stream when present', () async { diff --git a/test/features/desktop/desktop_input_handler_test.dart b/test/features/desktop/desktop_input_handler_test.dart index 99a798e2..ca03b278 100644 --- a/test/features/desktop/desktop_input_handler_test.dart +++ b/test/features/desktop/desktop_input_handler_test.dart @@ -1,5 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:xworkmate/features/desktop/desktop_input_handler.dart'; void main() { @@ -73,4 +75,82 @@ void main() { expect(position, const Offset(0.5, 0.5)); }); }); + + group('DesktopInputHandler pointer flow control', () { + test('throttles pointer move events before they hit the data channel', () { + var now = 0; + final events = >[]; + final handler = DesktopInputHandler( + onSendInput: events.add, + nowMillis: () => now, + ); + + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + now = 5; + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(20, 20)), + const Size(100, 100), + ); + now = 16; + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(30, 30)), + const Size(100, 100), + ); + + expect(events, hasLength(2)); + expect(events.first['x'], 0.1); + expect(events.last['x'], 0.3); + }); + + test('deduplicates unchanged pointer move positions', () { + var now = 0; + final events = >[]; + final handler = DesktopInputHandler( + onSendInput: events.add, + nowMillis: () => now, + ); + + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + now = 100; + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + + expect(events, hasLength(1)); + }); + + test('forces latest pointer position before mouse down', () { + var now = 0; + final events = >[]; + final handler = DesktopInputHandler( + onSendInput: events.add, + nowMillis: () => now, + ); + + handler.handlePointerMove( + const PointerHoverEvent(position: Offset(10, 10)), + const Size(100, 100), + ); + now = 5; + handler.handlePointerDown( + const PointerDownEvent( + position: Offset(80, 20), + buttons: kPrimaryMouseButton, + ), + const Size(100, 100), + ); + + expect(events, hasLength(3)); + expect(events[1], containsPair('type', 'mouse_move')); + expect(events[1]['x'], 0.8); + expect(events[2], containsPair('type', 'mouse_down')); + }); + }); }