diff --git a/dotnet/RdpBridge.slnx b/dotnet/RdpBridge.slnx
index e191128..68aee67 100644
--- a/dotnet/RdpBridge.slnx
+++ b/dotnet/RdpBridge.slnx
@@ -1,4 +1,5 @@
+
diff --git a/dotnet/RdpBridge/RdpBridgeClient.cs b/dotnet/RdpBridge/RdpBridgeClient.cs
index 7f4d0de..a88719b 100644
--- a/dotnet/RdpBridge/RdpBridgeClient.cs
+++ b/dotnet/RdpBridge/RdpBridgeClient.cs
@@ -19,6 +19,7 @@ public sealed class RdpBridgeClient : IDisposable
private readonly DisconnectCallback _disconnectCallback;
private readonly StateCallback _stateCallback;
private readonly ClipboardCallback _clipboardCallback;
+ private LocalClipboardCallback? _localClipboardCallback;
private IntPtr _handle;
static RdpBridgeClient()
@@ -31,6 +32,12 @@ static RdpBridgeClient()
public event Action? StateChanged;
public event Action? ClipboardReceived;
+ ///
+ /// Fired when the local clipboard text changes (Windows only; never fired on other platforms).
+ /// Requires to be called first.
+ ///
+ public event Action? LocalClipboardChanged;
+
public RdpBridgeClient(IRdpFrameReceiver? frameReceiver = null)
{
@@ -112,6 +119,29 @@ public bool SetClipboardText(string text)
return NativeMethods.RdpBridge_clipboard_set_text(_handle, text) == 0;
}
+ ///
+ /// Start monitoring local clipboard changes. On Windows this uses
+ /// AddClipboardFormatListener (event-driven). On other platforms this is
+ /// a no-op and is never fired.
+ ///
+ public bool StartLocalClipboardMonitor()
+ {
+ if (_handle == IntPtr.Zero) return false;
+ _localClipboardCallback = OnLocalClipboard;
+ return NativeMethods.RdpBridge_start_local_clipboard_monitor(
+ _handle, _localClipboardCallback, IntPtr.Zero) == 0;
+ }
+
+ ///
+ /// Stop local clipboard monitoring.
+ ///
+ public void StopLocalClipboardMonitor()
+ {
+ if (_handle == IntPtr.Zero) return;
+ NativeMethods.RdpBridge_stop_local_clipboard_monitor(_handle);
+ _localClipboardCallback = null;
+ }
+
///
/// Request a dynamic desktop resize. Safe to call before or after Connect.
///
@@ -257,6 +287,13 @@ private void OnClipboard(IntPtr userData, IntPtr text)
ClipboardReceived?.Invoke(value);
}
+ private void OnLocalClipboard(IntPtr userData, IntPtr text)
+ {
+ var value = text == IntPtr.Zero ? string.Empty : Marshal.PtrToStringUTF8(text) ?? string.Empty;
+ if (!string.IsNullOrEmpty(value))
+ LocalClipboardChanged?.Invoke(value);
+ }
+
private void DebugLog(string message)
{
try
@@ -297,6 +334,9 @@ private static string SanitizeLogValue(string? value)
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void ClipboardCallback(IntPtr userData, IntPtr utf8Text);
+ [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
+ private delegate void LocalClipboardCallback(IntPtr userData, IntPtr utf8Text);
+
private static class NativeMethods
{
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
@@ -362,6 +402,15 @@ public static extern int RdpBridge_clipboard_set_text(
IntPtr handle,
[MarshalAs(UnmanagedType.LPUTF8Str)] string utf8Text);
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern int RdpBridge_start_local_clipboard_monitor(
+ IntPtr handle,
+ LocalClipboardCallback callback,
+ IntPtr userData);
+
+ [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
+ public static extern void RdpBridge_stop_local_clipboard_monitor(IntPtr handle);
+
[DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)]
public static extern int RdpBridge_resize(IntPtr handle, int width, int height);
}
diff --git a/dotnet/RdpDemo/App.axaml b/dotnet/RdpDemo/App.axaml
new file mode 100644
index 0000000..880b342
--- /dev/null
+++ b/dotnet/RdpDemo/App.axaml
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/dotnet/RdpDemo/App.axaml.cs b/dotnet/RdpDemo/App.axaml.cs
new file mode 100644
index 0000000..fe34dd1
--- /dev/null
+++ b/dotnet/RdpDemo/App.axaml.cs
@@ -0,0 +1,24 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using RdpDemo.Views;
+
+namespace RdpDemo;
+
+public partial class App : Application
+{
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+}
diff --git a/dotnet/RdpDemo/Program.cs b/dotnet/RdpDemo/Program.cs
new file mode 100644
index 0000000..955315e
--- /dev/null
+++ b/dotnet/RdpDemo/Program.cs
@@ -0,0 +1,15 @@
+using Avalonia;
+
+namespace RdpDemo;
+
+internal class Program
+{
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .LogToTrace();
+}
diff --git a/dotnet/RdpDemo/RdpDemo.csproj b/dotnet/RdpDemo/RdpDemo.csproj
new file mode 100644
index 0000000..d7445d5
--- /dev/null
+++ b/dotnet/RdpDemo/RdpDemo.csproj
@@ -0,0 +1,37 @@
+
+
+
+ WinExe
+ net10.0
+ enable
+ enable
+ true
+ RdpDemo
+ RdpDemo
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/RdpDemo/ViewModels/MainWindowViewModel.cs b/dotnet/RdpDemo/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..c581952
--- /dev/null
+++ b/dotnet/RdpDemo/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,199 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading.Tasks;
+using Avalonia;
+using Avalonia.Media.Imaging;
+using Avalonia.Platform;
+using Avalonia.Threading;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using LuYao.RdpBridge;
+
+namespace RdpDemo.ViewModels;
+
+public partial class MainWindowViewModel : ObservableObject, IRdpFrameReceiver, IDisposable
+{
+ private RdpBridgeClient? _client;
+ private bool _started;
+
+ // Injected by the View; used to write remote clipboard text to local clipboard.
+ private Func? _setLocalClipboard;
+
+ // Last text received from remote — used to suppress echo when writing to local clipboard.
+ private string? _lastRemoteClipboard;
+
+ [ObservableProperty] private string _host = "192.168.1.1";
+ [ObservableProperty] private string _port = "3389";
+ [ObservableProperty] private string _username = string.Empty;
+ [ObservableProperty] private string _password = string.Empty;
+ [ObservableProperty] private string _desktopWidth = "1280";
+ [ObservableProperty] private string _desktopHeight = "800";
+
+ [ObservableProperty] private WriteableBitmap? _framebuffer;
+ [ObservableProperty] private string _statusText = "Not connected";
+ [ObservableProperty] private bool _isConnected;
+ [ObservableProperty] private bool _isFitToWindow = true;
+ [ObservableProperty] private int _remoteWidth;
+ [ObservableProperty] private int _remoteHeight;
+
+ public string ScaleModeText => IsFitToWindow ? "Fit" : "1:1";
+
+ partial void OnIsFitToWindowChanged(bool value) => OnPropertyChanged(nameof(ScaleModeText));
+
+ ///
+ /// Called by the View once it has a TopLevel reference.
+ /// Only the setter is needed — local→remote is handled by the native monitor.
+ ///
+ public void SetClipboardAccessors(Func set)
+ {
+ _setLocalClipboard = set;
+ }
+
+ [RelayCommand]
+ private void Connect()
+ {
+ if (_started) return;
+
+ if (!int.TryParse(Port, out var port) || port < 1 || port > 65535) port = 3389;
+ if (!int.TryParse(DesktopWidth, out var width) || width < 1) width = 1280;
+ if (!int.TryParse(DesktopHeight, out var height) || height < 1) height = 800;
+
+ _started = true;
+ StatusText = "Connecting...";
+
+ _client?.Dispose();
+ _client = new RdpBridgeClient(this);
+ _client.StatusChanged += msg => Dispatcher.UIThread.Post(() => StatusText = msg);
+ _client.StateChanged += state => Dispatcher.UIThread.Post(() =>
+ {
+ IsConnected = state == RdpState.Connected;
+ if (state == RdpState.Connected)
+ {
+ // Start event-driven local clipboard monitor (Windows: AddClipboardFormatListener;
+ // other platforms: silent no-op, LocalClipboardChanged never fires).
+ _client?.StartLocalClipboardMonitor();
+ }
+ else if (state is RdpState.Disconnected or RdpState.Failed)
+ {
+ _started = false;
+ StatusText = state == RdpState.Failed ? "Connection failed" : "Disconnected";
+ }
+ });
+ _client.Disconnected += () => Dispatcher.UIThread.Post(() =>
+ {
+ _started = false;
+ IsConnected = false;
+ StatusText = "Disconnected";
+ });
+
+ // Remote → local: native cliprdr channel callback.
+ _client.ClipboardReceived += text => Dispatcher.UIThread.Post(() => OnRemoteClipboardReceived(text));
+
+ // Local → remote: native monitor callback (event-driven on Windows).
+ _client.LocalClipboardChanged += text =>
+ {
+ // Guard: don't echo back text that came from the remote side.
+ if (text == _lastRemoteClipboard) return;
+ _client?.SetClipboardText(text);
+ };
+
+ _ = Task.Run(() =>
+ {
+ try
+ {
+ _client.Connect(Host, port, Username, Password, width, height);
+ }
+ catch (DllNotFoundException)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _started = false;
+ StatusText = $"{RdpBridgeClient.GetExpectedNativeLibraryName()} not found — build native library first.";
+ });
+ }
+ catch (Exception ex)
+ {
+ Dispatcher.UIThread.Post(() =>
+ {
+ _started = false;
+ StatusText = $"Error: {ex.Message}";
+ });
+ }
+ });
+ }
+
+ [RelayCommand]
+ private void Disconnect()
+ {
+ _client?.StopLocalClipboardMonitor();
+ _client?.Disconnect();
+ _started = false;
+ IsConnected = false;
+ StatusText = "Disconnected";
+ }
+
+ [RelayCommand]
+ private void ToggleScaleMode() => IsFitToWindow = !IsFitToWindow;
+
+ public void SendPointer(ushort flags, ushort x, ushort y)
+ {
+ if (IsConnected) _client?.SendPointer(flags, x, y);
+ }
+
+ public void SendKey(uint scancode, bool down)
+ {
+ if (IsConnected) _client?.SendKey(scancode, down);
+ }
+
+ // ── Remote → local clipboard ──────────────────────────────────────────────
+
+ private async void OnRemoteClipboardReceived(string text)
+ {
+ if (string.IsNullOrEmpty(text) || _setLocalClipboard == null) return;
+ if (text == _lastRemoteClipboard) return;
+
+ _lastRemoteClipboard = text;
+ try
+ {
+ await _setLocalClipboard(text);
+ }
+ catch
+ {
+ // Clipboard access can throw; never crash the session.
+ }
+ }
+
+ // ── Frame delivery ────────────────────────────────────────────────────────
+
+ unsafe void IRdpFrameReceiver.OnFrame(int width, int height, int stride, ReadOnlySpan pixels)
+ {
+ var copy = new byte[pixels.Length];
+ pixels.CopyTo(copy);
+
+ Dispatcher.UIThread.Post(() =>
+ {
+ var bmp = new WriteableBitmap(
+ new PixelSize(width, height),
+ new Vector(96, 96),
+ PixelFormat.Bgra8888,
+ AlphaFormat.Opaque);
+
+ using var locked = bmp.Lock();
+ var dst = new Span((void*)locked.Address, locked.RowBytes * locked.Size.Height);
+ var src = copy.AsSpan(0, Math.Min(copy.Length, dst.Length));
+ src.CopyTo(dst);
+
+ Framebuffer = bmp;
+ RemoteWidth = width;
+ RemoteHeight = height;
+ });
+ }
+
+ public void Dispose()
+ {
+ _client?.StopLocalClipboardMonitor();
+ _client?.Dispose();
+ _client = null;
+ }
+}
+
diff --git a/dotnet/RdpDemo/Views/MainWindow.axaml b/dotnet/RdpDemo/Views/MainWindow.axaml
new file mode 100644
index 0000000..527d799
--- /dev/null
+++ b/dotnet/RdpDemo/Views/MainWindow.axaml
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/RdpDemo/Views/MainWindow.axaml.cs b/dotnet/RdpDemo/Views/MainWindow.axaml.cs
new file mode 100644
index 0000000..3276e19
--- /dev/null
+++ b/dotnet/RdpDemo/Views/MainWindow.axaml.cs
@@ -0,0 +1,27 @@
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Input.Platform;
+using RdpDemo.ViewModels;
+
+namespace RdpDemo.Views;
+
+public partial class MainWindow : Window
+{
+ public MainWindow()
+ {
+ InitializeComponent();
+ var vm = new MainWindowViewModel();
+ DataContext = vm;
+
+ Opened += (_, _) =>
+ {
+ var clipboard = Clipboard;
+ if (clipboard == null) return;
+
+ vm.SetClipboardAccessors(
+ text => clipboard.SetTextAsync(text));
+ };
+ }
+}
+
+
diff --git a/dotnet/RdpDemo/Views/RdpView.axaml b/dotnet/RdpDemo/Views/RdpView.axaml
new file mode 100644
index 0000000..23fa0f9
--- /dev/null
+++ b/dotnet/RdpDemo/Views/RdpView.axaml
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/dotnet/RdpDemo/Views/RdpView.axaml.cs b/dotnet/RdpDemo/Views/RdpView.axaml.cs
new file mode 100644
index 0000000..67f11f6
--- /dev/null
+++ b/dotnet/RdpDemo/Views/RdpView.axaml.cs
@@ -0,0 +1,206 @@
+using System;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Media;
+using RdpDemo.ViewModels;
+
+namespace RdpDemo.Views;
+
+public partial class RdpView : UserControl
+{
+ private ushort _buttonFlags;
+ private bool _syntheticShiftDown;
+
+ public RdpView()
+ {
+ InitializeComponent();
+ AddHandler(KeyDownEvent, OnKeyDown, RoutingStrategies.Tunnel);
+ AddHandler(KeyUpEvent, OnKeyUp, RoutingStrategies.Tunnel);
+ AttachedToVisualTree += (_, _) => Focus();
+ }
+
+ // ── Pointer ──────────────────────────────────────────────────────────────
+
+ private void OnPointerMoved(object? sender, PointerEventArgs e)
+ => SendPointer(e, 0x0800);
+
+ private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
+ {
+ Focus();
+ var point = e.GetCurrentPoint(FramebufferImage);
+ if (point.Properties.IsLeftButtonPressed) _buttonFlags |= 0x1000;
+ if (point.Properties.IsMiddleButtonPressed) _buttonFlags |= 0x4000;
+ if (point.Properties.IsRightButtonPressed) _buttonFlags |= 0x2000;
+ SendPointer(e, (ushort)(0x8000 | _buttonFlags));
+ e.Handled = true;
+ }
+
+ private void OnPointerReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ var flag = e.InitialPressMouseButton switch
+ {
+ MouseButton.Left => (ushort)0x1000,
+ MouseButton.Right => (ushort)0x2000,
+ MouseButton.Middle => (ushort)0x4000,
+ _ => (ushort)0
+ };
+ if (flag != 0)
+ {
+ SendPointer(e, flag);
+ _buttonFlags &= (ushort)~flag;
+ }
+ e.Handled = true;
+ }
+
+ private void OnPointerWheelChanged(object? sender, PointerWheelEventArgs e)
+ {
+ var flag = e.Delta.Y > 0 ? (ushort)0x0200 : (ushort)(0x0200 | 0x0100);
+ SendPointer(e, flag);
+ e.Handled = true;
+ }
+
+ private void SendPointer(PointerEventArgs e, ushort flags)
+ {
+ if (DataContext is not MainWindowViewModel vm || FramebufferImage is null) return;
+ var (x, y) = MapPointer(
+ e.GetPosition(FramebufferImage),
+ FramebufferImage.Bounds.Size,
+ vm.RemoteWidth, vm.RemoteHeight, vm.IsFitToWindow);
+ vm.SendPointer(flags, (ushort)x, (ushort)y);
+ }
+
+ private static (int X, int Y) MapPointer(Point pt, Size imageSize, int rw, int rh, bool fit)
+ {
+ if (rw <= 0 || rh <= 0 || imageSize.Width <= 0 || imageSize.Height <= 0) return (0, 0);
+ var scale = fit ? Math.Min(imageSize.Width / rw, imageSize.Height / rh) : 1.0;
+ var ox = (imageSize.Width - rw * scale) / 2;
+ var oy = (imageSize.Height - rh * scale) / 2;
+ var x = (int)Math.Round((pt.X - ox) / scale);
+ var y = (int)Math.Round((pt.Y - oy) / scale);
+ return (Math.Clamp(x, 0, rw - 1), Math.Clamp(y, 0, rh - 1));
+ }
+
+ // ── Keyboard ─────────────────────────────────────────────────────────────
+
+ private void OnKeyDown(object? sender, KeyEventArgs e) => SendKey(e, true);
+ private void OnKeyUp(object? sender, KeyEventArgs e) => SendKey(e, false);
+
+ private void SendKey(KeyEventArgs e, bool down)
+ {
+ if (DataContext is not MainWindowViewModel vm) return;
+
+ if (TryGetPrintableScancode(e, out var scancode, out var needsShift))
+ {
+ if (!needsShift && down && _syntheticShiftDown)
+ {
+ vm.SendKey(0x2A, false);
+ _syntheticShiftDown = false;
+ }
+ if (needsShift && down && !_syntheticShiftDown)
+ {
+ vm.SendKey(0x2A, true);
+ _syntheticShiftDown = true;
+ }
+
+ vm.SendKey(scancode, down);
+
+ if (!down && _syntheticShiftDown)
+ {
+ vm.SendKey(0x2A, false);
+ _syntheticShiftDown = false;
+ }
+ e.Handled = true;
+ return;
+ }
+
+ var sc = ToRdpScancode(e);
+ if (sc == 0) return;
+ vm.SendKey(sc, down);
+ e.Handled = true;
+ }
+
+ private static bool TryGetPrintableScancode(KeyEventArgs e, out uint scancode, out bool needsShift)
+ {
+ scancode = 0;
+ needsShift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
+
+ if (e.Key >= Key.A && e.Key <= Key.Z)
+ {
+ scancode = e.Key switch
+ {
+ Key.A => 0x1E, Key.B => 0x30, Key.C => 0x2E, Key.D => 0x20, Key.E => 0x12,
+ Key.F => 0x21, Key.G => 0x22, Key.H => 0x23, Key.I => 0x17, Key.J => 0x24,
+ Key.K => 0x25, Key.L => 0x26, Key.M => 0x32, Key.N => 0x31, Key.O => 0x18,
+ Key.P => 0x19, Key.Q => 0x10, Key.R => 0x13, Key.S => 0x1F, Key.T => 0x14,
+ Key.U => 0x16, Key.V => 0x2F, Key.W => 0x11, Key.X => 0x2D, Key.Y => 0x15,
+ Key.Z => 0x2C, _ => 0
+ };
+ return scancode != 0;
+ }
+
+ if (e.Key >= Key.D0 && e.Key <= Key.D9)
+ {
+ var idx = e.Key - Key.D0;
+ scancode = idx == 0 ? 0x0Bu : 0x01u + (uint)idx;
+ return true;
+ }
+
+ if (e.Key >= Key.NumPad0 && e.Key <= Key.NumPad9)
+ {
+ scancode = 0x52u + (uint)(e.Key - Key.NumPad0);
+ needsShift = false;
+ return true;
+ }
+
+ scancode = e.Key switch
+ {
+ Key.Space => 0x39,
+ Key.OemMinus => 0x0C,
+ Key.OemPlus => 0x0D,
+ Key.OemOpenBrackets => 0x1A,
+ Key.OemCloseBrackets => 0x1B,
+ Key.OemPipe => 0x2B,
+ Key.OemSemicolon => 0x27,
+ Key.OemQuotes => 0x28,
+ Key.OemComma => 0x33,
+ Key.OemPeriod => 0x34,
+ Key.OemQuestion => 0x35,
+ Key.OemTilde => 0x29,
+ Key.Decimal => 0x53,
+ Key.Add => 0x4E,
+ Key.Subtract => 0x4A,
+ Key.Multiply => 0x37,
+ Key.Divide => 0x0100 | 0x35,
+ _ => 0
+ };
+ return scancode != 0;
+ }
+
+ private static uint ToRdpScancode(KeyEventArgs e) => e.Key switch
+ {
+ Key.Escape => 0x01,
+ Key.Back => 0x0E,
+ Key.Tab => 0x0F,
+ Key.Enter => 0x1C,
+ Key.LeftShift or Key.RightShift => 0x2A,
+ Key.LeftCtrl or Key.RightCtrl => 0x1D,
+ Key.LeftAlt or Key.RightAlt => 0x38,
+ Key.CapsLock => 0x3A,
+ Key.F1 => 0x3B, Key.F2 => 0x3C, Key.F3 => 0x3D, Key.F4 => 0x3E,
+ Key.F5 => 0x3F, Key.F6 => 0x40, Key.F7 => 0x41, Key.F8 => 0x42,
+ Key.F9 => 0x43, Key.F10 => 0x44, Key.F11 => 0x57, Key.F12 => 0x58,
+ Key.Home => 0x0100 | 0x47,
+ Key.Up => 0x0100 | 0x48,
+ Key.PageUp => 0x0100 | 0x49,
+ Key.Left => 0x0100 | 0x4B,
+ Key.Right => 0x0100 | 0x4D,
+ Key.End => 0x0100 | 0x4F,
+ Key.Down => 0x0100 | 0x50,
+ Key.PageDown => 0x0100 | 0x51,
+ Key.Insert => 0x0100 | 0x52,
+ Key.Delete => 0x0100 | 0x53,
+ _ => 0
+ };
+}
diff --git a/include/rdp_bridge.h b/include/rdp_bridge.h
index 53272e9..6c13c71 100644
--- a/include/rdp_bridge.h
+++ b/include/rdp_bridge.h
@@ -243,6 +243,46 @@ RDP_BRIDGE_API void RdpBridge_set_clipboard_callback(
RdpBridge_ClipboardCallback clipboard_callback,
void* user_data);
+/**
+ * Local clipboard monitor callback: called when the local clipboard text changes.
+ * Fired from a background thread on Windows (hidden HWND message loop).
+ * Not fired on platforms where monitoring is not implemented.
+ *
+ * @param user_data Opaque pointer supplied via RdpBridge_start_local_clipboard_monitor.
+ * @param utf8_text Null-terminated UTF-8 string with the new clipboard text.
+ * Valid only during the callback.
+ */
+typedef void (*RdpBridge_LocalClipboardCallback)(void* user_data, const char* utf8_text);
+
+/**
+ * Start monitoring the local clipboard for text changes.
+ * On Windows: registers AddClipboardFormatListener on a hidden HWND_MESSAGE
+ * window and fires callback on every WM_CLIPBOARDUPDATE that carries text.
+ * On other platforms: no-op, returns 0, callback is never invoked.
+ *
+ * Only one monitor is active per handle at a time; calling again replaces
+ * the previous registration. Call RdpBridge_stop_local_clipboard_monitor
+ * to stop.
+ *
+ * @param handle Session handle (used only for lifetime association).
+ * @param callback Called on clipboard change. NULL stops monitoring.
+ * @param user_data Passed unchanged to every callback invocation.
+ * @return 0 – started (or no-op on unsupported platforms).
+ * -1 – invalid handle or OS-level failure.
+ */
+RDP_BRIDGE_API int RdpBridge_start_local_clipboard_monitor(
+ void* handle,
+ RdpBridge_LocalClipboardCallback callback,
+ void* user_data);
+
+/**
+ * Stop local clipboard monitoring started by RdpBridge_start_local_clipboard_monitor.
+ * Safe to call when monitoring is not active (no-op).
+ *
+ * @param handle Session handle.
+ */
+RDP_BRIDGE_API void RdpBridge_stop_local_clipboard_monitor(void* handle);
+
/**
* Push local text to the remote clipboard.
* Safe to call from any thread while the session is connected.
diff --git a/src/RdpBridge/rdp_bridge.cpp b/src/RdpBridge/rdp_bridge.cpp
index 881f0bf..3fe9977 100644
--- a/src/RdpBridge/rdp_bridge.cpp
+++ b/src/RdpBridge/rdp_bridge.cpp
@@ -16,6 +16,7 @@
#else
#include
#include
+#include
#endif
#include
@@ -80,6 +81,14 @@ struct RdpSession
void* state_user_data = nullptr;
void* clipboard_user_data = nullptr;
+ // Local clipboard monitor (Windows only; null/zero on other platforms)
+ RdpBridge_LocalClipboardCallback local_clipboard_callback = nullptr;
+ void* local_clipboard_user_data = nullptr;
+#if defined(_WIN32)
+ HWND clipboard_hwnd = nullptr;
+ HANDLE clipboard_thread = nullptr;
+#endif
+
CliprdrClientContext* cliprdr = nullptr;
std::string pending_local_text;
@@ -1045,9 +1054,51 @@ void connection_thread(RdpSession* session)
return;
}
+ // Event loop: block on OS handles until data arrives, then dispatch.
+ // freerdp_check_fds() is a legacy busy-poll API — avoid it here.
while (session->running)
{
- if (freerdp_check_fds(session->instance) != TRUE)
+ HANDLE handles[64];
+ DWORD count = freerdp_get_event_handles(session->instance->context,
+ handles, ARRAYSIZE(handles));
+ if (count == 0)
+ {
+ set_error(session, "freerdp_get_event_handles failed.");
+ break;
+ }
+
+#if defined(_WIN32)
+ const DWORD waitResult = WaitForMultipleObjects(count, handles, FALSE, 100);
+ if (waitResult == WAIT_FAILED)
+ {
+ set_error(session, "WaitForMultipleObjects failed.");
+ break;
+ }
+#else
+ // On POSIX, freerdp_get_event_handles returns file descriptors cast to
+ // HANDLE (void*). Build an fd_set and call select() with a short timeout
+ // so we also respect session->running without busy-waiting.
+ fd_set rfds;
+ FD_ZERO(&rfds);
+ int maxfd = -1;
+ for (DWORD i = 0; i < count; ++i)
+ {
+ int fd = static_cast(reinterpret_cast(handles[i]));
+ if (fd >= 0 && fd < FD_SETSIZE)
+ {
+ FD_SET(fd, &rfds);
+ if (fd > maxfd) maxfd = fd;
+ }
+ }
+ struct timeval tv{ 0, 100'000 }; // 100 ms
+ if (maxfd >= 0)
+ select(maxfd + 1, &rfds, nullptr, nullptr, &tv);
+#endif
+
+ if (!session->running)
+ break;
+
+ if (freerdp_check_event_handles(session->instance->context) != TRUE)
{
set_error(session, "FreeRDP transport closed.");
break;
@@ -1059,6 +1110,166 @@ void connection_thread(RdpSession* session)
session->running = false;
}
+// ---------------------------------------------------------------------------
+// Local clipboard monitor (Windows only)
+// ---------------------------------------------------------------------------
+
+#if defined(_WIN32)
+
+static constexpr UINT WM_RDPBRIDGE_STOP = WM_USER + 1;
+static constexpr wchar_t CLIPBOARD_WNDCLASS[] = L"RdpBridgeClipboardMonitor";
+
+// Retrieve CF_UNICODETEXT from the clipboard and convert to UTF-8.
+static std::string get_clipboard_text_utf8()
+{
+ if (!IsClipboardFormatAvailable(CF_UNICODETEXT))
+ return {};
+
+ if (!OpenClipboard(nullptr))
+ return {};
+
+ std::string result;
+ HANDLE hData = GetClipboardData(CF_UNICODETEXT);
+ if (hData)
+ {
+ auto* wstr = static_cast(GlobalLock(hData));
+ if (wstr)
+ {
+ int len = WideCharToMultiByte(
+ CP_UTF8, 0, wstr, -1, nullptr, 0, nullptr, nullptr);
+ if (len > 1) // len includes null terminator
+ {
+ result.resize(static_cast(len - 1));
+ WideCharToMultiByte(
+ CP_UTF8, 0, wstr, -1,
+ result.data(), len, nullptr, nullptr);
+ }
+ GlobalUnlock(hData);
+ }
+ }
+ CloseClipboard();
+ return result;
+}
+
+static LRESULT CALLBACK clipboard_wnd_proc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
+{
+ if (msg == WM_CREATE)
+ {
+ auto* cs = reinterpret_cast(lp);
+ SetWindowLongPtrW(hwnd, GWLP_USERDATA,
+ reinterpret_cast(cs->lpCreateParams));
+ AddClipboardFormatListener(hwnd);
+ return 0;
+ }
+
+ if (msg == WM_DESTROY)
+ {
+ RemoveClipboardFormatListener(hwnd);
+ PostQuitMessage(0);
+ return 0;
+ }
+
+ if (msg == WM_RDPBRIDGE_STOP)
+ {
+ DestroyWindow(hwnd);
+ return 0;
+ }
+
+ if (msg == WM_CLIPBOARDUPDATE)
+ {
+ auto* session = reinterpret_cast(
+ GetWindowLongPtrW(hwnd, GWLP_USERDATA));
+ if (session)
+ {
+ const std::string text = get_clipboard_text_utf8();
+ if (!text.empty())
+ {
+ RdpBridge_LocalClipboardCallback cb = nullptr;
+ void* ud = nullptr;
+ {
+ std::lock_guard lock(session->callback_mutex);
+ cb = session->local_clipboard_callback;
+ ud = session->local_clipboard_user_data;
+ }
+ if (cb)
+ cb(ud, text.c_str());
+ }
+ }
+ return 0;
+ }
+
+ return DefWindowProcW(hwnd, msg, wp, lp);
+}
+
+static DWORD WINAPI clipboard_monitor_thread(LPVOID param)
+{
+ auto* session = static_cast(param);
+
+ // Register window class (idempotent — fails silently if already registered)
+ WNDCLASSEXW wc{};
+ wc.cbSize = sizeof(wc);
+ wc.lpfnWndProc = clipboard_wnd_proc;
+ wc.hInstance = GetModuleHandleW(nullptr);
+ wc.lpszClassName = CLIPBOARD_WNDCLASS;
+ RegisterClassExW(&wc);
+
+ HWND hwnd = CreateWindowExW(
+ 0, CLIPBOARD_WNDCLASS, nullptr, 0,
+ 0, 0, 0, 0,
+ HWND_MESSAGE, nullptr,
+ GetModuleHandleW(nullptr),
+ session); // passed to WM_CREATE via CREATESTRUCT::lpCreateParams
+
+ if (!hwnd)
+ return 1;
+
+ // Publish HWND so stop() can post WM_RDPBRIDGE_STOP
+ {
+ std::lock_guard lock(session->callback_mutex);
+ session->clipboard_hwnd = hwnd;
+ }
+
+ MSG msg;
+ while (GetMessageW(&msg, nullptr, 0, 0) > 0)
+ {
+ TranslateMessage(&msg);
+ DispatchMessageW(&msg);
+ }
+
+ {
+ std::lock_guard lock(session->callback_mutex);
+ session->clipboard_hwnd = nullptr;
+ }
+
+ return 0;
+}
+
+static void stop_local_clipboard_monitor_impl(RdpSession* session)
+{
+ HWND hwnd = nullptr;
+ HANDLE thread = nullptr;
+ {
+ std::lock_guard lock(session->callback_mutex);
+ hwnd = session->clipboard_hwnd;
+ thread = session->clipboard_thread;
+ session->clipboard_hwnd = nullptr;
+ session->clipboard_thread = nullptr;
+ session->local_clipboard_callback = nullptr;
+ session->local_clipboard_user_data = nullptr;
+ }
+
+ if (hwnd)
+ PostMessageW(hwnd, WM_RDPBRIDGE_STOP, 0, 0);
+
+ if (thread)
+ {
+ WaitForSingleObject(thread, 3000);
+ CloseHandle(thread);
+ }
+}
+
+#endif // _WIN32
+
} // anonymous namespace
// ---------------------------------------------------------------------------
@@ -1079,6 +1290,7 @@ RDP_BRIDGE_API void RdpBridge_destroy(void* handle)
if (!session)
return;
+ RdpBridge_stop_local_clipboard_monitor(handle);
RdpBridge_disconnect(handle);
free_instance(session);
delete session;
@@ -1341,4 +1553,60 @@ RDP_BRIDGE_API int RdpBridge_resize(
return 0;
}
+RDP_BRIDGE_API int RdpBridge_start_local_clipboard_monitor(
+ void* handle,
+ RdpBridge_LocalClipboardCallback callback,
+ void* user_data)
+{
+ auto* session = static_cast(handle);
+ if (!session)
+ return -1;
+
+#if defined(_WIN32)
+ // Stop any existing monitor first.
+ stop_local_clipboard_monitor_impl(session);
+
+ if (!callback)
+ return 0;
+
+ {
+ std::lock_guard lock(session->callback_mutex);
+ session->local_clipboard_callback = callback;
+ session->local_clipboard_user_data = user_data;
+ }
+
+ HANDLE thread = CreateThread(
+ nullptr, 0, clipboard_monitor_thread, session, 0, nullptr);
+ if (!thread)
+ {
+ std::lock_guard lock(session->callback_mutex);
+ session->local_clipboard_callback = nullptr;
+ session->local_clipboard_user_data = nullptr;
+ return -1;
+ }
+
+ {
+ std::lock_guard lock(session->callback_mutex);
+ session->clipboard_thread = thread;
+ }
+ return 0;
+#else
+ // Not implemented on this platform — silent no-op.
+ (void)callback;
+ (void)user_data;
+ return 0;
+#endif
+}
+
+RDP_BRIDGE_API void RdpBridge_stop_local_clipboard_monitor(void* handle)
+{
+ auto* session = static_cast(handle);
+ if (!session)
+ return;
+
+#if defined(_WIN32)
+ stop_local_clipboard_monitor_impl(session);
+#endif
+}
+
} // extern "C"