fix(keyboard): wayland clipboard input prompt (#14700)

* fix(keyboard): wayland clipboard input prompt

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

* fix(wayland): Simple refactor

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

* fix(wayland): clipboard input, remove unused code

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

* fix(wayland): Simple refactor

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

* fix(wayland): dialog, better enableAndContinue

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

* fix(wayland): input dialog consent

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

* fix(wayland): prompt text

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

* fix(wayland): text input

1. Use `keysym` for the installed version if possible.
2. Use the clipboard if the string cannot be fully handled by `keysym`.

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

* fix(wayland): input prompt dialog

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

* fix(wayland): translations

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

* fix(wayland): dialog, title type

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

* fix(wayland): better decode_utf8_prefix()

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

* fix(wayland): better process_chr()

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

* fix(wayland): unit tests

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

* fix(wayland): input prompt dialog, no icon

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

* fix(wayland): input dialog, Toast show the result

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

* fix(wayland): input dialog, showToast() on persist failed

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

* fix(wayland): input prompt, better dialog

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

* fix(wayland): input prompt dialog, translations

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

* fix(input): better wayland clipboard input prompt

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

* fix(input): wayland clipboard, link external app

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

* fix(input): trivial changes

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

* fix(input): wayland clipboard input, dialog content

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

* fix(input): tranlsations

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

* fix(input): translations

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

* fix(input): translations

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

* fix(input): translations

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-06-02 16:06:35 +08:00
committed by GitHub
parent 00032854eb
commit 3217125dd3
60 changed files with 1144 additions and 103 deletions

View File

@@ -13,8 +13,64 @@ import 'package:flutter_hbb/models/model.dart';
import 'package:flutter_hbb/models/platform_model.dart';
import 'package:flutter_hbb/utils/multi_window_manager.dart';
import 'package:get/get.dart';
import 'package:url_launcher/url_launcher.dart';
bool isEditOsPassword = false;
const String kPeerOptionAllowWaylandKeyboard = 'allow-wayland-keyboard';
const String kWaylandKeyboardIssueUrl =
'https://github.com/rustdesk/rustdesk/issues/14586';
final Set<String> _waylandKeyboardPromptSuppressedConnectionIds = <String>{};
Future<bool> openWaylandKeyboardIssueUrl() {
return launchUrl(
Uri.parse(kWaylandKeyboardIssueUrl),
mode: LaunchMode.externalApplication,
);
}
bool isWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
return _waylandKeyboardPromptSuppressedConnectionIds.contains(connectionId);
}
void setWaylandKeyboardPromptSuppressedForConnection(
String connectionId, bool suppressed) {
if (suppressed) {
_waylandKeyboardPromptSuppressedConnectionIds.add(connectionId);
} else {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
}
void clearWaylandKeyboardPromptSuppressedForConnection(String connectionId) {
_waylandKeyboardPromptSuppressedConnectionIds.remove(connectionId);
}
bool shouldShowWaylandKeyboardPrompt({
required String connectionId,
required bool isWaylandPeer,
required bool allowWaylandKeyboardRemembered,
}) {
return isWaylandPeer &&
!allowWaylandKeyboardRemembered &&
!isWaylandKeyboardPromptSuppressedForConnection(connectionId);
}
Widget waylandKeyboardScopeChip(BuildContext context, String text) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
border: Border.all(color: colorScheme.primary.withOpacity(0.35)),
),
child: Text(
text,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600),
),
);
}
// macOS privacy mode blacks out all online displays, so switching the remote
// display does not weaken the local privacy protection.
@@ -93,12 +149,179 @@ handleOsPasswordAction(
}
}
void showWaylandKeyboardInputWarningDialog(
{required String id,
required String connectionId,
required FFI ffi,
required Future<void> Function() onEnable}) {
bool remember = false;
bool consentInProgress = false;
bool dialogClosed = false;
final dialogFuture = ffi.dialogManager.show((setState, close, context) {
void safeSetState(VoidCallback fn) {
if (dialogClosed) {
return;
}
try {
setState(fn);
} catch (e) {
debugPrint('Ignore setState after dialog disposal: $e');
}
}
void closeDialog() {
if (dialogClosed) {
return;
}
dialogClosed = true;
close();
}
Future<void> enableAndContinue() async {
if (consentInProgress || dialogClosed) {
return;
}
consentInProgress = true;
safeSetState(() {});
try {
await onEnable();
} catch (e, st) {
debugPrint('Failed to enable Wayland keyboard input consent: $e');
debugPrintStack(stackTrace: st);
consentInProgress = false;
safeSetState(() {});
return;
}
ffi.inputModel.keyboardInputAllowed = true;
var rememberPersisted = true;
if (remember) {
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, true));
} catch (e) {
rememberPersisted = false;
debugPrint('Failed to persist Wayland keyboard input consent: $e');
}
}
// Always suppress prompt for current connection after explicit consent.
setWaylandKeyboardPromptSuppressedForConnection(connectionId, true);
closeDialog();
if (remember && !rememberPersisted) {
// It's a rare edge case that persisting the user's choice fails.
// Failed to persist the user's choice, but still allow keyboard input for current session.
showToast(translate('Failed'));
}
}
void cancel() {
if (consentInProgress) {
return;
}
closeDialog();
}
return CustomAlertDialog(
title: null,
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
msgboxContent(
'',
'wayland-keyboard-input-disabled-tip',
'wayland-keyboard-input-consent-tip',
),
SizedBox(height: isMobile ? 2 : 6),
if (isMobile) ...[
Text(
translate('wayland-keyboard-input-applies-to-tip'),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
).marginOnly(bottom: 6),
Wrap(
spacing: 6,
runSpacing: 6,
children: [
waylandKeyboardScopeChip(
context, translate('Send clipboard keystrokes')),
waylandKeyboardScopeChip(
context, translate('wayland-soft-keyboard-input-label')),
],
).marginOnly(bottom: 10),
],
TextButton(
onPressed: consentInProgress
? null
: () async {
try {
final opened = await openWaylandKeyboardIssueUrl();
if (!opened) {
// Opening this optional help link almost never fails in
// normal desktop environments. Keep the result handled
// for review hygiene, but avoid a low-value user toast.
debugPrint('Failed to open Wayland keyboard issue URL');
}
} catch (e) {
debugPrint(
'Failed to open Wayland keyboard issue URL: $e');
}
},
style: TextButton.styleFrom(
foregroundColor: Colors.blue,
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
translate('Why this happens'),
style: const TextStyle(decoration: TextDecoration.underline),
),
).marginOnly(bottom: 6),
CheckboxListTile(
value: remember,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(translate('remember-wayland-keyboard-choice-tip')),
onChanged: consentInProgress
? null
: (v) {
safeSetState(() => remember = v == true);
},
),
],
),
actions: [
dialogButton(
'Cancel',
onPressed: consentInProgress ? null : cancel,
isOutline: true,
),
dialogButton(
'OK',
onPressed:
consentInProgress ? null : () => unawaited(enableAndContinue()),
),
],
onCancel: consentInProgress ? null : cancel,
onSubmit: consentInProgress ? null : () => unawaited(enableAndContinue()),
);
}, clickMaskDismiss: false, backDismiss: false);
unawaited(dialogFuture.whenComplete(() => dialogClosed = true));
}
List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final perms = ffiModel.permissions;
final sessionId = ffi.sessionId;
final isDefaultConn = ffi.connType == ConnType.defaultConn;
final isWaylandPeer = pi.platform == kPeerPlatformLinux && pi.isWayland;
List<TTextMenu> v = [];
// elevation
@@ -148,11 +371,60 @@ List<TTextMenu> toolbarControls(BuildContext context, String id, FFI ffi) {
v.add(TTextMenu(
child: Text(translate('Send clipboard keystrokes')),
onPressed: () async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
Future<void> sendClipboardKeystrokes() async {
ClipboardData? data = await Clipboard.getData(Clipboard.kTextPlain);
if (data != null && data.text != null) {
bind.sessionInputString(
sessionId: sessionId, value: data.text ?? "");
}
}
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: isWaylandPeer,
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
ffi.inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: id,
connectionId: sessionId.toString(),
ffi: ffi,
onEnable: sendClipboardKeystrokes,
);
return;
}
await sendClipboardKeystrokes();
}));
}
if (isDefaultConn &&
isWaylandPeer &&
(mainGetPeerBoolOptionSync(id, kPeerOptionAllowWaylandKeyboard) ||
isWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString()))) {
v.add(TTextMenu(
child: Text(translate('wayland-keyboard-input-reset-choice-tip')),
onPressed: () async {
var persistedCleared = false;
try {
await bind.mainSetPeerOption(
id: id,
key: kPeerOptionAllowWaylandKeyboard,
value: bool2option(kPeerOptionAllowWaylandKeyboard, false));
persistedCleared = true;
} catch (e) {
debugPrint(
'Failed to clear persisted Wayland keyboard permission: $e');
} finally {
clearWaylandKeyboardPromptSuppressedForConnection(
sessionId.toString());
ffi.inputModel.keyboardInputAllowed = false;
if (isMobile) {
await ffi.invokeMethod("enable_soft_keyboard", false);
}
}
showToast(translate(persistedCleared ? 'Successful' : 'Failed'));
}));
}
// reset canvas
@@ -766,7 +1038,8 @@ List<TToggleMenu> toolbarPrivacyMode(
final ffiModel = ffi.ffiModel;
final pi = ffiModel.pi;
final sessionId = ffi.sessionId;
final hasPrivacyModePermission = ffiModel.permissions['privacy_mode'] != false;
final hasPrivacyModePermission =
ffiModel.permissions['privacy_mode'] != false;
// Backend revocation already attempts to turn privacy mode off.
// Still keep this menu when privacy mode is active, so users can turn it off
@@ -776,8 +1049,8 @@ List<TToggleMenu> toolbarPrivacyMode(
}
getDefaultMenu(Future<void> Function(SessionID sid, String opt) toggleFunc) {
final enabled =
!ffiModel.viewOnly && (hasPrivacyModePermission || privacyModeState.isNotEmpty);
final enabled = !ffiModel.viewOnly &&
(hasPrivacyModePermission || privacyModeState.isNotEmpty);
return TToggleMenu(
value: privacyModeState.isNotEmpty,
onChanged: enabled

View File

@@ -101,6 +101,9 @@ class _RemotePageState extends State<RemotePage>
Function(bool)? _onEnterOrLeaveImage4Toolbar;
late FFI _ffi;
Worker? _waylandKeyboardModeWorker;
bool _waylandKeyboardModeNormalized = false;
bool _waylandKeyboardModeNormalizing = false;
SessionID get sessionId => _ffi.sessionId;
@@ -178,6 +181,48 @@ class _RemotePageState extends State<RemotePage>
// Register callback to cancel debounce timer when relative mouse mode is disabled
_ffi.inputModel.onRelativeMouseModeDisabled =
_cancelPointerLockCenterDebounceTimer;
_waylandKeyboardModeWorker = ever(_ffi.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
});
if (_ffi.ffiModel.pi.isSet.value) {
unawaited(_normalizeWaylandKeyboardModeIfNeeded());
}
}
Future<void> _normalizeWaylandKeyboardModeIfNeeded() async {
if (!mounted ||
_waylandKeyboardModeNormalized ||
_waylandKeyboardModeNormalizing) {
return;
}
_waylandKeyboardModeNormalizing = true;
try {
final pi = _ffi.ffiModel.pi;
if (pi.platform != kPeerPlatformLinux || !pi.isWayland) return;
final mapSupported = bind.sessionIsKeyboardModeSupported(
sessionId: sessionId, mode: kKeyMapMode);
if (!mapSupported) return;
final current = await bind.sessionGetKeyboardMode(sessionId: sessionId);
if (!mounted) return;
if (current == kKeyMapMode) {
_waylandKeyboardModeNormalized = true;
return;
}
await bind.sessionSetKeyboardMode(
sessionId: sessionId, value: kKeyMapMode);
if (!mounted) return;
await _ffi.inputModel.updateKeyboardMode();
if (!mounted) return;
_waylandKeyboardModeNormalized = true;
} catch (e, st) {
debugPrint('Failed to normalize Wayland keyboard mode: $e');
debugPrintStack(stackTrace: st);
} finally {
_waylandKeyboardModeNormalizing = false;
}
}
/// Cancel the pointer lock center debounce timer
@@ -318,6 +363,7 @@ class _RemotePageState extends State<RemotePage>
_pointerLockCenterDebounceTimer?.cancel();
_pointerLockCenterDebounceTimer = null;
_waylandKeyboardModeWorker?.dispose();
// Clear callback reference to prevent memory leaks and stale references
_ffi.inputModel.onRelativeMouseModeDisabled = null;
// Relative mouse mode cleanup is centralized in FFI.close(closeSession: ...).
@@ -331,6 +377,9 @@ class _RemotePageState extends State<RemotePage>
_ffi.imageModel.disposeImage();
_ffi.cursorModel.disposeImages();
_rawKeyFocusNode.dispose();
if (closeSession) {
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
}
await _ffi.close(closeSession: closeSession);
_timer?.cancel();
_ffi.dialogManager.dismissAll();

View File

@@ -2324,18 +2324,8 @@ class _KeyboardMenu extends StatelessWidget {
continue;
}
if (pi.isWayland) {
// Legacy mode is hidden on desktop control side because dead keys
// don't work properly on Wayland. When the control side is mobile,
// Legacy mode is used automatically (mobile always sends Legacy events).
if (mode.key == kKeyLegacyMode) {
continue;
}
// Translate mode requires server >= 1.4.6.
if (mode.key == kKeyTranslateMode &&
versionCmp(pi.version, '1.4.6') < 0) {
continue;
}
if (pi.isWayland && mode.key != kKeyMapMode) {
continue;
}
var text = translate(mode.menu);

View File

@@ -75,6 +75,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
final FocusNode _physicalFocusNode = FocusNode();
var _showEdit = false; // use soft keyboard
Worker? _waylandKeyboardGateWorker;
bool _waylandKeyboardGateInitialized = false;
InputModel get inputModel => gFFI.inputModel;
SessionID get sessionId => gFFI.sessionId;
@@ -121,6 +124,20 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
isKeyboardVisible: keyboardVisibilityController.isVisible);
});
WidgetsBinding.instance.addObserver(this);
inputModel.keyboardInputAllowed = true;
// Wayland sessions may use clipboard-based text input on the controlled side.
// Require explicit user confirmation before allowing soft-keyboard and
// clipboard-assisted text input. Physical keyboard events are not gated here.
_waylandKeyboardGateWorker = ever(gFFI.ffiModel.pi.isSet, (bool isSet) {
if (isSet) {
_initWaylandKeyboardGateIfNeeded();
}
});
if (gFFI.ffiModel.pi.isSet.value) {
_initWaylandKeyboardGateIfNeeded();
}
}
@override
@@ -143,6 +160,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
await gFFI.invokeMethod("enable_soft_keyboard", true);
_mobileFocusNode.dispose();
_physicalFocusNode.dispose();
clearWaylandKeyboardPromptSuppressedForConnection(sessionId.toString());
_waylandKeyboardGateWorker?.dispose();
inputModel.keyboardInputAllowed = true;
await gFFI.close();
_timer?.cancel();
_iosKeyboardWorkaroundTimer?.cancel();
@@ -171,6 +191,40 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.invokeMethod("try_sync_clipboard");
}
bool _shouldGateKeyboardForWayland() {
if (!(isAndroid || isIOS)) return false;
final pi = gFFI.ffiModel.pi;
return pi.platform == kPeerPlatformLinux && pi.isWayland;
}
void _initWaylandKeyboardGateIfNeeded() {
if (!mounted) return;
if (_waylandKeyboardGateInitialized) return;
if (!_shouldGateKeyboardForWayland()) return;
_waylandKeyboardGateInitialized = true;
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (!shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = true;
return;
}
inputModel.keyboardInputAllowed = false;
// Ensure soft keyboard is not active before user confirms.
_showEdit = false;
gFFI.invokeMethod("enable_soft_keyboard", false);
_mobileFocusNode.unfocus();
_physicalFocusNode.requestFocus();
setState(() {});
}
// to-do: It should be better to use transparent color instead of the bgColor.
// But for now, the transparent color will cause the canvas to be white.
// I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
@@ -302,7 +356,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
content == '【】')) {
// can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
bind.sessionInputString(sessionId: sessionId, value: content);
openKeyboard();
_openKeyboardUnlocked();
return;
}
bind.sessionInputString(sessionId: sessionId, value: content);
@@ -314,6 +368,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
// handle mobile virtual keyboard
void handleSoftKeyboardInput(String newValue) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (isIOS) {
_handleIOSSoftKeyboardInput(newValue);
} else {
@@ -322,6 +379,9 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
void inputChar(String char) {
if (!inputModel.keyboardInputAllowed) {
return;
}
if (char == '\n') {
char = 'VK_RETURN';
} else if (char == ' ') {
@@ -331,6 +391,29 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
}
void openKeyboard() {
final allowWaylandKeyboard =
mainGetPeerBoolOptionSync(widget.id, kPeerOptionAllowWaylandKeyboard);
if (shouldShowWaylandKeyboardPrompt(
connectionId: sessionId.toString(),
isWaylandPeer: _shouldGateKeyboardForWayland(),
allowWaylandKeyboardRemembered: allowWaylandKeyboard,
)) {
inputModel.keyboardInputAllowed = false;
showWaylandKeyboardInputWarningDialog(
id: widget.id,
connectionId: sessionId.toString(),
ffi: gFFI,
onEnable: () async {
_openKeyboardUnlocked();
},
);
return;
}
_openKeyboardUnlocked();
}
void _openKeyboardUnlocked() {
inputModel.keyboardInputAllowed = true;
gFFI.invokeMethod("enable_soft_keyboard", true);
// destroy first, so that our _value trick can work
_value = initText;

View File

@@ -474,6 +474,10 @@ class InputModel {
late final SessionID sessionId;
// Local gate for clipboard-assisted input flows on mobile Wayland dialogs.
// It should not block physical keyboard events.
bool keyboardInputAllowed = true;
bool get keyboardPerm => parent.target!.ffiModel.keyboard;
String get id => parent.target?.id ?? '';
String? get peerPlatform => parent.target?.ffiModel.pi.platform;