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