Compare commits

...

13 Commits

Author SHA1 Message Date
cui
20f11018ce fix: lte should be lt like in linux.rs (#14344) 2026-02-19 22:24:32 +08:00
Nicola Spieser Buiss
9345fb754a fix: correct typos and improve code clarity (#14341)
- Fix 'clipbard' typos in clipboard.rs (function names, comments, strings)
- Fix 'seperate' typo in x11/server.rs comment
- Replace !is_ok() with idiomatic is_err() in updater.rs
- Fix double backtick typo in updater.rs comment

Co-authored-by: Ocean <ocean@Mac-mini-von-Ocean.local>
2026-02-17 14:29:50 +08:00
fufesou
779b7aaf02 feat(wayland): keyboard mode, legacy translate (#14317)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-15 16:43:21 +08:00
21pages
b268aa1061 Fix some single device multiple ids scenarios on MacOS (#14196)
* fix(macos): sync config to root when root config is empty

Signed-off-by: 21pages <sunboeasy@gmail.com>

* fix(server): gate startup on initial config sync; document CheckIfResendPk limitation

  - wait up to 3s for initial root->local config sync before starting server services
  - continue startup when timeout is hit, while keeping sync/watch running in background
  - avoid blocking non-server process startup
  - clarify that CheckIfResendPk only re-registers PK for current ID and does not solve multi-ID when root uses a non-default mac-generated ID

Signed-off-by: 21pages <sunboeasy@gmail.com>

---------

Signed-off-by: 21pages <sunboeasy@gmail.com>
2026-02-15 16:12:26 +08:00
Vance
40f86fa639 fix(mobile): account for safe area padding in canvas size calculation (#14285)
* fix(mobile): account for safe area padding in canvas size calculation

* fix(mobile): differentiate safe area handling for portrait vs landscape

* refact(ios): Simple refactor

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

* fix(ios): canvas getSize, test -> Android

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

* fix: comments

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

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: fufesou <linlong1266@gmail.com>
2026-02-15 14:52:27 +08:00
rustdesk
980bc11e68 update common 2026-02-14 17:48:53 +08:00
Shaikh Naasir
85db677982 docs: fix typos in clipboard documentation (#13521)
Signed-off-by: Naasir <yoursdeveloper@protonmail.com>
2026-02-14 01:06:25 +08:00
fufesou
2842315b1d Fix/linux shortcuts inhibit (#14302)
* feat: Inhibit system shortcuts on Linux

Fixes #13013.

Signed-off-by: Max von Forell <max@vonforell.de>

* fix(linux): shortcuts inhibit

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

---------

Signed-off-by: Max von Forell <max@vonforell.de>
Signed-off-by: fufesou <linlong1266@gmail.com>
Co-authored-by: Max von Forell <max@vonforell.de>
2026-02-11 16:11:47 +08:00
fufesou
6c541f7bfd fix(xdo): deb, libxdo3 | libxdo4 (#14314)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-11 16:11:15 +08:00
VenusGirl❤
067fab2b73 Update Korean (#14298)
Correct spacing and spelling
2026-02-10 18:48:30 +08:00
fufesou
de6bf9dc7e fix(ios): Add defensive timer cancellation for keyboard visibility (#14301)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-09 15:54:22 +08:00
fufesou
54eae37038 fix(ios): workaround physical keyboard after virtual keyboard hidden (#14207)
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-02-09 00:36:25 +08:00
Hugo Breda
0118e16132 PT-BR language update (#14295)
* PT-BR language update

@rustdesk
Please merge. Thanks

* Update ptbr.rs

* Update ptbr.rs

Please submit, i will get back soon and finish all other stuff.

* PT-BR language update

Completed all missing PT-BR translations.
2026-02-09 00:31:47 +08:00
27 changed files with 1636 additions and 109 deletions

View File

@@ -299,7 +299,7 @@ Version: %s
Architecture: %s
Maintainer: rustdesk <info@rustdesk.com>
Homepage: https://rustdesk.com
Depends: libgtk-3-0, libxcb-randr0, libxdo3, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Depends: libgtk-3-0, libxcb-randr0, libxdo3 | libxdo4, libxfixes3, libxcb-shape0, libxcb-xfixes0, libasound2, libsystemd0, curl, libva2, libva-drm2, libva-x11-2, libgstreamer-plugins-base1.0-0, libpam0g, gstreamer1.0-pipewire%s
Recommends: libayatana-appindicator3-1
Description: A remote control software.

View File

@@ -55,6 +55,7 @@
],
"finish-args": [
"--share=ipc",
"--socket=wayland",
"--socket=x11",
"--share=network",
"--filesystem=home",

View File

@@ -2538,6 +2538,49 @@ class WaylandCard extends StatefulWidget {
class _WaylandCardState extends State<WaylandCard> {
final restoreTokenKey = 'wayland-restore-token';
static const _kClearShortcutsInhibitorEventKey =
'clear-gnome-shortcuts-inhibitor-permission-res';
final _clearShortcutsInhibitorFailedMsg = ''.obs;
// Don't show the shortcuts permission reset button for now.
// Users can change it manually:
// "Settings" -> "Apps" -> "RustDesk" -> "Permissions" -> "Inhibit Shortcuts".
// For resetting(clearing) the permission from the portal permission store, you can
// use (replace <desktop-id> with the RustDesk desktop file ID):
// busctl --user call org.freedesktop.impl.portal.PermissionStore \
// /org/freedesktop/impl/portal/PermissionStore org.freedesktop.impl.portal.PermissionStore \
// DeletePermission sss "gnome" "shortcuts-inhibitor" "<desktop-id>"
// On a native install this is typically "rustdesk.desktop"; on Flatpak it is usually
// the exported desktop ID derived from the Flatpak app-id (e.g. "com.rustdesk.RustDesk.desktop").
//
// We may add it back in the future if needed.
final showResetInhibitorPermission = false;
@override
void initState() {
super.initState();
if (showResetInhibitorPermission) {
platformFFI.registerEventHandler(
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey,
(evt) async {
if (!mounted) return;
if (evt['success'] == true) {
setState(() {});
} else {
_clearShortcutsInhibitorFailedMsg.value =
evt['msg'] as String? ?? 'Unknown error';
}
});
}
}
@override
void dispose() {
if (showResetInhibitorPermission) {
platformFFI.unregisterEventHandler(
_kClearShortcutsInhibitorEventKey, _kClearShortcutsInhibitorEventKey);
}
super.dispose();
}
@override
Widget build(BuildContext context) {
@@ -2545,9 +2588,16 @@ class _WaylandCardState extends State<WaylandCard> {
future: bind.mainHandleWaylandScreencastRestoreToken(
key: restoreTokenKey, value: "get"),
hasData: (restoreToken) {
final hasShortcutsPermission = showResetInhibitorPermission &&
bind.mainGetCommonSync(
key: "has-gnome-shortcuts-inhibitor-permission") ==
"true";
final children = [
if (restoreToken.isNotEmpty)
_buildClearScreenSelection(context, restoreToken),
if (hasShortcutsPermission)
_buildClearShortcutsInhibitorPermission(context),
];
return Offstage(
offstage: children.isEmpty,
@@ -2592,6 +2642,50 @@ class _WaylandCardState extends State<WaylandCard> {
),
);
}
Widget _buildClearShortcutsInhibitorPermission(BuildContext context) {
onConfirm() {
_clearShortcutsInhibitorFailedMsg.value = '';
bind.mainSetCommon(
key: "clear-gnome-shortcuts-inhibitor-permission", value: "");
gFFI.dialogManager.dismissAll();
}
showConfirmMsgBox() => msgBoxCommon(
gFFI.dialogManager,
'Confirmation',
Text(
translate('confirm-clear-shortcuts-inhibitor-permission-tip'),
),
[
dialogButton('OK', onPressed: onConfirm),
dialogButton('Cancel',
onPressed: () => gFFI.dialogManager.dismissAll())
]);
return Column(children: [
Obx(
() => _clearShortcutsInhibitorFailedMsg.value.isEmpty
? Offstage()
: Align(
alignment: Alignment.topLeft,
child: Text(_clearShortcutsInhibitorFailedMsg.value,
style: DefaultTextStyle.of(context)
.style
.copyWith(color: Colors.red))
.marginOnly(bottom: 10.0)),
),
_Button(
'Reset keyboard shortcuts permission',
showConfirmMsgBox,
tip: 'clear-shortcuts-inhibitor-permission-tip',
style: ButtonStyle(
backgroundColor: MaterialStateProperty.all<Color>(
Theme.of(context).colorScheme.error.withOpacity(0.75)),
),
),
]);
}
}
// ignore: non_constant_identifier_names

View File

@@ -1861,8 +1861,18 @@ class _KeyboardMenu extends StatelessWidget {
continue;
}
if (pi.isWayland && mode.key != kKeyMapMode) {
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;
}
}
var text = translate(mode.menu);

View File

@@ -68,6 +68,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
double _viewInsetsBottom = 0;
final _uniqueKey = UniqueKey();
Timer? _timerDidChangeMetrics;
Timer? _iosKeyboardWorkaroundTimer;
final _blockableOverlayState = BlockableOverlayState();
@@ -140,6 +141,7 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
await gFFI.close();
_timer?.cancel();
_timerDidChangeMetrics?.cancel();
_iosKeyboardWorkaroundTimer?.cancel();
gFFI.dialogManager.dismissAll();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
overlays: SystemUiOverlay.values);
@@ -206,7 +208,24 @@ class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
gFFI.ffiModel.pi.version.isNotEmpty) {
gFFI.invokeMethod("enable_soft_keyboard", false);
}
// Workaround for iOS: physical keyboard input fails after virtual keyboard is hidden
// https://github.com/flutter/flutter/issues/39900
// https://github.com/rustdesk/rustdesk/discussions/11843#discussioncomment-13499698 - Virtual keyboard issue
if (isIOS) {
_iosKeyboardWorkaroundTimer?.cancel();
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 100), () {
if (!mounted) return;
_physicalFocusNode.unfocus();
_iosKeyboardWorkaroundTimer = Timer(Duration(milliseconds: 50), () {
if (!mounted) return;
_physicalFocusNode.requestFocus();
});
});
}
} else {
_iosKeyboardWorkaroundTimer?.cancel();
_iosKeyboardWorkaroundTimer = null;
_timer?.cancel();
_timer = Timer(kMobileDelaySoftKeyboardFocus, () {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,

View File

@@ -2215,10 +2215,32 @@ class CanvasModel with ChangeNotifier {
double w = size.width - leftToEdge - rightToEdge;
double h = size.height - topToEdge - bottomToEdge;
if (isMobile) {
// Account for horizontal safe area insets on both orientations.
w = w - mediaData.padding.left - mediaData.padding.right;
// Vertically, subtract the bottom keyboard inset (viewInsets.bottom) and any
// bottom overlay (e.g. key-help tools) so the canvas is not covered.
h = h -
mediaData.viewInsets.bottom -
(parent.target?.cursorModel.keyHelpToolsRectToAdjustCanvas?.bottom ??
0);
// Orientation-specific handling:
// - Portrait: additionally subtract top padding (e.g. status bar / notch)
// - Landscape: does not subtract mediaData.padding.top/bottom (home indicator auto-hides)
final isPortrait = size.height > size.width;
if (isPortrait) {
// In portrait mode, subtract the top safe-area padding (e.g. status bar / notch)
// so the remote image is not truncated, while keeping the bottom inset to avoid
// introducing unnecessary blank space around the canvas.
//
// iOS -> Android, portrait, adjust mode:
// h = h (no padding subtracted): top and bottom are truncated
// https://github.com/user-attachments/assets/30ed4559-c27e-432b-847f-8fec23c9f998
// h = h - top - bottom: extra blank spaces appear
// https://github.com/user-attachments/assets/12a98817-3b4e-43aa-be0f-4b03cf364b7e
// h = h - top (current): works fine
// https://github.com/user-attachments/assets/95f047f2-7f47-4a36-8113-5023989a0c81
h = h - mediaData.padding.top;
}
}
return Size(w < 0 ? 0 : w, h < 0 ? 0 : h);
}

View File

@@ -1,6 +1,6 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.10)
project(runner LANGUAGES CXX)
project(runner LANGUAGES C CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
@@ -54,6 +54,55 @@ add_subdirectory(${FLUTTER_MANAGED_DIR})
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
# Wayland protocol for keyboard shortcuts inhibit
pkg_check_modules(WAYLAND_CLIENT IMPORTED_TARGET wayland-client)
pkg_check_modules(WAYLAND_PROTOCOLS_PKG QUIET wayland-protocols)
pkg_check_modules(WAYLAND_SCANNER_PKG QUIET wayland-scanner)
if(WAYLAND_PROTOCOLS_PKG_FOUND)
pkg_get_variable(WAYLAND_PROTOCOLS_DIR wayland-protocols pkgdatadir)
endif()
if(WAYLAND_SCANNER_PKG_FOUND)
pkg_get_variable(WAYLAND_SCANNER wayland-scanner wayland_scanner)
endif()
if(WAYLAND_CLIENT_FOUND AND WAYLAND_PROTOCOLS_DIR AND WAYLAND_SCANNER)
set(KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL
"${WAYLAND_PROTOCOLS_DIR}/unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml")
if(EXISTS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL})
set(WAYLAND_GENERATED_DIR "${CMAKE_CURRENT_BINARY_DIR}/wayland-protocols")
file(MAKE_DIRECTORY ${WAYLAND_GENERATED_DIR})
# Generate client header
add_custom_command(
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
COMMAND ${WAYLAND_SCANNER} client-header
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
VERBATIM
)
# Generate protocol code
add_custom_command(
OUTPUT "${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
COMMAND ${WAYLAND_SCANNER} private-code
${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
DEPENDS ${KEYBOARD_SHORTCUTS_INHIBIT_PROTOCOL}
VERBATIM
)
set(WAYLAND_PROTOCOL_SOURCES
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
"${WAYLAND_GENERATED_DIR}/keyboard-shortcuts-inhibit-unstable-v1-protocol.c"
)
set(HAS_KEYBOARD_SHORTCUTS_INHIBIT TRUE)
endif()
endif()
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
# Define the application target. To change its name, change BINARY_NAME above,
@@ -63,9 +112,11 @@ add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
add_executable(${BINARY_NAME}
"main.cc"
"my_application.cc"
"wayland_shortcuts_inhibit.cc"
"bump_mouse.cc"
"bump_mouse_x11.cc"
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
${WAYLAND_PROTOCOL_SOURCES}
)
# Apply the standard set of build settings. This can be removed for applications
@@ -78,6 +129,13 @@ target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_link_libraries(${BINARY_NAME} PRIVATE ${CMAKE_DL_LIBS})
# target_link_libraries(${BINARY_NAME} PRIVATE librustdesk)
# Wayland support for keyboard shortcuts inhibit
if(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
target_compile_definitions(${BINARY_NAME} PRIVATE HAS_KEYBOARD_SHORTCUTS_INHIBIT)
target_include_directories(${BINARY_NAME} PRIVATE ${WAYLAND_GENERATED_DIR})
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_CLIENT)
endif()
# Run the Flutter tool portions of the build. This must not be removed.
add_dependencies(${BINARY_NAME} flutter_assemble)

View File

@@ -6,6 +6,11 @@
#ifdef GDK_WINDOWING_X11
#include <gdk/gdkx.h>
#endif
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
#include "wayland_shortcuts_inhibit.h"
#endif
#include <desktop_multi_window/desktop_multi_window_plugin.h>
#include "flutter/generated_plugin_registrant.h"
@@ -91,6 +96,13 @@ static void my_application_activate(GApplication* application) {
gtk_widget_show(GTK_WIDGET(window));
gtk_widget_show(GTK_WIDGET(view));
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
// Register callback for sub-windows created by desktop_multi_window plugin
// Only sub-windows (remote windows) need keyboard shortcuts inhibition
desktop_multi_window_plugin_set_window_created_callback(
(WindowCreatedCallback)wayland_shortcuts_inhibit_init_for_subwindow);
#endif
fl_register_plugins(FL_PLUGIN_REGISTRY(view));
g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new();

View File

@@ -0,0 +1,244 @@
// Wayland keyboard shortcuts inhibit implementation
// Uses the zwp_keyboard_shortcuts_inhibit_manager_v1 protocol to request
// the compositor to disable system shortcuts for specific windows.
#include "wayland_shortcuts_inhibit.h"
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
#include <cstring>
#include <gdk/gdkwayland.h>
#include <wayland-client.h>
#include "keyboard-shortcuts-inhibit-unstable-v1-client-protocol.h"
// Data structure to hold inhibitor state for each window
typedef struct {
struct zwp_keyboard_shortcuts_inhibit_manager_v1* manager;
struct zwp_keyboard_shortcuts_inhibitor_v1* inhibitor;
} ShortcutsInhibitData;
// Cleanup function for ShortcutsInhibitData
static void shortcuts_inhibit_data_free(gpointer data) {
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(data);
if (inhibit_data->inhibitor != NULL) {
zwp_keyboard_shortcuts_inhibitor_v1_destroy(inhibit_data->inhibitor);
}
if (inhibit_data->manager != NULL) {
zwp_keyboard_shortcuts_inhibit_manager_v1_destroy(inhibit_data->manager);
}
g_free(inhibit_data);
}
// Wayland registry handler to find the shortcuts inhibit manager
static void registry_handle_global(void* data, struct wl_registry* registry,
uint32_t name, const char* interface,
uint32_t /*version*/) {
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(data);
if (strcmp(interface,
zwp_keyboard_shortcuts_inhibit_manager_v1_interface.name) == 0) {
inhibit_data->manager =
static_cast<zwp_keyboard_shortcuts_inhibit_manager_v1*>(wl_registry_bind(
registry, name, &zwp_keyboard_shortcuts_inhibit_manager_v1_interface,
1));
}
}
static void registry_handle_global_remove(void* /*data*/, struct wl_registry* /*registry*/,
uint32_t /*name*/) {
// Not needed for this use case
}
static const struct wl_registry_listener registry_listener = {
registry_handle_global,
registry_handle_global_remove,
};
// Inhibitor event handlers
static void inhibitor_active(void* /*data*/,
struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) {
// Inhibitor is now active, shortcuts are being captured
}
static void inhibitor_inactive(void* /*data*/,
struct zwp_keyboard_shortcuts_inhibitor_v1* /*inhibitor*/) {
// Inhibitor is now inactive, shortcuts restored to compositor
}
static const struct zwp_keyboard_shortcuts_inhibitor_v1_listener inhibitor_listener = {
inhibitor_active,
inhibitor_inactive,
};
// Forward declaration
static void uninhibit_keyboard_shortcuts(GtkWindow* window);
// Inhibit keyboard shortcuts on Wayland for a specific window
static void inhibit_keyboard_shortcuts(GtkWindow* window) {
GdkDisplay* display = gtk_widget_get_display(GTK_WIDGET(window));
if (!GDK_IS_WAYLAND_DISPLAY(display)) {
return;
}
// Check if already inhibited for this window
if (g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data") != NULL) {
return;
}
ShortcutsInhibitData* inhibit_data = g_new0(ShortcutsInhibitData, 1);
struct wl_display* wl_display = gdk_wayland_display_get_wl_display(display);
if (wl_display == NULL) {
shortcuts_inhibit_data_free(inhibit_data);
return;
}
struct wl_registry* registry = wl_display_get_registry(wl_display);
if (registry == NULL) {
shortcuts_inhibit_data_free(inhibit_data);
return;
}
wl_registry_add_listener(registry, &registry_listener, inhibit_data);
wl_display_roundtrip(wl_display);
if (inhibit_data->manager == NULL) {
wl_registry_destroy(registry);
shortcuts_inhibit_data_free(inhibit_data);
return;
}
GdkWindow* gdk_window = gtk_widget_get_window(GTK_WIDGET(window));
if (gdk_window == NULL) {
wl_registry_destroy(registry);
shortcuts_inhibit_data_free(inhibit_data);
return;
}
struct wl_surface* surface = gdk_wayland_window_get_wl_surface(gdk_window);
if (surface == NULL) {
wl_registry_destroy(registry);
shortcuts_inhibit_data_free(inhibit_data);
return;
}
GdkSeat* gdk_seat = gdk_display_get_default_seat(display);
if (gdk_seat == NULL) {
wl_registry_destroy(registry);
shortcuts_inhibit_data_free(inhibit_data);
return;
}
struct wl_seat* seat = gdk_wayland_seat_get_wl_seat(gdk_seat);
if (seat == NULL) {
wl_registry_destroy(registry);
shortcuts_inhibit_data_free(inhibit_data);
return;
}
inhibit_data->inhibitor =
zwp_keyboard_shortcuts_inhibit_manager_v1_inhibit_shortcuts(
inhibit_data->manager, surface, seat);
if (inhibit_data->inhibitor == NULL) {
wl_registry_destroy(registry);
shortcuts_inhibit_data_free(inhibit_data);
return;
}
// Add listener to monitor active/inactive state
zwp_keyboard_shortcuts_inhibitor_v1_add_listener(
inhibit_data->inhibitor, &inhibitor_listener, window);
wl_display_roundtrip(wl_display);
wl_registry_destroy(registry);
// Associate the inhibit data with the window for cleanup on destroy
g_object_set_data_full(G_OBJECT(window), "shortcuts-inhibit-data",
inhibit_data, shortcuts_inhibit_data_free);
}
// Remove keyboard shortcuts inhibitor from a window
static void uninhibit_keyboard_shortcuts(GtkWindow* window) {
ShortcutsInhibitData* inhibit_data = static_cast<ShortcutsInhibitData*>(
g_object_get_data(G_OBJECT(window), "shortcuts-inhibit-data"));
if (inhibit_data == NULL) {
return;
}
// This will trigger shortcuts_inhibit_data_free via g_object_set_data
g_object_set_data(G_OBJECT(window), "shortcuts-inhibit-data", NULL);
}
// Focus event handlers for dynamic inhibitor management
static gboolean on_window_focus_in(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) {
if (GTK_IS_WINDOW(widget)) {
inhibit_keyboard_shortcuts(GTK_WINDOW(widget));
}
return FALSE; // Continue event propagation
}
static gboolean on_window_focus_out(GtkWidget* widget, GdkEventFocus* /*event*/, gpointer /*user_data*/) {
if (GTK_IS_WINDOW(widget)) {
uninhibit_keyboard_shortcuts(GTK_WINDOW(widget));
}
return FALSE; // Continue event propagation
}
// Key for marking window as having focus handlers connected
static const char* const kFocusHandlersConnectedKey = "shortcuts-inhibit-focus-handlers-connected";
// Key for marking window as having a pending realize handler
static const char* const kRealizeHandlerConnectedKey = "shortcuts-inhibit-realize-handler-connected";
// Callback when window is realized (mapped to screen)
// Sets up focus-based inhibitor management
static void on_window_realize(GtkWidget* widget, gpointer /*user_data*/) {
if (GTK_IS_WINDOW(widget)) {
// Check if focus handlers are already connected to avoid duplicates
if (g_object_get_data(G_OBJECT(widget), kFocusHandlersConnectedKey) != NULL) {
return;
}
// Connect focus events for dynamic inhibitor management
g_signal_connect(widget, "focus-in-event",
G_CALLBACK(on_window_focus_in), NULL);
g_signal_connect(widget, "focus-out-event",
G_CALLBACK(on_window_focus_out), NULL);
// Mark as connected to prevent duplicate connections
g_object_set_data(G_OBJECT(widget), kFocusHandlersConnectedKey, GINT_TO_POINTER(1));
// If window already has focus, create inhibitor now
if (gtk_window_has_toplevel_focus(GTK_WINDOW(widget))) {
inhibit_keyboard_shortcuts(GTK_WINDOW(widget));
}
}
}
// Public API: Initialize shortcuts inhibit for a sub-window
void wayland_shortcuts_inhibit_init_for_subwindow(void* view) {
GtkWidget* widget = GTK_WIDGET(view);
GtkWidget* toplevel = gtk_widget_get_toplevel(widget);
if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) {
// Check if already initialized to avoid duplicate realize handlers
if (g_object_get_data(G_OBJECT(toplevel), kFocusHandlersConnectedKey) != NULL ||
g_object_get_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey) != NULL) {
return;
}
if (gtk_widget_get_realized(toplevel)) {
// Window is already realized, set up focus handlers now
on_window_realize(toplevel, NULL);
} else {
// Mark realize handler as connected to prevent duplicate connections
// if called again before window is realized
g_object_set_data(G_OBJECT(toplevel), kRealizeHandlerConnectedKey, GINT_TO_POINTER(1));
// Wait for window to be realized
g_signal_connect(toplevel, "realize",
G_CALLBACK(on_window_realize), NULL);
}
}
}
#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)

View File

@@ -0,0 +1,22 @@
// Wayland keyboard shortcuts inhibit support
// This module provides functionality to inhibit system keyboard shortcuts
// on Wayland compositors, allowing remote desktop windows to capture all
// key events including Super, Alt+Tab, etc.
#ifndef WAYLAND_SHORTCUTS_INHIBIT_H_
#define WAYLAND_SHORTCUTS_INHIBIT_H_
#include <gtk/gtk.h>
#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
// Initialize shortcuts inhibit for a sub-window created by desktop_multi_window plugin.
// This sets up focus-based inhibitor management: inhibitor is created when
// the window gains focus and destroyed when it loses focus.
//
// @param view The FlView of the sub-window
void wayland_shortcuts_inhibit_init_for_subwindow(void* view);
#endif // defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT)
#endif // WAYLAND_SHORTCUTS_INHIBIT_H_

View File

@@ -10,7 +10,7 @@ TODO: Move this lib to a separate project.
## How it works
Terminalogies:
Terminologies:
- cliprdr: this module
- local: the endpoint which initiates a file copy events
@@ -50,7 +50,7 @@ sequenceDiagram
r ->> l: Format List Response (notified)
r ->> l: Format Data Request (requests file list)
activate l
note left of l: Retrive file list from system clipboard
note left of l: Retrieve file list from system clipboard
l ->> r: Format Data Response (containing file list)
deactivate l
note over r: Update system clipboard with received file list
@@ -84,10 +84,10 @@ and copy files to remote.
The protocol was originally designed as an extension of the Windows RDP,
so the specific message packages fits windows well.
When starting cliprdr, a thread is spawn to create a invisible window
When starting cliprdr, a thread is spawned to create an invisible window
and to subscribe to OLE clipboard events.
The window's callback (see `cliprdr_proc` in `src/windows/wf_cliprdr.c`) was
set to handle a variaty of events.
set to handle a variety of events.
Detailed implementation is shown in pictures above.
@@ -108,18 +108,18 @@ after filtering out those pointing to our FUSE directory or duplicated,
send format list directly to remote.
The cliprdr server also uses clipboard client for setting clipboard,
or retrive paths from system.
or retrieve paths from system.
#### Local File List
The local file list is a temperary list of file metadata.
The local file list is a temporary list of file metadata.
When receiving file contents PDU from peer, the server picks
out the file requested and open it for reading if necessary.
Also when receiving Format Data Request PDU from remote asking for file list,
the local file list should be rebuilt from file list retrieved from Clipboard Client.
Some caching and preloading could done on it since applications are likely to read
Some caching and preloading could be done on it since applications are likely to read
on the list sequentially.
#### FUSE server

View File

@@ -261,6 +261,8 @@ impl KeyboardControllable for Enigo {
} else {
if let Some(keyboard) = &mut self.custom_keyboard {
keyboard.key_sequence(sequence)
} else {
log::warn!("Enigo::key_sequence: no custom_keyboard set for Wayland!");
}
}
}
@@ -277,6 +279,7 @@ impl KeyboardControllable for Enigo {
if let Some(keyboard) = &mut self.custom_keyboard {
keyboard.key_down(key)
} else {
log::warn!("Enigo::key_down: no custom_keyboard set for Wayland!");
Ok(())
}
}
@@ -290,13 +293,24 @@ impl KeyboardControllable for Enigo {
} else {
if let Some(keyboard) = &mut self.custom_keyboard {
keyboard.key_up(key)
} else {
log::warn!("Enigo::key_up: no custom_keyboard set for Wayland!");
}
}
}
fn key_click(&mut self, key: Key) {
if self.tfc_key_click(key).is_err() {
self.key_down(key).ok();
self.key_up(key);
if self.is_x11 {
// X11: try tfc first, then fallback to key_down/key_up
if self.tfc_key_click(key).is_err() {
self.key_down(key).ok();
self.key_up(key);
}
} else {
if let Some(keyboard) = &mut self.custom_keyboard {
keyboard.key_click(key);
} else {
log::warn!("Enigo::key_click: no custom_keyboard set for Wayland!");
}
}
}
}

View File

@@ -98,7 +98,7 @@ unsafe fn check_x11_shm_available(c: *mut xcb_connection_t) -> Result<(), Error>
let mut e: *mut xcb_generic_error_t = std::ptr::null_mut();
let reply = xcb_shm_query_version_reply(c, cookie, &mut e as _);
if reply.is_null() {
// TODO: Should seperate SHM disabled from SHM not supported?
// TODO: Should separate SHM disabled from SHM not supported?
return Err(Error::UnsupportedExtension);
} else {
// https://github.com/FFmpeg/FFmpeg/blob/6229e4ac425b4566446edefb67d5c225eb397b58/libavdevice/xcbgrab.c#L229

View File

@@ -197,7 +197,7 @@ pub fn check_clipboard_cm() -> ResultType<MultiClipboards> {
#[cfg(not(target_os = "android"))]
fn update_clipboard_(multi_clipboards: Vec<Clipboard>, side: ClipboardSide) {
let to_update_data = proto::from_multi_clipbards(multi_clipboards);
let to_update_data = proto::from_multi_clipboards(multi_clipboards);
if to_update_data.is_empty() {
return;
}
@@ -432,7 +432,7 @@ impl ClipboardContext {
#[cfg(target_os = "macos")]
let is_kde_x11 = false;
let clear_holder_text = if is_kde_x11 {
"RustDesk placeholder to clear the file clipbard"
"RustDesk placeholder to clear the file clipboard"
} else {
""
}
@@ -672,7 +672,7 @@ mod proto {
}
#[cfg(not(target_os = "android"))]
pub fn from_multi_clipbards(multi_clipboards: Vec<Clipboard>) -> Vec<ClipboardData> {
pub fn from_multi_clipboards(multi_clipboards: Vec<Clipboard>) -> Vec<ClipboardData> {
multi_clipboards
.into_iter()
.filter_map(from_clipboard)
@@ -814,7 +814,7 @@ pub mod clipboard_listener {
subscribers: listener_lock.subscribers.clone(),
};
let (tx_start_res, rx_start_res) = channel();
let h = start_clipbard_master_thread(handler, tx_start_res);
let h = start_clipboard_master_thread(handler, tx_start_res);
let shutdown = match rx_start_res.recv() {
Ok((Some(s), _)) => s,
Ok((None, err)) => {
@@ -854,7 +854,7 @@ pub mod clipboard_listener {
log::info!("Clipboard listener unsubscribed: {}", name);
}
fn start_clipbard_master_thread(
fn start_clipboard_master_thread(
handler: impl ClipboardHandler + Send + 'static,
tx_start_res: Sender<(Option<Shutdown>, String)>,
) -> JoinHandle<()> {

View File

@@ -2759,6 +2759,11 @@ pub fn main_get_common(key: String) -> String {
None => "",
}
.to_string();
} else if key == "has-gnome-shortcuts-inhibitor-permission" {
#[cfg(target_os = "linux")]
return crate::platform::linux::has_gnome_shortcuts_inhibitor_permission().to_string();
#[cfg(not(target_os = "linux"))]
return false.to_string();
} else {
if key.starts_with("download-data-") {
let id = key.replace("download-data-", "");
@@ -2920,6 +2925,29 @@ pub fn main_set_common(_key: String, _value: String) {
} else if _key == "cancel-downloader" {
crate::hbbs_http::downloader::cancel(&_value);
}
#[cfg(target_os = "linux")]
if _key == "clear-gnome-shortcuts-inhibitor-permission" {
std::thread::spawn(move || {
let (success, msg) =
match crate::platform::linux::clear_gnome_shortcuts_inhibitor_permission() {
Ok(_) => (true, "".to_owned()),
Err(e) => (false, e.to_string()),
};
let data = HashMap::from([
(
"name",
serde_json::json!("clear-gnome-shortcuts-inhibitor-permission-res"),
),
("success", serde_json::json!(success)),
("msg", serde_json::json!(msg)),
]);
let _res = flutter::push_global_event(
flutter::APP_TYPE_MAIN,
serde_json::ser::to_string(&data).unwrap_or("".to_owned()),
);
});
}
}
pub fn session_get_common_sync(

View File

@@ -220,7 +220,7 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("default_proxy_tip", "Default protocol and port are Socks5 and 1080"),
("no_audio_input_device_tip", "No audio input device found."),
("clear_Wayland_screen_selection_tip", "After clearing the screen selection, you can reselect the screen to share."),
("confirm_clear_Wayland_screen_selection_tip", "Are you sure to clear the Wayland screen selection?"),
("confirm_clear_Wayland_screen_selection_tip", "Are you sure you want to clear the Wayland screen selection?"),
("android_new_voice_call_tip", "A new voice call request was received. If you accept, the audio will switch to voice communication."),
("texture_render_tip", "Use texture rendering to make the pictures smoother. You could try disabling this option if you encounter rendering issues."),
("floating_window_tip", "It helps to keep RustDesk background service"),

View File

@@ -738,6 +738,6 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Changelog", "변경 기록"),
("keep-awake-during-outgoing-sessions-label", "발신 세션 중 화면 켜짐 유지"),
("keep-awake-during-incoming-sessions-label", "수신 세션 중 화면 켜짐 유지"),
("Continue with {}", "{} (으)로 계속"),
("Continue with {}", "{}(으)로 계속"),
].iter().cloned().collect();
}

View File

@@ -673,21 +673,21 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("dont-show-again-tip", "Não mostrar novamente"),
("Take screenshot", "Capturar de tela"),
("Taking screenshot", "Capturando tela"),
("screenshot-merged-screen-not-supported-tip", ""),
("screenshot-action-tip", ""),
("screenshot-merged-screen-not-supported-tip", "Mesclar a captura de tela de múltiplos monitores não é suportada no momento. Por favor, alterne para um único monitor e tente novamente."),
("screenshot-action-tip", "Por favor, selecione como seguir com a captura de tela."),
("Save as", "Salvar como"),
("Copy to clipboard", "Copiar para área de transferência"),
("Enable remote printer", "Habilitar impressora remota"),
("Downloading {}", ""),
("{} Update", ""),
("{}-to-update-tip", ""),
("Downloading {}", "Baixando {}"),
("{} Update", "Atualização do {}"),
("{}-to-update-tip", "{} será fechado agora para instalar a nova versão."),
("download-new-version-failed-tip", "Falha no download. Você pode tentar novamente ou clicar no botão \"Download\" para baixar da página releases e atualizar manualmente."),
("Auto update", "Atualização automática"),
("update-failed-check-msi-tip", "Falha na verificação do método de instalação. Clique no botão \"Download\" para baixar da página releases e atualizar manualmente."),
("websocket_tip", "Usando WebSocket, apenas conexões via relay são suportadas."),
("Use WebSocket", "Usar WebSocket"),
("Trackpad speed", "Velocidade do trackpad"),
("Default trackpad speed", ""),
("Default trackpad speed", "Velocidade padrão do trackpad"),
("Numeric one-time password", "Senha numérica de uso único"),
("Enable IPv6 P2P connection", "Habilitar conexão IPv6 P2P"),
("Enable UDP hole punching", "Habilitar UDP hole punching"),
@@ -717,11 +717,11 @@ pub static ref T: std::collections::HashMap<&'static str, &'static str> =
("Virtual mouse size", "Tamanho do mouse virtual"),
("Small", "Pequeno"),
("Large", "Grande"),
("Show virtual joystick", ""),
("Show virtual joystick", "Mostrar joystick virtual"),
("Edit note", "Editar nota"),
("Alias", "Apelido"),
("ScrollEdge", "Rolagem nas bordas"),
("Allow insecure TLS fallback", ""),
("Allow insecure TLS fallback", "Permitir fallback TLS inseguro"),
("allow-insecure-tls-fallback-tip", "Por padrão, o RustDesk verifica o certificado do servidor para protocolos que usam TLS.\nCom esta opção habilitada, o RustDesk ignorará a verificação e prosseguirá em caso de falha."),
("Disable UDP", "Desabilitar UDP"),
("disable-udp-tip", "Controla se deve usar somente TCP.\nCom esta opção habilitada, o RustDesk não usará mais UDP 21116, TCP 21116 será usado no lugar."),

View File

@@ -2088,3 +2088,122 @@ pub fn is_selinux_enforcing() -> bool {
},
}
}
/// Get the app ID for shortcuts inhibitor permission.
/// Returns different ID based on whether running in Flatpak or native.
/// The ID must match the installed .desktop filename, as GNOME Shell's
/// inhibitShortcutsDialog uses `Shell.WindowTracker.get_window_app(window).get_id()`.
fn get_shortcuts_inhibitor_app_id() -> String {
if is_flatpak() {
// In Flatpak, FLATPAK_ID is set automatically by the runtime to the app ID
// (e.g., "com.rustdesk.RustDesk"). This is the most reliable source.
// Fall back to constructing from app name if not available.
match std::env::var("FLATPAK_ID") {
Ok(id) if !id.is_empty() => format!("{}.desktop", id),
_ => {
let app_name = crate::get_app_name();
format!("com.{}.{}.desktop", app_name.to_lowercase(), app_name)
}
}
} else {
format!("{}.desktop", crate::get_app_name().to_lowercase())
}
}
const PERMISSION_STORE_DEST: &str = "org.freedesktop.impl.portal.PermissionStore";
const PERMISSION_STORE_PATH: &str = "/org/freedesktop/impl/portal/PermissionStore";
const PERMISSION_STORE_IFACE: &str = "org.freedesktop.impl.portal.PermissionStore";
/// Clear GNOME shortcuts inhibitor permission via D-Bus.
/// This allows the permission dialog to be shown again.
pub fn clear_gnome_shortcuts_inhibitor_permission() -> ResultType<()> {
let app_id = get_shortcuts_inhibitor_app_id();
log::info!(
"Clearing shortcuts inhibitor permission for app_id: {}, is_flatpak: {}",
app_id,
is_flatpak()
);
let conn = dbus::blocking::Connection::new_session()?;
let proxy = conn.with_proxy(
PERMISSION_STORE_DEST,
PERMISSION_STORE_PATH,
std::time::Duration::from_secs(3),
);
// DeletePermission(s table, s id, s app) -> ()
let result: Result<(), dbus::Error> = proxy.method_call(
PERMISSION_STORE_IFACE,
"DeletePermission",
("gnome", "shortcuts-inhibitor", app_id.as_str()),
);
match result {
Ok(()) => {
log::info!("Successfully cleared GNOME shortcuts inhibitor permission");
Ok(())
}
Err(e) => {
let err_name = e.name().unwrap_or("");
// If the permission doesn't exist, that's also fine
if err_name == "org.freedesktop.portal.Error.NotFound"
|| err_name == "org.freedesktop.DBus.Error.UnknownObject"
|| err_name == "org.freedesktop.DBus.Error.ServiceUnknown"
{
log::info!("GNOME shortcuts inhibitor permission was not set ({})", err_name);
Ok(())
} else {
bail!("Failed to clear permission: {}", e)
}
}
}
}
/// Check if GNOME shortcuts inhibitor permission exists.
pub fn has_gnome_shortcuts_inhibitor_permission() -> bool {
let app_id = get_shortcuts_inhibitor_app_id();
let conn = match dbus::blocking::Connection::new_session() {
Ok(c) => c,
Err(e) => {
log::debug!("Failed to connect to session bus: {}", e);
return false;
}
};
let proxy = conn.with_proxy(
PERMISSION_STORE_DEST,
PERMISSION_STORE_PATH,
std::time::Duration::from_secs(3),
);
// Lookup(s table, s id) -> (a{sas} permissions, v data)
// We only need the permissions dict; check if app_id is a key.
let result: Result<
(
std::collections::HashMap<String, Vec<String>>,
dbus::arg::Variant<Box<dyn dbus::arg::RefArg>>,
),
dbus::Error,
> = proxy.method_call(
PERMISSION_STORE_IFACE,
"Lookup",
("gnome", "shortcuts-inhibitor"),
);
match result {
Ok((permissions, _)) => {
let found = permissions.contains_key(&app_id);
log::debug!(
"Shortcuts inhibitor permission lookup: app_id={}, found={}, keys={:?}",
app_id,
found,
permissions.keys().collect::<Vec<_>>()
);
found
}
Err(e) => {
log::debug!("Failed to query shortcuts inhibitor permission: {}", e);
false
}
}
}

View File

@@ -107,9 +107,9 @@ pub fn get_focused_display(displays: Vec<DisplayInfo>) -> Option<usize> {
let center_x = rect.left + (rect.right - rect.left) / 2;
let center_y = rect.top + (rect.bottom - rect.top) / 2;
center_x >= display.x
&& center_x <= display.x + display.width
&& center_x < display.x + display.width
&& center_y >= display.y
&& center_y <= display.y + display.height
&& center_y < display.y + display.height
})
}
}

View File

@@ -40,6 +40,7 @@ lazy_static::lazy_static! {
}
static SHOULD_EXIT: AtomicBool = AtomicBool::new(false);
static MANUAL_RESTARTED: AtomicBool = AtomicBool::new(false);
static SENT_REGISTER_PK: AtomicBool = AtomicBool::new(false);
#[derive(Clone)]
pub struct RendezvousMediator {
@@ -689,6 +690,7 @@ impl RendezvousMediator {
..Default::default()
});
socket.send(&msg_out).await?;
SENT_REGISTER_PK.store(true, Ordering::SeqCst);
Ok(())
}
@@ -904,3 +906,28 @@ async fn udp_nat_listen(
})?;
Ok(())
}
// When config is not yet synced from root, register_pk may have already been sent with a new generated pk.
// After config sync completes, the pk may change. This struct detects pk changes and triggers
// a re-registration by setting key_confirmed to false.
// NOTE:
// This only corrects PK registration for the current ID. If root uses a non-default mac-generated ID,
// this does not resolve the multi-ID issue by itself.
pub struct CheckIfResendPk {
pk: Option<Vec<u8>>,
}
impl CheckIfResendPk {
pub fn new() -> Self {
Self {
pk: Config::get_cached_pk(),
}
}
}
impl Drop for CheckIfResendPk {
fn drop(&mut self) {
if SENT_REGISTER_PK.load(Ordering::SeqCst) && Config::get_cached_pk() != self.pk {
Config::set_key_confirmed(false);
log::info!("Set key_confirmed to false due to pk changed, will resend register_pk");
}
}
}

View File

@@ -82,6 +82,10 @@ type ConnMap = HashMap<i32, ConnInner>;
#[cfg(any(target_os = "macos", target_os = "linux"))]
const CONFIG_SYNC_INTERVAL_SECS: f32 = 0.3;
#[cfg(any(target_os = "macos", target_os = "linux"))]
// 3s is enough for at least one initial sync attempt:
// 0.3s backoff + up to 1s connect timeout + up to 1s response timeout.
const CONFIG_SYNC_INITIAL_WAIT_SECS: u64 = 3;
lazy_static::lazy_static! {
pub static ref CHILD_PROCESS: Childs = Default::default();
@@ -600,7 +604,7 @@ pub async fn start_server(is_server: bool, no_server: bool) {
allow_err!(input_service::setup_uinput(0, 1920, 0, 1080).await);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
tokio::spawn(async { sync_and_watch_config_dir().await });
wait_initial_config_sync().await;
#[cfg(target_os = "windows")]
crate::platform::try_kill_broker();
#[cfg(feature = "hwcodec")]
@@ -685,13 +689,43 @@ pub async fn start_ipc_url_server() {
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
async fn sync_and_watch_config_dir() {
async fn wait_initial_config_sync() {
if crate::platform::is_root() {
return;
}
// Non-server process should not block startup, but still keeps background sync/watch alive.
if !crate::is_server() {
tokio::spawn(async move {
sync_and_watch_config_dir(None).await;
});
return;
}
let (sync_done_tx, mut sync_done_rx) = tokio::sync::oneshot::channel::<()>();
tokio::spawn(async move {
sync_and_watch_config_dir(Some(sync_done_tx)).await;
});
// Server process waits up to N seconds for initial root->local sync to reduce stale-start window.
tokio::select! {
_ = &mut sync_done_rx => {
}
_ = tokio::time::sleep(Duration::from_secs(CONFIG_SYNC_INITIAL_WAIT_SECS)) => {
log::warn!(
"timed out waiting {}s for initial config sync, continue startup and keep syncing in background",
CONFIG_SYNC_INITIAL_WAIT_SECS
);
}
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
async fn sync_and_watch_config_dir(sync_done_tx: Option<tokio::sync::oneshot::Sender<()>>) {
let mut cfg0 = (Config::get(), Config2::get());
let mut synced = false;
let mut is_root_config_empty = false;
let mut sync_done_tx = sync_done_tx;
let tries = if crate::is_server() { 30 } else { 3 };
log::debug!("#tries of ipc service connection: {}", tries);
use hbb_common::sleep;
@@ -706,6 +740,8 @@ async fn sync_and_watch_config_dir() {
Data::SyncConfig(Some(configs)) => {
let (config, config2) = *configs;
let _chk = crate::ipc::CheckIfRestart::new();
#[cfg(target_os = "macos")]
let _chk_pk = crate::CheckIfResendPk::new();
if !config.is_empty() {
if cfg0.0 != config {
cfg0.0 = config.clone();
@@ -717,8 +753,20 @@ async fn sync_and_watch_config_dir() {
Config2::set(config2);
log::info!("sync config2 from root");
}
} else {
// only on macos, because this issue was only reproduced on macos
#[cfg(target_os = "macos")]
{
// root config is empty, mark for sync in watch loop
// to prevent root from generating a new config on login screen
is_root_config_empty = true;
}
}
synced = true;
// Notify startup waiter once initial sync phase finishes successfully.
if let Some(tx) = sync_done_tx.take() {
let _ = tx.send(());
}
}
_ => {}
};
@@ -729,8 +777,14 @@ async fn sync_and_watch_config_dir() {
loop {
sleep(CONFIG_SYNC_INTERVAL_SECS).await;
let cfg = (Config::get(), Config2::get());
if cfg != cfg0 {
log::info!("config updated, sync to root");
let should_sync =
cfg != cfg0 || (is_root_config_empty && !cfg.0.is_empty());
if should_sync {
if is_root_config_empty {
log::info!("root config is empty, sync our config to root");
} else {
log::info!("config updated, sync to root");
}
match conn.send(&Data::SyncConfig(Some(cfg.clone().into()))).await {
Err(e) => {
log::error!("sync config to root failed: {}", e);
@@ -745,6 +799,7 @@ async fn sync_and_watch_config_dir() {
_ => {
cfg0 = cfg;
conn.next_timeout(1000).await.ok();
is_root_config_empty = false;
}
}
}
@@ -755,6 +810,10 @@ async fn sync_and_watch_config_dir() {
}
}
}
// Notify startup waiter even when initial sync is skipped/failed, to avoid unnecessary waiting.
if let Some(tx) = sync_done_tx.take() {
let _ = tx.send(());
}
log::warn!("skipped config sync");
}

View File

@@ -111,6 +111,10 @@ struct Input {
const KEY_CHAR_START: u64 = 9999;
// XKB keycode for Insert key (evdev KEY_INSERT code 110 + 8 for XKB offset)
#[cfg(target_os = "linux")]
const XKB_KEY_INSERT: u16 = evdev::Key::KEY_INSERT.code() + 8;
#[derive(Clone, Default)]
pub struct MouseCursorSub {
inner: ConnInner,
@@ -1105,8 +1109,12 @@ pub fn handle_mouse_simulation_(evt: &MouseEvent, conn: i32) {
// Clamp delta to prevent extreme/malicious values from reaching OS APIs.
// This matches the Flutter client's kMaxRelativeMouseDelta constant.
const MAX_RELATIVE_MOUSE_DELTA: i32 = 10000;
let dx = evt.x.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
let dy = evt.y.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
let dx = evt
.x
.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
let dy = evt
.y
.clamp(-MAX_RELATIVE_MOUSE_DELTA, MAX_RELATIVE_MOUSE_DELTA);
en.mouse_move_relative(dx, dy);
// Get actual cursor position after relative movement for tracking
if let Some((x, y)) = crate::get_cursor_pos() {
@@ -1465,20 +1473,26 @@ fn map_keyboard_mode(evt: &KeyEvent) {
// Wayland
#[cfg(target_os = "linux")]
if !crate::platform::linux::is_x11() {
let mut en = ENIGO.lock().unwrap();
let code = evt.chr() as u16;
if evt.down {
en.key_down(enigo::Key::Raw(code)).ok();
} else {
en.key_up(enigo::Key::Raw(code));
}
wayland_send_raw_key(evt.chr() as u16, evt.down);
return;
}
sim_rdev_rawkey_position(evt.chr() as _, evt.down);
}
/// Send raw keycode on Wayland via the active backend (uinput or RemoteDesktop portal).
/// The keycode is expected to be a Linux keycode (evdev code + 8 for X11 compatibility).
#[cfg(target_os = "linux")]
#[inline]
fn wayland_send_raw_key(code: u16, down: bool) {
let mut en = ENIGO.lock().unwrap();
if down {
en.key_down(enigo::Key::Raw(code)).ok();
} else {
en.key_up(enigo::Key::Raw(code));
}
}
#[cfg(target_os = "macos")]
fn add_flags_to_enigo(en: &mut Enigo, key_event: &KeyEvent) {
// When long-pressed the command key, then press and release
@@ -1559,6 +1573,20 @@ fn need_to_uppercase(en: &mut Enigo) -> bool {
}
fn process_chr(en: &mut Enigo, chr: u32, down: bool) {
// On Wayland with uinput mode, use clipboard for character input
#[cfg(target_os = "linux")]
if !crate::platform::linux::is_x11() && wayland_use_uinput() {
// Skip clipboard for hotkeys (Ctrl/Alt/Meta pressed)
if !is_hotkey_modifier_pressed(en) {
if down {
if let Ok(c) = char::try_from(chr) {
input_char_via_clipboard_server(en, c);
}
}
return;
}
}
let key = char_value_to_key(chr);
if down {
@@ -1578,15 +1606,136 @@ fn process_chr(en: &mut Enigo, chr: u32, down: bool) {
}
fn process_unicode(en: &mut Enigo, chr: u32) {
// On Wayland with uinput mode, use clipboard for character input
#[cfg(target_os = "linux")]
if !crate::platform::linux::is_x11() && wayland_use_uinput() {
if let Ok(c) = char::try_from(chr) {
input_char_via_clipboard_server(en, c);
}
return;
}
if let Ok(chr) = char::try_from(chr) {
en.key_sequence(&chr.to_string());
}
}
fn process_seq(en: &mut Enigo, sequence: &str) {
// On Wayland with uinput mode, use clipboard for text input
#[cfg(target_os = "linux")]
if !crate::platform::linux::is_x11() && wayland_use_uinput() {
input_text_via_clipboard_server(en, sequence);
return;
}
en.key_sequence(&sequence);
}
/// Delay in milliseconds to wait for clipboard to sync on Wayland.
/// This is an empirical value — Wayland provides no callback or event to confirm
/// clipboard content has been received by the compositor. Under heavy system load,
/// this delay may be insufficient, but there is no reliable alternative mechanism.
#[cfg(target_os = "linux")]
const CLIPBOARD_SYNC_DELAY_MS: u64 = 50;
/// Internal: Set clipboard content without delay.
/// Returns true if clipboard was set successfully.
#[cfg(target_os = "linux")]
fn set_clipboard_content(text: &str) -> bool {
use arboard::{Clipboard, LinuxClipboardKind, SetExtLinux};
let mut clipboard = match Clipboard::new() {
Ok(cb) => cb,
Err(e) => {
log::error!("set_clipboard_content: failed to create clipboard: {:?}", e);
return false;
}
};
// Set both CLIPBOARD and PRIMARY selections
// Terminal uses PRIMARY for Shift+Insert, GUI apps use CLIPBOARD
if let Err(e) = clipboard
.set()
.clipboard(LinuxClipboardKind::Clipboard)
.text(text.to_owned())
{
log::error!("set_clipboard_content: failed to set CLIPBOARD: {:?}", e);
return false;
}
if let Err(e) = clipboard
.set()
.clipboard(LinuxClipboardKind::Primary)
.text(text.to_owned())
{
log::warn!("set_clipboard_content: failed to set PRIMARY: {:?}", e);
// Continue anyway, CLIPBOARD might work
}
true
}
/// Set clipboard content for paste operation (sync version for use in blocking contexts).
///
/// Note: The original clipboard content is intentionally NOT restored after paste.
/// Restoring clipboard could cause race conditions where subsequent keystrokes
/// might accidentally paste the old clipboard content instead of the intended input.
/// This trade-off prioritizes input reliability over preserving clipboard state.
#[cfg(target_os = "linux")]
#[inline]
pub(super) fn set_clipboard_for_paste_sync(text: &str) -> bool {
if !set_clipboard_content(text) {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(CLIPBOARD_SYNC_DELAY_MS));
true
}
/// Check if a character is ASCII printable (0x20-0x7E).
#[cfg(target_os = "linux")]
#[inline]
pub(super) fn is_ascii_printable(c: char) -> bool {
c as u32 >= 0x20 && c as u32 <= 0x7E
}
/// Input a single character via clipboard + Shift+Insert in server process.
#[cfg(target_os = "linux")]
#[inline]
fn input_char_via_clipboard_server(en: &mut Enigo, chr: char) {
input_text_via_clipboard_server(en, &chr.to_string());
}
/// Input text via clipboard + Shift+Insert in server process.
/// Shift+Insert is more universal than Ctrl+V, works in both GUI apps and terminals.
///
/// Note: Clipboard content is NOT restored after paste - see `set_clipboard_for_paste_sync` for rationale.
#[cfg(target_os = "linux")]
fn input_text_via_clipboard_server(en: &mut Enigo, text: &str) {
if text.is_empty() {
return;
}
if !set_clipboard_for_paste_sync(text) {
return;
}
// Use ENIGO's custom_keyboard directly to avoid creating new IPC connections
// which would cause excessive logging and keyboard device creation/destruction
if en.key_down(Key::Shift).is_err() {
log::error!("input_text_via_clipboard_server: failed to press Shift, skipping paste");
return;
}
if en.key_down(Key::Raw(XKB_KEY_INSERT)).is_err() {
log::error!("input_text_via_clipboard_server: failed to press Insert, releasing Shift");
en.key_up(Key::Shift);
return;
}
en.key_up(Key::Raw(XKB_KEY_INSERT));
en.key_up(Key::Shift);
// Brief delay to allow the target application to process the paste event.
// Empirical value — no reliable synchronization mechanism exists on Wayland.
std::thread::sleep(std::time::Duration::from_millis(20));
}
#[cfg(not(target_os = "macos"))]
fn release_keys(en: &mut Enigo, to_release: &Vec<Key>) {
for key in to_release {
@@ -1621,6 +1770,64 @@ fn is_function_key(ck: &EnumOrUnknown<ControlKey>) -> bool {
return res;
}
/// Check if any hotkey modifier (Ctrl/Alt/Meta) is currently pressed.
/// Used to detect hotkey combinations like Ctrl+C, Alt+Tab, etc.
///
/// Note: Shift is intentionally NOT checked here. Shift+character produces a different
/// character (e.g., Shift+a → 'A'), which is normal text input, not a hotkey.
/// Shift is only relevant as a hotkey modifier when combined with Ctrl/Alt/Meta
/// (e.g., Ctrl+Shift+Z), in which case this function already returns true via Ctrl.
#[cfg(target_os = "linux")]
#[inline]
fn is_hotkey_modifier_pressed(en: &mut Enigo) -> bool {
get_modifier_state(Key::Control, en)
|| get_modifier_state(Key::RightControl, en)
|| get_modifier_state(Key::Alt, en)
|| get_modifier_state(Key::RightAlt, en)
|| get_modifier_state(Key::Meta, en)
|| get_modifier_state(Key::RWin, en)
}
/// Release Shift keys before character input in Legacy/Translate mode.
/// In these modes, the character has already been converted by the client,
/// so we should input it directly without Shift modifier affecting the result.
///
/// Note: Does NOT release Shift if hotkey modifiers (Ctrl/Alt/Meta) are pressed,
/// to preserve combinations like Ctrl+Shift+Z.
#[cfg(target_os = "linux")]
fn release_shift_for_char_input(en: &mut Enigo) {
// Don't release Shift if hotkey modifiers (Ctrl/Alt/Meta) are pressed.
// This preserves combinations like Ctrl+Shift+Z.
if is_hotkey_modifier_pressed(en) {
return;
}
// In translate mode, the client has already converted the keystroke to a character
// (e.g., Shift+a → 'A'). We release Shift here so the server inputs the character
// directly without Shift affecting the result.
//
// Shift is intentionally NOT restored after input — the client will send an explicit
// Shift key_up event when the user physically releases Shift. Restoring it here would
// cause a brief Shift re-press that could interfere with the next input event.
let is_x11 = crate::platform::linux::is_x11();
if get_modifier_state(Key::Shift, en) {
if !is_x11 {
en.key_up(Key::Shift);
} else {
simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft));
}
}
if get_modifier_state(Key::RightShift, en) {
if !is_x11 {
en.key_up(Key::RightShift);
} else {
simulate_(&EventType::KeyRelease(RdevKey::ShiftRight));
}
}
}
fn legacy_keyboard_mode(evt: &KeyEvent) {
#[cfg(windows)]
crate::platform::windows::try_change_desktop();
@@ -1640,11 +1847,24 @@ fn legacy_keyboard_mode(evt: &KeyEvent) {
process_control_key(&mut en, &ck, down)
}
Some(key_event::Union::Chr(chr)) => {
// For character input in Legacy mode, we need to release Shift first.
// The character has already been converted by the client, so we should
// input it directly without Shift modifier affecting the result.
// Only Ctrl/Alt/Meta should be kept for hotkeys like Ctrl+C.
#[cfg(target_os = "linux")]
release_shift_for_char_input(&mut en);
let record_key = chr as u64 + KEY_CHAR_START;
record_pressed_key(KeysDown::EnigoKey(record_key), down);
process_chr(&mut en, chr, down)
}
Some(key_event::Union::Unicode(chr)) => process_unicode(&mut en, chr),
Some(key_event::Union::Unicode(chr)) => {
// Same as Chr: release Shift for Unicode input
#[cfg(target_os = "linux")]
release_shift_for_char_input(&mut en);
process_unicode(&mut en, chr)
}
Some(key_event::Union::Seq(ref seq)) => process_seq(&mut en, seq),
_ => {}
}
@@ -1665,6 +1885,51 @@ fn translate_process_code(code: u32, down: bool) {
fn translate_keyboard_mode(evt: &KeyEvent) {
match &evt.union {
Some(key_event::Union::Seq(seq)) => {
// On Wayland, handle character input directly in this (--server) process using clipboard.
// This function runs in the --server process (logged-in user session), which has
// WAYLAND_DISPLAY and XDG_RUNTIME_DIR — so clipboard operations work here.
//
// Why not let it go through uinput IPC:
// 1. For uinput mode: the uinput service thread runs in the --service (root) process,
// which typically lacks user session environment. Clipboard operations there are
// unreliable. Handling clipboard here avoids that issue.
// 2. For RDP input mode: Portal's notify_keyboard_keysym API interprets keysyms
// based on its internal modifier state, which may not match our released state.
// Using clipboard bypasses this issue entirely.
#[cfg(target_os = "linux")]
if !crate::platform::linux::is_x11() {
let mut en = ENIGO.lock().unwrap();
// Check if this is a hotkey (Ctrl/Alt/Meta pressed)
// For hotkeys, we send character-based key events via Enigo instead of
// using the clipboard. This relies on the local keyboard layout for
// mapping characters to physical keys.
// This assumes client and server use the same keyboard layout (common case).
// Note: For non-Latin keyboards (e.g., Arabic), hotkeys may not work
// correctly if the character cannot be mapped to a key via KEY_MAP_LAYOUT.
// This is a known limitation - most common hotkeys (Ctrl+A/C/V/Z) use Latin
// characters which are mappable on most keyboard layouts.
if is_hotkey_modifier_pressed(&mut en) {
// For hotkeys, send character-based key events via Enigo.
// This relies on the local keyboard layout mapping (KEY_MAP_LAYOUT).
for chr in seq.chars() {
if !is_ascii_printable(chr) {
log::warn!(
"Hotkey with non-ASCII character may not work correctly on non-Latin keyboard layouts"
);
}
en.key_click(Key::Layout(chr));
}
return;
}
// Normal text input: release Shift and use clipboard
release_shift_for_char_input(&mut en);
input_text_via_clipboard_server(&mut en, seq);
return;
}
// Fr -> US
// client: Shift + & => 1(send to remote)
// remote: Shift + 1 => !
@@ -1682,11 +1947,16 @@ fn translate_keyboard_mode(evt: &KeyEvent) {
#[cfg(target_os = "linux")]
let simulate_win_hot_key = false;
if !simulate_win_hot_key {
if get_modifier_state(Key::Shift, &mut en) {
simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft));
}
if get_modifier_state(Key::RightShift, &mut en) {
simulate_(&EventType::KeyRelease(RdevKey::ShiftRight));
#[cfg(target_os = "linux")]
release_shift_for_char_input(&mut en);
#[cfg(target_os = "windows")]
{
if get_modifier_state(Key::Shift, &mut en) {
simulate_(&EventType::KeyRelease(RdevKey::ShiftLeft));
}
if get_modifier_state(Key::RightShift, &mut en) {
simulate_(&EventType::KeyRelease(RdevKey::ShiftRight));
}
}
}
for chr in seq.chars() {
@@ -1706,7 +1976,16 @@ fn translate_keyboard_mode(evt: &KeyEvent) {
Some(key_event::Union::Chr(..)) => {
#[cfg(target_os = "windows")]
translate_process_code(evt.chr(), evt.down);
#[cfg(not(target_os = "windows"))]
#[cfg(target_os = "linux")]
{
if !crate::platform::linux::is_x11() {
// Wayland: use uinput to send raw keycode
wayland_send_raw_key(evt.chr() as u16, evt.down);
} else {
sim_rdev_rawkey_position(evt.chr() as _, evt.down);
}
}
#[cfg(target_os = "macos")]
sim_rdev_rawkey_position(evt.chr() as _, evt.down);
}
Some(key_event::Union::Unicode(..)) => {
@@ -1717,7 +1996,11 @@ fn translate_keyboard_mode(evt: &KeyEvent) {
simulate_win2win_hotkey(*code, evt.down);
}
_ => {
log::debug!("Unreachable. Unexpected key event {:?}", &evt);
log::debug!(
"Unreachable. Unexpected key event (mode={:?}, down={:?})",
&evt.mode,
&evt.down
);
}
}
}

View File

@@ -1,7 +1,8 @@
use crate::uinput::service::map_key;
use super::input_service::set_clipboard_for_paste_sync;
use crate::uinput::service::{can_input_via_keysym, char_to_keysym, map_key};
use dbus::{blocking::SyncConnection, Path};
use enigo::{Key, KeyboardControllable, MouseButton, MouseControllable};
use hbb_common::ResultType;
use hbb_common::{log, ResultType};
use scrap::wayland::pipewire::{get_portal, PwStreamInfo};
use scrap::wayland::remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop as remote_desktop_portal;
use std::collections::HashMap;
@@ -19,14 +20,74 @@ pub mod client {
const PRESSED_DOWN_STATE: u32 = 1;
const PRESSED_UP_STATE: u32 = 0;
/// Modifier key state tracking for RDP input.
/// Portal API doesn't provide a way to query key state, so we track it ourselves.
#[derive(Default)]
struct ModifierState {
shift_left: bool,
shift_right: bool,
ctrl_left: bool,
ctrl_right: bool,
alt_left: bool,
alt_right: bool,
meta_left: bool,
meta_right: bool,
}
impl ModifierState {
fn update(&mut self, key: &Key, down: bool) {
match key {
Key::Shift => self.shift_left = down,
Key::RightShift => self.shift_right = down,
Key::Control => self.ctrl_left = down,
Key::RightControl => self.ctrl_right = down,
Key::Alt => self.alt_left = down,
Key::RightAlt => self.alt_right = down,
Key::Meta | Key::Super | Key::Windows | Key::Command => self.meta_left = down,
Key::RWin => self.meta_right = down,
// Handle raw keycodes for modifier keys (Linux evdev codes + 8)
// In translate mode, modifier keys may be sent as Chr events with raw keycodes.
// The +8 offset converts evdev codes to X11/XKB keycodes.
Key::Raw(code) => {
const EVDEV_OFFSET: u16 = 8;
const KEY_LEFTSHIFT: u16 = evdev::Key::KEY_LEFTSHIFT.code() + EVDEV_OFFSET;
const KEY_RIGHTSHIFT: u16 = evdev::Key::KEY_RIGHTSHIFT.code() + EVDEV_OFFSET;
const KEY_LEFTCTRL: u16 = evdev::Key::KEY_LEFTCTRL.code() + EVDEV_OFFSET;
const KEY_RIGHTCTRL: u16 = evdev::Key::KEY_RIGHTCTRL.code() + EVDEV_OFFSET;
const KEY_LEFTALT: u16 = evdev::Key::KEY_LEFTALT.code() + EVDEV_OFFSET;
const KEY_RIGHTALT: u16 = evdev::Key::KEY_RIGHTALT.code() + EVDEV_OFFSET;
const KEY_LEFTMETA: u16 = evdev::Key::KEY_LEFTMETA.code() + EVDEV_OFFSET;
const KEY_RIGHTMETA: u16 = evdev::Key::KEY_RIGHTMETA.code() + EVDEV_OFFSET;
match *code {
KEY_LEFTSHIFT => self.shift_left = down,
KEY_RIGHTSHIFT => self.shift_right = down,
KEY_LEFTCTRL => self.ctrl_left = down,
KEY_RIGHTCTRL => self.ctrl_right = down,
KEY_LEFTALT => self.alt_left = down,
KEY_RIGHTALT => self.alt_right = down,
KEY_LEFTMETA => self.meta_left = down,
KEY_RIGHTMETA => self.meta_right = down,
_ => {}
}
}
_ => {}
}
}
}
pub struct RdpInputKeyboard {
conn: Arc<SyncConnection>,
session: Path<'static>,
modifier_state: ModifierState,
}
impl RdpInputKeyboard {
pub fn new(conn: Arc<SyncConnection>, session: Path<'static>) -> ResultType<Self> {
Ok(Self { conn, session })
Ok(Self {
conn,
session,
modifier_state: ModifierState::default(),
})
}
}
@@ -39,29 +100,192 @@ pub mod client {
self
}
fn get_key_state(&mut self, _: Key) -> bool {
// no api for this
false
fn get_key_state(&mut self, key: Key) -> bool {
// Use tracked modifier state for supported keys
match key {
Key::Shift => self.modifier_state.shift_left,
Key::RightShift => self.modifier_state.shift_right,
Key::Control => self.modifier_state.ctrl_left,
Key::RightControl => self.modifier_state.ctrl_right,
Key::Alt => self.modifier_state.alt_left,
Key::RightAlt => self.modifier_state.alt_right,
Key::Meta | Key::Super | Key::Windows | Key::Command => {
self.modifier_state.meta_left
}
Key::RWin => self.modifier_state.meta_right,
_ => false,
}
}
fn key_sequence(&mut self, s: &str) {
for c in s.chars() {
let key = Key::Layout(c);
let _ = handle_key(true, key, self.conn.clone(), &self.session);
let _ = handle_key(false, key, self.conn.clone(), &self.session);
let keysym = char_to_keysym(c);
// ASCII characters: use keysym
if can_input_via_keysym(c, keysym) {
if let Err(e) = send_keysym(keysym, true, self.conn.clone(), &self.session) {
log::error!("Failed to send keysym down: {:?}", e);
}
if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) {
log::error!("Failed to send keysym up: {:?}", e);
}
} else {
// Non-ASCII: use clipboard
input_text_via_clipboard(&c.to_string(), self.conn.clone(), &self.session);
}
}
}
fn key_down(&mut self, key: Key) -> enigo::ResultType {
handle_key(true, key, self.conn.clone(), &self.session)?;
if let Key::Layout(chr) = key {
let keysym = char_to_keysym(chr);
// ASCII characters: use keysym
if can_input_via_keysym(chr, keysym) {
send_keysym(keysym, true, self.conn.clone(), &self.session)?;
} else {
// Non-ASCII: use clipboard (complete key press in key_down)
input_text_via_clipboard(&chr.to_string(), self.conn.clone(), &self.session);
}
} else {
handle_key(true, key.clone(), self.conn.clone(), &self.session)?;
// Update modifier state only after successful send —
// if handle_key fails, we don't want stale "pressed" state
// affecting subsequent key event decisions.
self.modifier_state.update(&key, true);
}
Ok(())
}
fn key_up(&mut self, key: Key) {
let _ = handle_key(false, key, self.conn.clone(), &self.session);
// Intentionally asymmetric with key_down: update state BEFORE sending.
// On release, we always mark as released even if the send fails below,
// to avoid permanently stuck-modifier state in our tracker. The trade-off
// (tracker says "released" while OS may still have it pressed) is acceptable
// because such failures are rare and subsequent events will resynchronize.
self.modifier_state.update(&key, false);
if let Key::Layout(chr) = key {
// ASCII characters: send keysym up if we also sent it on key_down
let keysym = char_to_keysym(chr);
if can_input_via_keysym(chr, keysym) {
if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session)
{
log::error!("Failed to send keysym up: {:?}", e);
}
}
// Non-ASCII: already handled completely in key_down via clipboard paste,
// no corresponding release needed (clipboard paste is an atomic operation)
} else {
if let Err(e) = handle_key(false, key, self.conn.clone(), &self.session) {
log::error!("Failed to handle key up: {:?}", e);
}
}
}
fn key_click(&mut self, key: Key) {
let _ = handle_key(true, key, self.conn.clone(), &self.session);
let _ = handle_key(false, key, self.conn.clone(), &self.session);
if let Key::Layout(chr) = key {
let keysym = char_to_keysym(chr);
// ASCII characters: use keysym
if can_input_via_keysym(chr, keysym) {
if let Err(e) = send_keysym(keysym, true, self.conn.clone(), &self.session) {
log::error!("Failed to send keysym down: {:?}", e);
}
if let Err(e) = send_keysym(keysym, false, self.conn.clone(), &self.session) {
log::error!("Failed to send keysym up: {:?}", e);
}
} else {
// Non-ASCII: use clipboard
input_text_via_clipboard(&chr.to_string(), self.conn.clone(), &self.session);
}
} else {
if let Err(e) = handle_key(true, key.clone(), self.conn.clone(), &self.session) {
log::error!("Failed to handle key down: {:?}", e);
} else {
// Only mark modifier as pressed if key-down was actually delivered
self.modifier_state.update(&key, true);
}
// Always mark as released to avoid stuck-modifier state
self.modifier_state.update(&key, false);
if let Err(e) = handle_key(false, key, self.conn.clone(), &self.session) {
log::error!("Failed to handle key up: {:?}", e);
}
}
}
}
/// Input text via clipboard + Shift+Insert.
/// Shift+Insert is more universal than Ctrl+V, works in both GUI apps and terminals.
///
/// Note: Clipboard content is NOT restored after paste - see `set_clipboard_for_paste_sync` for rationale.
fn input_text_via_clipboard(text: &str, conn: Arc<SyncConnection>, session: &Path<'static>) {
if text.is_empty() {
return;
}
if !set_clipboard_for_paste_sync(text) {
return;
}
let portal = get_portal(&conn);
let shift_keycode = evdev::Key::KEY_LEFTSHIFT.code() as i32;
let insert_keycode = evdev::Key::KEY_INSERT.code() as i32;
// Send Shift+Insert (universal paste shortcut)
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
session,
HashMap::new(),
shift_keycode,
PRESSED_DOWN_STATE,
) {
log::error!("input_text_via_clipboard: failed to press Shift: {:?}", e);
return;
}
// Press Insert
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
session,
HashMap::new(),
insert_keycode,
PRESSED_DOWN_STATE,
) {
log::error!("input_text_via_clipboard: failed to press Insert: {:?}", e);
// Still try to release Shift.
// Note: clipboard has already been set by set_clipboard_for_paste_sync but paste
// never happened. We don't attempt to restore the previous clipboard contents
// because reading the clipboard on Wayland requires focus/permission.
let _ = remote_desktop_portal::notify_keyboard_keycode(
&portal,
session,
HashMap::new(),
shift_keycode,
PRESSED_UP_STATE,
);
return;
}
// Release Insert
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
session,
HashMap::new(),
insert_keycode,
PRESSED_UP_STATE,
) {
log::error!(
"input_text_via_clipboard: failed to release Insert: {:?}",
e
);
}
// Release Shift
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
session,
HashMap::new(),
shift_keycode,
PRESSED_UP_STATE,
) {
log::error!("input_text_via_clipboard: failed to release Shift: {:?}", e);
}
}
@@ -196,6 +420,39 @@ pub mod client {
}
}
/// Send a keysym via RemoteDesktop portal.
fn send_keysym(
keysym: i32,
down: bool,
conn: Arc<SyncConnection>,
session: &Path<'static>,
) -> ResultType<()> {
let state: u32 = if down {
PRESSED_DOWN_STATE
} else {
PRESSED_UP_STATE
};
let portal = get_portal(&conn);
log::trace!(
"send_keysym: calling notify_keyboard_keysym, keysym={:#x}, state={}",
keysym,
state
);
match remote_desktop_portal::notify_keyboard_keysym(
&portal,
session,
HashMap::new(),
keysym,
state,
) {
Ok(_) => {
log::trace!("send_keysym: notify_keyboard_keysym succeeded");
Ok(())
}
Err(e) => Err(e.into()),
}
}
fn get_raw_evdev_keycode(key: u16) -> i32 {
// 8 is the offset between xkb and evdev
let mut key = key as i32 - 8;
@@ -231,22 +488,86 @@ pub mod client {
}
_ => {
if let Ok((key, is_shift)) = map_key(&key) {
if is_shift {
remote_desktop_portal::notify_keyboard_keycode(
let shift_keycode = evdev::Key::KEY_LEFTSHIFT.code() as i32;
if down {
// Press: Shift down first, then key down
if is_shift {
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
shift_keycode,
state,
) {
log::error!("handle_key: failed to press Shift: {:?}", e);
return Err(e.into());
}
}
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
evdev::Key::KEY_LEFTSHIFT.code() as i32,
key.code() as i32,
state,
)?;
) {
log::error!("handle_key: failed to press key: {:?}", e);
// Best-effort: release Shift if it was pressed
if is_shift {
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
shift_keycode,
PRESSED_UP_STATE,
) {
log::warn!(
"handle_key: best-effort Shift release also failed: {:?}",
e
);
}
}
return Err(e.into());
}
} else {
// Release: key up first, then Shift up
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
key.code() as i32,
PRESSED_UP_STATE,
) {
log::error!("handle_key: failed to release key: {:?}", e);
// Best-effort: still try to release Shift
if is_shift {
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
shift_keycode,
PRESSED_UP_STATE,
) {
log::warn!(
"handle_key: best-effort Shift release also failed: {:?}",
e
);
}
}
return Err(e.into());
}
if is_shift {
if let Err(e) = remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
shift_keycode,
PRESSED_UP_STATE,
) {
log::error!("handle_key: failed to release Shift: {:?}", e);
return Err(e.into());
}
}
}
remote_desktop_portal::notify_keyboard_keycode(
&portal,
&session,
HashMap::new(),
key.code() as i32,
state,
)?;
}
}
}

View File

@@ -90,6 +90,13 @@ pub mod client {
}
fn key_sequence(&mut self, sequence: &str) {
// Sequence events are normally handled in the --server process before reaching here.
// Forward via IPC as a fallback — input_text_wayland can still handle ASCII chars
// via keysym/uinput, though non-ASCII will be skipped (no clipboard in --service).
log::debug!(
"UInputKeyboard::key_sequence called (len={})",
sequence.len()
);
allow_err!(self.send(Data::Keyboard(DataKeyboard::Sequence(sequence.to_string()))));
}
@@ -178,6 +185,9 @@ pub mod client {
pub mod service {
use super::*;
use hbb_common::lazy_static;
use scrap::wayland::{
pipewire::RDP_SESSION_INFO, remote_desktop_portal::OrgFreedesktopPortalRemoteDesktop,
};
use std::{collections::HashMap, sync::Mutex};
lazy_static::lazy_static! {
@@ -309,6 +319,9 @@ pub mod service {
('/', (evdev::Key::KEY_SLASH, false)),
(';', (evdev::Key::KEY_SEMICOLON, false)),
('\'', (evdev::Key::KEY_APOSTROPHE, false)),
// Space is intentionally in both KEY_MAP_LAYOUT (char-to-evdev for text input)
// and KEY_MAP (Key::Space for key events). Both maps serve different lookup paths.
(' ', (evdev::Key::KEY_SPACE, false)),
// Shift + key
('A', (evdev::Key::KEY_A, true)),
@@ -364,6 +377,155 @@ pub mod service {
static ref RESOLUTION: Mutex<((i32, i32), (i32, i32))> = Mutex::new(((0, 0), (0, 0)));
}
/// Input text on Wayland using layout-independent methods.
/// ASCII chars (0x20-0x7E): Portal keysym or uinput fallback
/// Non-ASCII chars: skipped — this runs in the --service (root) process where clipboard
/// operations are unreliable (typically no user session environment).
/// Non-ASCII input is normally handled by the --server process via input_text_via_clipboard_server.
fn input_text_wayland(text: &str, keyboard: &mut VirtualDevice) {
let portal_info = {
let session_info = RDP_SESSION_INFO.lock().unwrap();
session_info
.as_ref()
.map(|info| (info.conn.clone(), info.session.clone()))
};
for c in text.chars() {
let keysym = char_to_keysym(c);
if can_input_via_keysym(c, keysym) {
// Try Portal first — down+up on the same channel
if let Some((ref conn, ref session)) = portal_info {
let portal = scrap::wayland::pipewire::get_portal(conn);
if portal
.notify_keyboard_keysym(session, HashMap::new(), keysym, 1)
.is_ok()
{
if let Err(e) =
portal.notify_keyboard_keysym(session, HashMap::new(), keysym, 0)
{
log::warn!(
"input_text_wayland: portal key-up failed for keysym {:#x}: {:?}",
keysym,
e
);
}
continue;
}
}
// Portal unavailable or failed, fallback to uinput (down+up together)
let key = enigo::Key::Layout(c);
if let Ok((evdev_key, is_shift)) = map_key(&key) {
let mut shift_pressed = false;
if is_shift {
let shift_down =
InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1);
if keyboard.emit(&[shift_down]).is_ok() {
shift_pressed = true;
} else {
log::warn!("input_text_wayland: failed to press Shift for '{}'", c);
}
}
let key_down = InputEvent::new(EventType::KEY, evdev_key.code(), 1);
let key_up = InputEvent::new(EventType::KEY, evdev_key.code(), 0);
allow_err!(keyboard.emit(&[key_down, key_up]));
if shift_pressed {
let shift_up =
InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 0);
allow_err!(keyboard.emit(&[shift_up]));
}
}
} else {
log::debug!("Skipping non-ASCII character in uinput service (no clipboard access)");
}
}
}
/// Send a single key down or up event for a Layout character.
/// Used by KeyDown/KeyUp to maintain correct press/release semantics.
/// `down`: true for key press, false for key release.
fn input_char_wayland_key_event(chr: char, down: bool, keyboard: &mut VirtualDevice) {
let keysym = char_to_keysym(chr);
let portal_state: u32 = if down { 1 } else { 0 };
if can_input_via_keysym(chr, keysym) {
let portal_info = {
let session_info = RDP_SESSION_INFO.lock().unwrap();
session_info
.as_ref()
.map(|info| (info.conn.clone(), info.session.clone()))
};
if let Some((ref conn, ref session)) = portal_info {
let portal = scrap::wayland::pipewire::get_portal(conn);
if portal
.notify_keyboard_keysym(session, HashMap::new(), keysym, portal_state)
.is_ok()
{
return;
}
}
// Portal unavailable or failed, fallback to uinput
let key = enigo::Key::Layout(chr);
if let Ok((evdev_key, is_shift)) = map_key(&key) {
if down {
// Press: Shift↓ (if needed) → Key↓
if is_shift {
let shift_down =
InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1);
if let Err(e) = keyboard.emit(&[shift_down]) {
log::warn!("input_char_wayland_key_event: failed to press Shift for '{}': {:?}", chr, e);
}
}
let key_down = InputEvent::new(EventType::KEY, evdev_key.code(), 1);
allow_err!(keyboard.emit(&[key_down]));
} else {
// Release: Key↑ → Shift↑ (if needed)
let key_up = InputEvent::new(EventType::KEY, evdev_key.code(), 0);
allow_err!(keyboard.emit(&[key_up]));
if is_shift {
let shift_up =
InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 0);
if let Err(e) = keyboard.emit(&[shift_up]) {
log::warn!("input_char_wayland_key_event: failed to release Shift for '{}': {:?}", chr, e);
}
}
}
}
} else {
// Non-ASCII: no reliable down/up semantics available.
// Clipboard paste is atomic and handled elsewhere.
log::debug!(
"Skipping non-ASCII character key {} in uinput service",
if down { "down" } else { "up" }
);
}
}
/// Check if character can be input via keysym (ASCII printable with valid keysym).
#[inline]
pub(crate) fn can_input_via_keysym(c: char, keysym: i32) -> bool {
// ASCII printable: 0x20 (space) to 0x7E (tilde)
(c as u32 >= 0x20 && c as u32 <= 0x7E) && keysym != 0
}
/// Convert a Unicode character to X11 keysym.
pub(crate) fn char_to_keysym(c: char) -> i32 {
let codepoint = c as u32;
if codepoint == 0 {
// Null character has no keysym
0
} else if (0x20..=0x7E).contains(&codepoint) {
// ASCII printable (0x20-0x7E): keysym == Unicode codepoint
codepoint as i32
} else if (0xA0..=0xFF).contains(&codepoint) {
// Latin-1 supplement (0xA0-0xFF): keysym == Unicode codepoint (per X11 keysym spec)
codepoint as i32
} else {
// Everything else (control chars 0x01-0x1F, DEL 0x7F, and all other non-ASCII Unicode):
// keysym = 0x01000000 | codepoint (X11 Unicode keysym encoding)
(0x0100_0000 | codepoint) as i32
}
}
fn create_uinput_keyboard() -> ResultType<VirtualDevice> {
// TODO: ensure keys here
let mut keys = AttributeSet::<evdev::Key>::new();
@@ -390,13 +552,13 @@ pub mod service {
pub fn map_key(key: &enigo::Key) -> ResultType<(evdev::Key, bool)> {
if let Some(k) = KEY_MAP.get(&key) {
log::trace!("mapkey {:?}, get {:?}", &key, &k);
log::trace!("mapkey matched in KEY_MAP, evdev={:?}", &k);
return Ok((k.clone(), false));
} else {
match key {
enigo::Key::Layout(c) => {
if let Some((k, is_shift)) = KEY_MAP_LAYOUT.get(&c) {
log::trace!("mapkey {:?}, get {:?}", &key, k);
log::trace!("mapkey Layout matched, evdev={:?}", k);
return Ok((k.clone(), is_shift.clone()));
}
}
@@ -421,41 +583,68 @@ pub mod service {
keyboard: &mut VirtualDevice,
data: &DataKeyboard,
) {
log::trace!("handle_keyboard {:?}", &data);
let data_desc = match data {
DataKeyboard::Sequence(seq) => format!("Sequence(len={})", seq.len()),
DataKeyboard::KeyDown(Key::Layout(_))
| DataKeyboard::KeyUp(Key::Layout(_))
| DataKeyboard::KeyClick(Key::Layout(_)) => "Layout(<redacted>)".to_string(),
_ => format!("{:?}", data),
};
log::trace!("handle_keyboard received: {}", data_desc);
match data {
DataKeyboard::Sequence(_seq) => {
// ignore
DataKeyboard::Sequence(seq) => {
// Normally handled by --server process (input_text_via_clipboard_server).
// Fallback: input_text_wayland handles ASCII via keysym/uinput;
// non-ASCII will be skipped (no clipboard access in --service process).
if !seq.is_empty() {
input_text_wayland(seq, keyboard);
}
}
DataKeyboard::KeyDown(enigo::Key::Raw(code)) => {
let down_event = InputEvent::new(EventType::KEY, *code - 8, 1);
allow_err!(keyboard.emit(&[down_event]));
}
DataKeyboard::KeyUp(enigo::Key::Raw(code)) => {
let up_event = InputEvent::new(EventType::KEY, *code - 8, 0);
allow_err!(keyboard.emit(&[up_event]));
}
DataKeyboard::KeyDown(key) => {
if let Ok((k, is_shift)) = map_key(key) {
if is_shift {
let down_event =
InputEvent::new(EventType::KEY, evdev::Key::KEY_LEFTSHIFT.code(), 1);
allow_err!(keyboard.emit(&[down_event]));
}
let down_event = InputEvent::new(EventType::KEY, k.code(), 1);
if *code < 8 {
log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code);
} else {
let down_event = InputEvent::new(EventType::KEY, *code - 8, 1);
allow_err!(keyboard.emit(&[down_event]));
}
}
DataKeyboard::KeyUp(key) => {
if let Ok((k, _)) = map_key(key) {
let up_event = InputEvent::new(EventType::KEY, k.code(), 0);
DataKeyboard::KeyUp(enigo::Key::Raw(code)) => {
if *code < 8 {
log::error!("Invalid Raw keycode {} (must be >= 8 due to XKB offset), skipping", code);
} else {
let up_event = InputEvent::new(EventType::KEY, *code - 8, 0);
allow_err!(keyboard.emit(&[up_event]));
}
}
DataKeyboard::KeyDown(key) => {
if let Key::Layout(chr) = key {
input_char_wayland_key_event(*chr, true, keyboard);
} else {
if let Ok((k, _is_shift)) = map_key(key) {
let down_event = InputEvent::new(EventType::KEY, k.code(), 1);
allow_err!(keyboard.emit(&[down_event]));
}
}
}
DataKeyboard::KeyUp(key) => {
if let Key::Layout(chr) = key {
input_char_wayland_key_event(*chr, false, keyboard);
} else {
if let Ok((k, _)) = map_key(key) {
let up_event = InputEvent::new(EventType::KEY, k.code(), 0);
allow_err!(keyboard.emit(&[up_event]));
}
}
}
DataKeyboard::KeyClick(key) => {
if let Ok((k, _)) = map_key(key) {
let down_event = InputEvent::new(EventType::KEY, k.code(), 1);
let up_event = InputEvent::new(EventType::KEY, k.code(), 0);
allow_err!(keyboard.emit(&[down_event, up_event]));
if let Key::Layout(chr) = key {
input_text_wayland(&chr.to_string(), keyboard);
} else {
if let Ok((k, _is_shift)) = map_key(key) {
let down_event = InputEvent::new(EventType::KEY, k.code(), 1);
let up_event = InputEvent::new(EventType::KEY, k.code(), 0);
allow_err!(keyboard.emit(&[down_event, up_event]));
}
}
}
DataKeyboard::GetKeyState(key) => {
@@ -580,9 +769,13 @@ pub mod service {
}
fn spawn_keyboard_handler(mut stream: Connection) {
log::debug!("spawn_keyboard_handler: new keyboard handler connection");
tokio::spawn(async move {
let mut keyboard = match create_uinput_keyboard() {
Ok(keyboard) => keyboard,
Ok(keyboard) => {
log::debug!("UInput keyboard device created successfully");
keyboard
}
Err(e) => {
log::error!("Failed to create keyboard {}", e);
return;
@@ -602,6 +795,7 @@ pub mod service {
handle_keyboard(&mut stream, &mut keyboard, &data).await;
}
_ => {
log::warn!("Unexpected data type in keyboard handler");
}
}
}

View File

@@ -123,7 +123,7 @@ fn check_update(manually: bool) -> ResultType<()> {
if !(manually || config::Config::get_bool_option(config::keys::OPTION_ALLOW_AUTO_UPDATE)) {
return Ok(());
}
if !do_check_software_update().is_ok() {
if do_check_software_update().is_err() {
// ignore
return Ok(());
}
@@ -185,7 +185,7 @@ fn check_update(manually: bool) -> ResultType<()> {
let mut file = std::fs::File::create(&file_path)?;
file.write_all(&file_data)?;
}
// We have checked if the `conns`` is empty before, but we need to check again.
// We have checked if the `conns` is empty before, but we need to check again.
// No need to care about the downloaded file here, because it's rare case that the `conns` are empty
// before the download, but not empty after the download.
if has_no_active_conns() {