diff --git a/src/Devolutions.Terminal.App/Platform/WindowsSurfaceRecovery.cs b/src/Devolutions.Terminal.App/Platform/WindowsSurfaceRecovery.cs new file mode 100644 index 0000000..4dd5883 --- /dev/null +++ b/src/Devolutions.Terminal.App/Platform/WindowsSurfaceRecovery.cs @@ -0,0 +1,185 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Avalonia.Controls; +using Avalonia.Interactivity; +using Avalonia.Threading; + +namespace Devolutions.Terminal.App.Platform; + +// Avalonia renders each window into a Windows.UI.Composition drawing surface. +// Windows can drop that surface's contents while the display is off, the session +// is locked or the GPU is reset, and Avalonia neither listens for +// RenderingDeviceReplaced nor presents frames while nothing is damaged. An idle +// terminal then shows only the window backdrop until something happens to repaint +// it. When the display comes back, a WM_PAINT makes Avalonia redraw the whole +// scene, which also recreates the render target if the device was lost. +public static partial class WindowsSurfaceRecovery +{ + internal const uint WmDestroy = 0x0002; + internal const uint WmDisplayChange = 0x007E; + internal const uint WmPowerBroadcast = 0x0218; + internal const uint WmWtsSessionChange = 0x02B1; + internal const nint PbtApmResumeSuspend = 0x0007; + internal const nint PbtApmResumeAutomatic = 0x0012; + internal const nint PbtPowerSettingChange = 0x8013; + internal const nint WtsConsoleConnect = 0x1; + internal const nint WtsRemoteConnect = 0x3; + internal const nint WtsSessionUnlock = 0x8; + internal const uint DisplayStateOff = 0; + + // GUID_CONSOLE_DISPLAY_STATE: 0 = off, 1 = on, 2 = dimmed. + internal static readonly Guid ConsoleDisplayState = new("6FE69556-704A-47A0-8F24-C28D936FDA47"); + + // The display-on notification can arrive before the display stack has finished + // reconfiguring, so repaint once more after it settles. + private static readonly TimeSpan SettledRepaintDelay = TimeSpan.FromSeconds(1); + private const uint DeviceNotifyWindowHandle = 0; + private const uint NotifyForThisSession = 0; + private static bool _installed; + + public static void Install() + { + if (!OperatingSystem.IsWindows() || _installed) + { + return; + } + + _installed = true; + Window.WindowOpenedEvent.AddClassHandler(OnWindowOpened); + } + + internal static unsafe bool ShouldRepaint(uint message, nint wParam, nint lParam) + { + switch (message) + { + case WmDisplayChange: + return true; + case WmWtsSessionChange: + return wParam is WtsSessionUnlock or WtsConsoleConnect or WtsRemoteConnect; + case WmPowerBroadcast when wParam is PbtApmResumeSuspend or PbtApmResumeAutomatic: + return true; + case WmPowerBroadcast when wParam == PbtPowerSettingChange && lParam != 0: + var setting = (PowerBroadcastSetting*)lParam; + return setting->PowerSetting == ConsoleDisplayState && + setting->DataLength >= sizeof(uint) && + setting->Data != DisplayStateOff; + default: + return false; + } + } + + private static void OnWindowOpened(Window window, RoutedEventArgs e) + { + if (!OperatingSystem.IsWindows() || + window.TryGetPlatformHandle()?.Handle is not { } hwnd || + hwnd == 0) + { + return; + } + + new Registration(window, hwnd).Attach(); + } + + [SupportedOSPlatform("windows")] + private sealed class Registration + { + private readonly Window _window; + private readonly nint _hwnd; + private readonly Win32Properties.CustomWndProcHookCallback _hook; + private nint _powerNotification; + private bool _sessionNotification; + private bool _destroyed; + + public Registration(Window window, nint hwnd) + { + _window = window; + _hwnd = hwnd; + _hook = OnMessage; + } + + public void Attach() + { + var displayState = ConsoleDisplayState; + _powerNotification = RegisterPowerSettingNotification( + _hwnd, + in displayState, + DeviceNotifyWindowHandle); + _sessionNotification = WTSRegisterSessionNotification(_hwnd, NotifyForThisSession); + Win32Properties.AddWndProcHookCallback(_window, _hook); + _window.Closed += (_, _) => Win32Properties.RemoveWndProcHookCallback(_window, _hook); + } + + private nint OnMessage(nint handle, uint message, nint wParam, nint lParam, ref bool handled) + { + if (message == WmDestroy) + { + Unregister(); + } + else if (ShouldRepaint(message, wParam, lParam)) + { + Repaint(); + DispatcherTimer.RunOnce(Repaint, SettledRepaintDelay); + } + + return 0; + } + + private void Repaint() + { + if (!_destroyed) + { + // Avalonia answers WM_PAINT with a full redraw of the composition target. + InvalidateRect(_hwnd, 0, false); + } + } + + private void Unregister() + { + _destroyed = true; + if (_powerNotification != 0) + { + UnregisterPowerSettingNotification(_powerNotification); + _powerNotification = 0; + } + + if (_sessionNotification) + { + WTSUnRegisterSessionNotification(_hwnd); + _sessionNotification = false; + } + } + } + + [StructLayout(LayoutKind.Sequential)] + internal struct PowerBroadcastSetting + { + public Guid PowerSetting; + public uint DataLength; + public uint Data; + } + + [LibraryImport("user32.dll", SetLastError = true)] + private static partial nint RegisterPowerSettingNotification( + nint hRecipient, + in Guid powerSettingGuid, + uint flags); + + [LibraryImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool UnregisterPowerSettingNotification(nint handle); + + [LibraryImport("wtsapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool WTSRegisterSessionNotification(nint hWnd, uint flags); + + [LibraryImport("wtsapi32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool WTSUnRegisterSessionNotification(nint hWnd); + + [LibraryImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool InvalidateRect( + nint hWnd, + nint lpRect, + [MarshalAs(UnmanagedType.Bool)] bool bErase); +} diff --git a/src/Devolutions.Terminal/App.axaml.cs b/src/Devolutions.Terminal/App.axaml.cs index 49f8227..9480282 100644 --- a/src/Devolutions.Terminal/App.axaml.cs +++ b/src/Devolutions.Terminal/App.axaml.cs @@ -30,6 +30,7 @@ public override void OnFrameworkInitializationCompleted() if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { _desktop = desktop; + WindowsSurfaceRecovery.Install(); _router = new TerminalWindowRouter( desktop, ConfigureWindow, diff --git a/tests/Devolutions.Terminal.App.Tests/WindowsSurfaceRecoveryTests.cs b/tests/Devolutions.Terminal.App.Tests/WindowsSurfaceRecoveryTests.cs new file mode 100644 index 0000000..70b2502 --- /dev/null +++ b/tests/Devolutions.Terminal.App.Tests/WindowsSurfaceRecoveryTests.cs @@ -0,0 +1,94 @@ +using System.Runtime.InteropServices; +using Devolutions.Terminal.App.Platform; +using Xunit; + +namespace Devolutions.Terminal.App.Tests; + +public sealed class WindowsSurfaceRecoveryTests +{ + [Fact] + public void RepaintsWhenTheDisplayConfigurationChanges() + { + Assert.True(WindowsSurfaceRecovery.ShouldRepaint(WindowsSurfaceRecovery.WmDisplayChange, 32, 0)); + } + + [Theory] + [InlineData(0x8, true)] // WTS_SESSION_UNLOCK + [InlineData(0x1, true)] // WTS_CONSOLE_CONNECT + [InlineData(0x3, true)] // WTS_REMOTE_CONNECT + [InlineData(0x7, false)] // WTS_SESSION_LOCK + [InlineData(0x2, false)] // WTS_CONSOLE_DISCONNECT + public void RepaintsWhenTheSessionBecomesVisibleAgain(long reason, bool expected) + { + Assert.Equal( + expected, + WindowsSurfaceRecovery.ShouldRepaint(WindowsSurfaceRecovery.WmWtsSessionChange, (nint)reason, 0)); + } + + [Theory] + [InlineData(0x7, true)] // PBT_APMRESUMESUSPEND + [InlineData(0x12, true)] // PBT_APMRESUMEAUTOMATIC + [InlineData(0x4, false)] // PBT_APMSUSPEND + [InlineData(0xA, false)] // PBT_APMPOWERSTATUSCHANGE + public void RepaintsAfterResume(long eventType, bool expected) + { + Assert.Equal( + expected, + WindowsSurfaceRecovery.ShouldRepaint(WindowsSurfaceRecovery.WmPowerBroadcast, (nint)eventType, 0)); + } + + [Theory] + [InlineData(0u, false)] // off + [InlineData(1u, true)] // on + [InlineData(2u, true)] // dimmed + public void RepaintsWhenTheDisplayTurnsBackOn(uint state, bool expected) + { + Assert.Equal( + expected, + ShouldRepaintForPowerSetting(WindowsSurfaceRecovery.ConsoleDisplayState, state)); + } + + [Fact] + public void IgnoresOtherPowerSettings() + { + Assert.False(ShouldRepaintForPowerSetting(Guid.NewGuid(), 1)); + Assert.False(WindowsSurfaceRecovery.ShouldRepaint( + WindowsSurfaceRecovery.WmPowerBroadcast, + WindowsSurfaceRecovery.PbtPowerSettingChange, + 0)); + } + + [Theory] + [InlineData(0x000Fu)] // WM_PAINT + [InlineData(0x0005u)] // WM_SIZE + [InlineData(0x0002u)] // WM_DESTROY + public void IgnoresUnrelatedMessages(uint message) + { + Assert.False(WindowsSurfaceRecovery.ShouldRepaint(message, 0x8, 0)); + } + + private static bool ShouldRepaintForPowerSetting(Guid powerSetting, uint data) + { + var buffer = Marshal.AllocHGlobal(Marshal.SizeOf()); + try + { + Marshal.StructureToPtr( + new WindowsSurfaceRecovery.PowerBroadcastSetting + { + PowerSetting = powerSetting, + DataLength = sizeof(uint), + Data = data, + }, + buffer, + fDeleteOld: false); + return WindowsSurfaceRecovery.ShouldRepaint( + WindowsSurfaceRecovery.WmPowerBroadcast, + WindowsSurfaceRecovery.PbtPowerSettingChange, + buffer); + } + finally + { + Marshal.FreeHGlobal(buffer); + } + } +}