diff --git a/dotnet/Devolutions.MsRdpEx.Avalonia/RdpClientView.cs b/dotnet/Devolutions.MsRdpEx.Avalonia/RdpClientView.cs index 16f1327..29ddc37 100644 --- a/dotnet/Devolutions.MsRdpEx.Avalonia/RdpClientView.cs +++ b/dotnet/Devolutions.MsRdpEx.Avalonia/RdpClientView.cs @@ -76,6 +76,7 @@ public class RdpClientView : NativeControlHost, IDisposable private RdpDisplayMode displayMode = RdpDisplayMode.FitToWindow; private int zoomLevel = 100; private bool disposed; + private IPlatformHandle? detachedNativeControl; public RdpClientView() { @@ -519,6 +520,16 @@ public void Dispose() // leaves a fullscreen shell behind. ExitFullScreen(); TeardownSession(); + + // NativeControlHost no longer tracks a control after its deferred + // destruction callback. If the view is still detached, release the + // retained HWND explicitly now that the owner has permanently closed + // the session. + if (detachedNativeControl is { } control) + { + detachedNativeControl = null; + base.DestroyNativeControlCore(control); + } } protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) @@ -583,6 +594,15 @@ protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent) { + // Avalonia reparents the returned HWND into the new native host. Reuse + // the existing control so a docking or tab detach does not replace the + // connected ActiveX session with a fresh, unconfigured surface. + if (detachedNativeControl is { } control) + { + detachedNativeControl = null; + return control; + } + nint window = CreateWindowExW( 0, StaticWindowClass, @@ -623,6 +643,16 @@ protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle paren protected override void DestroyNativeControlCore(IPlatformHandle control) { + // NativeControlHost destroys controls that remain outside the visual + // tree past its short reparenting grace period. A docking tab can stay + // detached much longer, so retain its HWND until either it is attached + // again or the owning view is explicitly disposed. + if (!disposed) + { + detachedNativeControl = control; + return; + } + // Release the OLE host while the window still exists; the base // implementation destroys the window itself afterwards. TeardownSession(); diff --git a/dotnet/MsRdpEx_Test/AvaloniaHosting.cs b/dotnet/MsRdpEx_Test/AvaloniaHosting.cs index ef7aacd..0c2b582 100644 --- a/dotnet/MsRdpEx_Test/AvaloniaHosting.cs +++ b/dotnet/MsRdpEx_Test/AvaloniaHosting.cs @@ -1,5 +1,8 @@ using Devolutions.MsRdpEx.Avalonia; +using Avalonia.Controls.Platform; +using Avalonia.Platform; + namespace MsRdpEx.Tests { public class AvaloniaHostingTests @@ -111,5 +114,63 @@ public void OleScopeDefersOwnerUninitializeWhileSessionsRemain() RdpActiveXSession.EnterOleScope(); Assert.True(RdpActiveXSession.ExitOleScope(true)); } + + [Fact] + public void RdpClientViewReusesDetachedNativeControl() + { + using TestableRdpClientView view = new(); + TestNativeControlHandle control = new(); + + view.DetachNativeControl(control); + + Assert.False(control.IsDestroyed); + Assert.Same(control, view.AttachNativeControl(new TestPlatformHandle())); + + view.Dispose(); + view.DetachNativeControl(control); + Assert.True(control.IsDestroyed); + } + + [Fact] + public void RdpClientViewDisposeDestroysDetachedNativeControl() + { + TestableRdpClientView view = new(); + TestNativeControlHandle control = new(); + + view.DetachNativeControl(control); + view.Dispose(); + + Assert.True(control.IsDestroyed); + } + + private sealed class TestableRdpClientView : RdpClientView + { + public IPlatformHandle AttachNativeControl(IPlatformHandle parent) + { + return base.CreateNativeControlCore(parent); + } + + public void DetachNativeControl(IPlatformHandle control) + { + base.DestroyNativeControlCore(control); + } + } + + private class TestPlatformHandle : IPlatformHandle + { + public nint Handle => 1; + + public string HandleDescriptor => "TEST"; + } + + private sealed class TestNativeControlHandle : TestPlatformHandle, INativeControlHostDestroyableControlHandle + { + public bool IsDestroyed { get; private set; } + + public void Destroy() + { + IsDestroyed = true; + } + } } }