Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions dotnet/RdpBridge.slnx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<Solution>
<Project Path="RdpBridge/RdpBridge.csproj" />
<Project Path="RdpSmokeTest/RdpSmokeTest.csproj" />
<Project Path="RdpDemo/RdpDemo.csproj" />
</Solution>
49 changes: 49 additions & 0 deletions dotnet/RdpBridge/RdpBridgeClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -31,6 +32,12 @@ static RdpBridgeClient()
public event Action<RdpState>? StateChanged;
public event Action<string>? ClipboardReceived;

/// <summary>
/// Fired when the local clipboard text changes (Windows only; never fired on other platforms).
/// Requires <see cref="StartLocalClipboardMonitor"/> to be called first.
/// </summary>
public event Action<string>? LocalClipboardChanged;


public RdpBridgeClient(IRdpFrameReceiver? frameReceiver = null)
{
Expand Down Expand Up @@ -112,6 +119,29 @@ public bool SetClipboardText(string text)
return NativeMethods.RdpBridge_clipboard_set_text(_handle, text) == 0;
}

/// <summary>
/// Start monitoring local clipboard changes. On Windows this uses
/// AddClipboardFormatListener (event-driven). On other platforms this is
/// a no-op and <see cref="LocalClipboardChanged"/> is never fired.
/// </summary>
public bool StartLocalClipboardMonitor()
{
if (_handle == IntPtr.Zero) return false;
_localClipboardCallback = OnLocalClipboard;
return NativeMethods.RdpBridge_start_local_clipboard_monitor(
_handle, _localClipboardCallback, IntPtr.Zero) == 0;
}

/// <summary>
/// Stop local clipboard monitoring.
/// </summary>
public void StopLocalClipboardMonitor()
{
if (_handle == IntPtr.Zero) return;
NativeMethods.RdpBridge_stop_local_clipboard_monitor(_handle);
_localClipboardCallback = null;
}

/// <summary>
/// Request a dynamic desktop resize. Safe to call before or after Connect.
/// </summary>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 7 additions & 0 deletions dotnet/RdpDemo/App.axaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="RdpDemo.App">
<Application.Styles>
<FluentTheme/>
</Application.Styles>
</Application>
24 changes: 24 additions & 0 deletions dotnet/RdpDemo/App.axaml.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
15 changes: 15 additions & 0 deletions dotnet/RdpDemo/Program.cs
Original file line number Diff line number Diff line change
@@ -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<App>()
.UsePlatformDetect()
.LogToTrace();
}
37 changes: 37 additions & 0 deletions dotnet/RdpDemo/RdpDemo.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<AssemblyName>RdpDemo</AssemblyName>
<RootNamespace>RdpDemo</RootNamespace>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>

<ItemGroup>
<AvaloniaResource Include="Assets/**" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\RdpBridge\RdpBridge.csproj" />
</ItemGroup>

<ItemGroup Condition="'$(RuntimeIdentifier)' == ''">
<None Include="runtimes/**/native/*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup Condition="'$(RuntimeIdentifier)' != ''">
<None Include="runtimes/$(RuntimeIdentifier)/native/*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>

</Project>
199 changes: 199 additions & 0 deletions dotnet/RdpDemo/ViewModels/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
@@ -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<string, Task>? _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));

/// <summary>
/// Called by the View once it has a TopLevel reference.
/// Only the setter is needed — local→remote is handled by the native monitor.
/// </summary>
public void SetClipboardAccessors(Func<string, Task> 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<byte> 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<byte>((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;
}
}

Loading
Loading