diff --git a/flutter/lib/models/input_model.dart b/flutter/lib/models/input_model.dart index 675a95e42..ab9278217 100644 --- a/flutter/lib/models/input_model.dart +++ b/flutter/lib/models/input_model.dart @@ -20,7 +20,7 @@ import '../common.dart'; import '../consts.dart'; /// Mouse button enum. -enum MouseButtons { left, right, wheel, back } +enum MouseButtons { left, right, wheel, back, forward } const _kMouseEventDown = 'mousedown'; const _kMouseEventUp = 'mouseup'; @@ -157,6 +157,8 @@ extension ToString on MouseButtons { return 'wheel'; case MouseButtons.back: return 'back'; + case MouseButtons.forward: + return 'forward'; } } } @@ -327,6 +329,80 @@ class ToReleaseKeys { } class InputModel { + // Side mouse button support for Linux. + // Flutter's Linux embedder drops X11 button 8/9 events, so we capture them + // natively via GDK and forward through the platform channel. + static InputModel? _activeSideButtonModel; + // Tracks per-button which model received a side button down event, so the + // matching up event is routed there even if the pointer has left the view + // or a different button was pressed in between. + static final Map _sideButtonDownModels = {}; + static bool _sideButtonChannelInitialized = false; + + /// Each Flutter engine (main window + sub-windows from desktop_multi_window) + /// runs its own Dart isolate with its own statics. Called from initEnv() + /// which runs per-engine, so each isolate registers its own handler tied + /// to its own set of InputModels. + static void initSideButtonChannel() { + if (!Platform.isLinux) return; + if (_sideButtonChannelInitialized) return; + _sideButtonChannelInitialized = true; + + const channel = MethodChannel('org.rustdesk.rustdesk/side_buttons'); + channel.setMethodCallHandler((call) async { + if (call.method == 'onSideMouseButton') { + final args = call.arguments as Map; + final button = args['button'] as String; + final type = args['type'] as String; + final mb = button == 'back' ? MouseButtons.back : MouseButtons.forward; + + if (type == 'down') { + final model = _activeSideButtonModel; + if (model != null && + !(model.isViewOnly && !model.showMyCursor) && + model.keyboardPerm && + !model.isViewCamera) { + _sideButtonDownModels[mb] = model; + // Fire-and-forget to avoid blocking the platform channel handler. + unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) { + debugPrint('[InputModel] failed to send side button $type for $mb: $e'); + })); + } + } else { + // Only route 'up' when we recorded the matching 'down'; + // dropping avoids sending unpaired 'up' to an unrelated session. + // Use _sendMouseUnchecked to bypass permission checks so the + // release always goes through even if permissions changed. + final model = _sideButtonDownModels.remove(mb); + if (model != null) { + unawaited(model._sendMouseUnchecked(type, mb).catchError((Object e) { + debugPrint('[InputModel] failed to send side button $type for $mb: $e'); + })); + } + } + } + return null; + }); + } + + /// Clear any static references to this model (prevents stale routing). + /// Releases any held side buttons on the peer so closing a session + /// mid-press does not leave a stuck button. + void disposeSideButtonTracking() { + if (_activeSideButtonModel == this) _activeSideButtonModel = null; + final held = _sideButtonDownModels.entries + .where((e) => e.value == this) + .map((e) => e.key) + .toList(); + for (final mb in held) { + _sideButtonDownModels.remove(mb); + // Best-effort release; session may already be tearing down. + unawaited(_sendMouseUnchecked('up', mb).catchError((Object e) { + debugPrint('[InputModel] failed to release side button $mb: $e'); + })); + } + } + final WeakReference parent; String keyboardMode = ''; @@ -412,6 +488,7 @@ class InputModel { bool get isRelativeMouseModeSupported => _relativeMouse.isSupported; InputModel(this.parent) { + initSideButtonChannel(); sessionId = parent.target!.sessionId; _relativeMouse = RelativeMouseModel( sessionId: sessionId, @@ -966,13 +1043,20 @@ class InputModel { return evt; } + /// Send mouse event unconditionally (no permission checks). + /// Used for side button releases that must go through even if permissions + /// changed after the matching down was sent. + Future _sendMouseUnchecked(String type, MouseButtons button) async { + await bind.sessionSendMouse( + sessionId: sessionId, + msg: json.encode(modify({'type': type, 'buttons': button.value}))); + } + /// Send mouse press event. Future sendMouse(String type, MouseButtons button) async { if (!keyboardPerm) return; if (isViewCamera) return; - await bind.sessionSendMouse( - sessionId: sessionId, - msg: json.encode(modify({'type': type, 'buttons': button.value}))); + await _sendMouseUnchecked(type, button); } void enterOrLeave(bool enter) { @@ -982,6 +1066,13 @@ class InputModel { _pointerInsideImage = enter; _lastWheelTsUs = 0; + // Track active model for side button events (Linux). + if (enter) { + _activeSideButtonModel = this; + } else if (_activeSideButtonModel == this) { + _activeSideButtonModel = null; + } + // Fix status if (!enter) { resetModifiers(); diff --git a/flutter/lib/models/model.dart b/flutter/lib/models/model.dart index 4533f11fa..e94834a2b 100644 --- a/flutter/lib/models/model.dart +++ b/flutter/lib/models/model.dart @@ -3932,6 +3932,7 @@ class FFI { inputModel.resetModifiers(); // Dispose relative mouse mode resources to ensure cursor is restored inputModel.disposeRelativeMouseMode(); + inputModel.disposeSideButtonTracking(); if (closeSession) { await bind.sessionClose(sessionId: sessionId); } diff --git a/flutter/linux/my_application.cc b/flutter/linux/my_application.cc index a05bb7856..210adba96 100644 --- a/flutter/linux/my_application.cc +++ b/flutter/linux/my_application.cc @@ -29,6 +29,80 @@ void try_set_transparent(GtkWindow* window, GdkScreen* screen, FlView* view); extern bool gIsConnectionManager; +// --- Side mouse button support (back/forward) --- +// Flutter's Linux embedder doesn't deliver X11 button 8/9 events to Dart. +// We intercept them via GDK and forward through a dedicated platform channel. + +static const char* kSideButtonChannelName = "org.rustdesk.rustdesk/side_buttons"; + +static gboolean on_side_button_event(GtkWidget* widget, GdkEventButton* event, gpointer user_data) { + if (event->button != 8 && event->button != 9) { + return FALSE; + } + // Ignore GDK_2BUTTON_PRESS / GDK_3BUTTON_PRESS (double/triple-click synthetic + // events) - only handle real press and release. + if (event->type != GDK_BUTTON_PRESS && event->type != GDK_BUTTON_RELEASE) { + return FALSE; + } + + FlMethodChannel* channel = FL_METHOD_CHANNEL(user_data); + if (channel == NULL) return FALSE; + + g_autoptr(FlValue) args = fl_value_new_map(); + fl_value_set_string_take(args, "button", + fl_value_new_string(event->button == 8 ? "back" : "forward")); + fl_value_set_string_take(args, "type", + fl_value_new_string(event->type == GDK_BUTTON_PRESS ? "down" : "up")); + + fl_method_channel_invoke_method(channel, "onSideMouseButton", args, + NULL, NULL, NULL); + + return TRUE; +} + +static FlMethodChannel* side_buttons_create_channel(FlEngine* engine) { + g_autoptr(FlStandardMethodCodec) codec = fl_standard_method_codec_new(); + return fl_method_channel_new( + fl_engine_get_binary_messenger(engine), + kSideButtonChannelName, + FL_METHOD_CODEC(codec)); +} + +static void side_buttons_channel_destroy(gpointer data) { + g_object_unref(data); +} + +static void side_buttons_init_for_window(GtkWindow* window, FlMethodChannel* channel) { + // Guard against double-initialization (would leave dangling signal user_data). + if (g_object_get_data(G_OBJECT(window), "side-buttons-channel") != NULL) return; + + gtk_widget_add_events(GTK_WIDGET(window), + GDK_BUTTON_PRESS_MASK | GDK_BUTTON_RELEASE_MASK); + // Store channel on the window so it stays alive and is freed with the window. + g_object_set_data_full(G_OBJECT(window), "side-buttons-channel", + g_object_ref(channel), side_buttons_channel_destroy); + g_signal_connect(window, "button-press-event", + G_CALLBACK(on_side_button_event), channel); + g_signal_connect(window, "button-release-event", + G_CALLBACK(on_side_button_event), channel); +} + +static void on_subwindow_created(FlPluginRegistry* registry) { +#if defined(GDK_WINDOWING_WAYLAND) && defined(HAS_KEYBOARD_SHORTCUTS_INHIBIT) + wayland_shortcuts_inhibit_init_for_subwindow(registry); +#endif + // Set up side button forwarding for sub-windows. + if (registry == NULL || !FL_IS_VIEW(registry)) return; + FlView* view = FL_VIEW(registry); + GtkWidget* toplevel = gtk_widget_get_toplevel(GTK_WIDGET(view)); + if (toplevel != NULL && GTK_IS_WINDOW(toplevel)) { + FlMethodChannel* channel = side_buttons_create_channel(fl_view_get_engine(view)); + if (channel == NULL) return; + side_buttons_init_for_window(GTK_WINDOW(toplevel), channel); + g_object_unref(channel); // window now owns a ref via g_object_set_data_full + } +} + GtkWidget *find_gl_area(GtkWidget *widget); // Implements GApplication::activate. @@ -96,12 +170,12 @@ 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 + // Register callback for sub-windows created by desktop_multi_window plugin. + // Handles both Wayland shortcuts inhibition (guarded inside) and side button + // forwarding. Safe to call on X11-only builds - the plugin just stores the + // callback pointer regardless of windowing system. desktop_multi_window_plugin_set_window_created_callback( - (WindowCreatedCallback)wayland_shortcuts_inhibit_init_for_subwindow); -#endif + (WindowCreatedCallback)on_subwindow_created); fl_register_plugins(FL_PLUGIN_REGISTRY(view)); @@ -116,6 +190,11 @@ static void my_application_activate(GApplication* application) { self, nullptr); + // Forward side mouse button events (back/forward) to Dart on the main window. + FlMethodChannel* side_channel = side_buttons_create_channel(fl_view_get_engine(view)); + side_buttons_init_for_window(window, side_channel); + g_object_unref(side_channel); + gtk_widget_grab_focus(GTK_WIDGET(view)); } diff --git a/libs/enigo/src/linux/xdo.rs b/libs/enigo/src/linux/xdo.rs index 26d090855..7796904f9 100644 --- a/libs/enigo/src/linux/xdo.rs +++ b/libs/enigo/src/linux/xdo.rs @@ -8,6 +8,7 @@ use crate::{Key, KeyboardControllable, MouseButton, MouseControllable}; use hbb_common::libc::c_int; +use hbb_common::x11::xlib::{Display, XCloseDisplay, XGetPointerMapping, XOpenDisplay}; use libxdo_sys::{self, xdo_t, CURRENTWINDOW}; use std::{borrow::Cow, ffi::CString}; @@ -32,6 +33,51 @@ fn mousebutton(button: MouseButton) -> c_int { } } +/// Minimum number of buttons the X11 core pointer must support. +/// Buttons 8 (Back) and 9 (Forward) are needed for mouse side buttons. +const MIN_POINTER_BUTTONS: usize = 9; + +/// Check that the X11 core pointer's button map includes at least 9 buttons +/// so that `XTestFakeButtonEvent` can simulate Back (8) and Forward (9). +/// +/// RustDesk's uinput "Mouse passthrough" device normally provides enough +/// buttons, but we log a warning if the map is too small so the issue is +/// diagnosable. `XSetPointerMapping` cannot extend the button count (its +/// length must match `XGetPointerMapping`), so we only diagnose here. +fn check_x11_button_map() { + // Skip on non-X11 sessions to avoid noisy "XOpenDisplay failed" warnings + // on pure Wayland or headless environments without $DISPLAY. + if std::env::var_os("DISPLAY").is_none() { + return; + } + + let display: *mut Display = unsafe { XOpenDisplay(std::ptr::null()) }; + if display.is_null() { + log::warn!("XOpenDisplay failed, cannot check button map"); + return; + } + + let mut current_map = [0u8; 32]; + let nbuttons = + unsafe { XGetPointerMapping(display, current_map.as_mut_ptr(), current_map.len() as i32) }; + unsafe { XCloseDisplay(display) }; + + if nbuttons < 0 { + log::warn!("XGetPointerMapping failed (returned {nbuttons})"); + return; + } + + let nbuttons = nbuttons as usize; + if nbuttons >= MIN_POINTER_BUTTONS { + log::info!("X11 pointer has {nbuttons} buttons, side buttons supported"); + } else { + log::warn!( + "X11 pointer has only {nbuttons} buttons (need {MIN_POINTER_BUTTONS}); \ + back/forward side buttons may not work until a device with more buttons is added" + ); + } +} + /// The main struct for handling the event emitting pub(super) struct EnigoXdo { xdo: *mut xdo_t, @@ -52,6 +98,7 @@ impl Default for EnigoXdo { log::warn!("Failed to create xdo context, xdo functions will be disabled"); } else { log::info!("xdo context created successfully"); + check_x11_button_map(); } Self { xdo,