fix: smooth remote desktop input over webrtc

This commit is contained in:
Haitao Pan 2026-06-09 10:46:58 +08:00
parent 064e0fdc27
commit b356714d52
4 changed files with 160 additions and 12 deletions

View File

@ -32,6 +32,14 @@ Map<String, Object?> desktopOfferParams({
};
}
bool desktopShouldDropInputEvent(
Map<String, dynamic> 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));
}

View File

@ -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<String, dynamic> 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),

View File

@ -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 {

View File

@ -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 = <Map<String, dynamic>>[];
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 = <Map<String, dynamic>>[];
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 = <Map<String, dynamic>>[];
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'));
});
});
}