From 1a02d75fdc81d5b9b9e289abf38a228692f4bb91 Mon Sep 17 00:00:00 2001 From: Soar360 Date: Tue, 30 Jun 2026 16:43:51 +0800 Subject: [PATCH 1/5] feat: add RdpDemo Avalonia desktop project Demonstrates basic RdpBridge integration: connection form, framebuffer display via WriteableBitmap, mouse/keyboard forwarding, state/status callbacks, and fit/1:1 scale toggle. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- dotnet/RdpBridge.slnx | 1 + dotnet/RdpDemo/App.axaml | 7 + dotnet/RdpDemo/App.axaml.cs | 24 ++ dotnet/RdpDemo/Program.cs | 15 ++ dotnet/RdpDemo/RdpDemo.csproj | 37 ++++ .../RdpDemo/ViewModels/MainWindowViewModel.cs | 144 ++++++++++++ dotnet/RdpDemo/Views/MainWindow.axaml | 55 +++++ dotnet/RdpDemo/Views/MainWindow.axaml.cs | 13 ++ dotnet/RdpDemo/Views/RdpView.axaml | 54 +++++ dotnet/RdpDemo/Views/RdpView.axaml.cs | 206 ++++++++++++++++++ 10 files changed, 556 insertions(+) create mode 100644 dotnet/RdpDemo/App.axaml create mode 100644 dotnet/RdpDemo/App.axaml.cs create mode 100644 dotnet/RdpDemo/Program.cs create mode 100644 dotnet/RdpDemo/RdpDemo.csproj create mode 100644 dotnet/RdpDemo/ViewModels/MainWindowViewModel.cs create mode 100644 dotnet/RdpDemo/Views/MainWindow.axaml create mode 100644 dotnet/RdpDemo/Views/MainWindow.axaml.cs create mode 100644 dotnet/RdpDemo/Views/RdpView.axaml create mode 100644 dotnet/RdpDemo/Views/RdpView.axaml.cs 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/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..e2bc109 --- /dev/null +++ b/dotnet/RdpDemo/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,144 @@ +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; + + [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)); + + [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 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"; + }); + + _ = 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?.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); + } + + 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?.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 @@ + + + + + + + + + + + + + + + + + + + + + + + +