diff --git a/apps/windows/DesktopHost.cs b/apps/windows/DesktopHost.cs index 6180c63..45fd803 100644 --- a/apps/windows/DesktopHost.cs +++ b/apps/windows/DesktopHost.cs @@ -84,7 +84,7 @@ public DesktopHost(Application app) new WindowInteropHelper(window).EnsureHandle(); NativeWindow.UseHardwareTransparency(window); NativeWindow.SetInteractive(window, false); - window.ContentRendered += (_, _) => NativeWindow.UseHardwareTransparency(window); + window.ContentRendered += (_, _) => { NativeWindow.UseHardwareTransparency(window); NativeWindow.SetInteractive(window, adjusting); }; SwitchVisualization(preferences.Visualization); tray = new Forms.NotifyIcon { Text = "Miucs", ContextMenuStrip = menu }; trayAnimation = new AnimatedTrayIcon(tray, () => AdvanceCatHead(Now)); diff --git a/apps/windows/NativeWindow.cs b/apps/windows/NativeWindow.cs index 64194c2..420d2af 100644 --- a/apps/windows/NativeWindow.cs +++ b/apps/windows/NativeWindow.cs @@ -1,4 +1,5 @@ using System; +using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Windows; using System.Windows.Interop; @@ -9,14 +10,18 @@ namespace Miucs.Windows; internal static class NativeWindow { private const int ExtendedStyle = -20; - private const long ToolWindow = 0x80, Transparent = 0x20, NoActivate = 0x08000000; + private const int HitTestMessage = 0x0084; + private const int StyleChangingMessage = 0x007C; + private const long ToolWindow = 0x80, Transparent = 0x20, NoActivate = 0x08000000, Layered = 0x00080000; [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] private static extern IntPtr GetWindowLongPtr(IntPtr hwnd, int index); [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] private static extern IntPtr SetWindowLongPtr(IntPtr hwnd, int index, IntPtr value); [DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter, int x, int y, int cx, int cy, uint flags); [DllImport("user32.dll")] private static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint flags); [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern bool GetMonitorInfo(IntPtr monitor, ref MonitorInfo info); + [DllImport("user32.dll")] private static extern IntPtr SendMessage(IntPtr hwnd, int message, IntPtr wParam, IntPtr lParam); [StructLayout(LayoutKind.Sequential)] private struct Rect { public int Left, Top, Right, Bottom; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] private struct MonitorInfo { public int Size; public Rect Monitor, Work; public uint Flags; } + [StructLayout(LayoutKind.Sequential)] private struct StyleChange { public int OldStyle, NewStyle; } [StructLayout(LayoutKind.Sequential)] private struct CursorPoint { public int X, Y; } [DllImport("user32.dll")] private static extern bool GetCursorPos(out CursorPoint point); @@ -27,6 +32,13 @@ internal static class NativeWindow [DllImport("dcomp.dll")] private static extern uint DCompositionWaitForCompositorClock(uint count, IntPtr[]? handles, uint timeout); [StructLayout(LayoutKind.Sequential)] private struct Margins { public int Left, Right, Top, Bottom; } + private sealed class PassThrough + { + public bool Interactive; + } + + private static readonly ConditionalWeakTable PassThroughWindows = new(); + public static bool TryGetCursorPosition(out Point point) { bool found = GetCursorPos(out var native); @@ -43,6 +55,20 @@ public static void PositionControl(Window window, Point origin, double width, do internal static bool IsClickThrough(Window window) => (GetWindowLongPtr(new WindowInteropHelper(window).Handle, ExtendedStyle).ToInt64() & Transparent) != 0; + internal static bool IsLayered(Window window) => + (GetWindowLongPtr(new WindowInteropHelper(window).Handle, ExtendedStyle).ToInt64() & Layered) != 0; + + internal static long ExStyle(IntPtr hwnd) => GetWindowLongPtr(hwnd, ExtendedStyle).ToInt64(); + + internal static int HitTest(Window window, int screenX, int screenY) => + HitTestHandle(new WindowInteropHelper(window).Handle, screenX, screenY); + + internal static int HitTestHandle(IntPtr hwnd, int screenX, int screenY) + { + var packed = new IntPtr((screenY << 16) | (screenX & 0xFFFF)); + return (int)SendMessage(hwnd, HitTestMessage, IntPtr.Zero, packed).ToInt64(); + } + public static void UseHardwareTransparency(Window window) { var hwnd = new WindowInteropHelper(window).Handle; @@ -69,13 +95,58 @@ public static void WaitForFrame() DCompositionWaitForCompositorClock(0, null, 34); } + internal static void PassThroughSurface(HwndSource source) + { + var pass = new PassThrough { Interactive = false }; + source.AddHook((IntPtr hwnd, int message, IntPtr wParam, IntPtr lParam, ref bool handled) => + FilterPassThrough(message, wParam, lParam, ref handled, pass)); + ApplyPassThroughStyle(source.Handle, false, false); + } + public static void SetInteractive(Window window, bool interactive) { - var hwnd = new WindowInteropHelper(window).Handle; - long style = GetWindowLongPtr(hwnd, ExtendedStyle).ToInt64() | ToolWindow | NoActivate; - style = interactive ? style & ~Transparent : style | Transparent; + var hwnd = new WindowInteropHelper(window).EnsureHandle(); + var pass = PassThroughWindows.GetValue(window, current => + { + var created = new PassThrough(); + if (HwndSource.FromHwnd(new WindowInteropHelper(current).Handle) is HwndSource source) + source.AddHook((IntPtr hookHwnd, int message, IntPtr wParam, IntPtr lParam, ref bool handled) => + FilterPassThrough(message, wParam, lParam, ref handled, created)); + return created; + }); + pass.Interactive = interactive; + ApplyPassThroughStyle(hwnd, interactive, true); + } + + private static IntPtr FilterPassThrough(int message, IntPtr wParam, IntPtr lParam, ref bool handled, PassThrough pass) + { + if (message == StyleChangingMessage && wParam.ToInt64() == ExtendedStyle) + { + var change = Marshal.PtrToStructure(lParam); + change.NewStyle = WithPassThrough(change.NewStyle, pass.Interactive); + Marshal.StructureToPtr(change, lParam, false); + handled = true; + return IntPtr.Zero; + } + if (message != HitTestMessage || pass.Interactive) return IntPtr.Zero; + handled = true; + return new IntPtr(-1); + } + + private static int WithPassThrough(int style, bool interactive) + { + style |= (int)(ToolWindow | NoActivate | Layered); + return interactive ? style & ~(int)Transparent : style | (int)Transparent; + } + + private static void ApplyPassThroughStyle(IntPtr hwnd, bool interactive, bool topmost) + { + int style = WithPassThrough(unchecked((int)GetWindowLongPtr(hwnd, ExtendedStyle).ToInt64()), interactive); SetWindowLongPtr(hwnd, ExtendedStyle, new IntPtr(style)); - SetWindowPos(hwnd, new IntPtr(-1), 0, 0, 0, 0, 0x0001 | 0x0002 | 0x0010 | 0x0020); + uint flags = 0x0001 | 0x0002 | 0x0010 | 0x0020; + var after = new IntPtr(-1); + if (!topmost) { flags |= 0x0004; after = IntPtr.Zero; } + SetWindowPos(hwnd, after, 0, 0, 0, 0, flags); } public static WindowFrame WorkArea(Window window) diff --git a/apps/windows/WindowsSmokeTests.cs b/apps/windows/WindowsSmokeTests.cs index b305ed6..3b6bb21 100644 --- a/apps/windows/WindowsSmokeTests.cs +++ b/apps/windows/WindowsSmokeTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.Windows; using System.Windows.Media; +using System.Windows.Interop; using System.Windows.Media.Imaging; using Miucs.Audio; using Miucs.Keyboard; @@ -17,6 +18,7 @@ public static void Run(string output) Directory.CreateDirectory(output); VerifyLockGlyph(output); VerifyHoverLockWindow(); + VerifyHardwareClickThrough(); VerifyTrayCat(output); VerifyPluginRendering(); File.WriteAllText(Path.Combine(output, "result.json"), "{\"pluginSurface\":\"passed\",\"realLoopback\":\"not-run\",\"realKeyboard\":\"not-run\",\"syntheticHoverWindow\":\"passed\"}\n"); @@ -113,6 +115,13 @@ private static void VerifyLockGlyph(string output) } } + + private static int HitAtCenter(Window window) + { + var point = window.PointToScreen(new Point(window.ActualWidth / 2, window.ActualHeight / 2)); + return NativeWindow.HitTest(window, (int)Math.Round(point.X), (int)Math.Round(point.Y)); + } + private static void VerifyHoverLockWindow() { var owner = new Window @@ -139,6 +148,7 @@ private static void VerifyHoverLockWindow() hover.Sample(3, true); Assert(hover.TargetWindow.IsVisible, "visible at 3 seconds"); Assert(NativeWindow.IsClickThrough(owner), "revealing does not intercept canvas"); + Assert(HitAtCenter(owner) == -1, "canvas hit test passes through"); Assert(!NativeWindow.IsClickThrough(hover.TargetWindow), "only lock target interactive"); Assert(hover.TargetWindow.ActualWidth >= 32 && hover.TargetWindow.ActualWidth <= 36 && hover.TargetWindow.ActualHeight >= 32 && hover.TargetWindow.ActualHeight <= 36, @@ -157,6 +167,7 @@ private static void VerifyHoverLockWindow() hover.Sample(3.1, false); Assert(!hover.TargetWindow.IsVisible, "leave hides target"); adjusting = true; NativeWindow.SetInteractive(owner, true); hover.SetLocked(false); + Assert(HitAtCenter(owner) == 1, "adjusting canvas accepts the pointer"); Assert(hover.TargetWindow.IsVisible, "menu unlock immediately reveals target"); hover.ClickForVerification(); hover.Sample(3.2, false); for (int i = 0; i < 30; i++) hover.Sample(4 + i * .1, true); @@ -183,6 +194,38 @@ private static void VerifyHoverLockWindow() finally { hover?.Dispose(); owner.Close(); } } + private static void VerifyHardwareClickThrough() + { + var window = new Window + { + Width = 180, Height = 120, Left = 40, Top = 40, WindowStyle = WindowStyle.None, + AllowsTransparency = false, Background = Brushes.Transparent, ShowActivated = false, + ShowInTaskbar = false + }; + window.Show(); + try + { + NativeWindow.UseHardwareTransparency(window); + NativeWindow.SetInteractive(window, false); + if (!NativeWindow.IsClickThrough(window) || !NativeWindow.IsLayered(window) || HitAtCenter(window) != -1) + throw new InvalidOperationException("Hardware canvas is not click-through: style=0x" + NativeWindow.ExStyle(new System.Windows.Interop.WindowInteropHelper(window).Handle).ToString("X")); + NativeWindow.SetInteractive(window, true); + if (NativeWindow.IsClickThrough(window) || !NativeWindow.IsLayered(window) || HitAtCenter(window) != 1) + throw new InvalidOperationException("Adjusting canvas lost layered input: style=0x" + NativeWindow.ExStyle(new System.Windows.Interop.WindowInteropHelper(window).Handle).ToString("X")); + var parameters = new HwndSourceParameters("Miucs click-through probe") + { + Width = 80, Height = 60, WindowStyle = unchecked((int)0x80000000), + ExtendedWindowStyle = 0x00200000 | 0x08000000 | 0x00000080 | 0x00000020 | 0x00000008 + }; + using var source = new HwndSource(parameters); + NativeWindow.PassThroughSurface(source); + long style = NativeWindow.ExStyle(source.Handle); + if ((style & 0x80000) == 0 || (style & 0x20) == 0 || (style & 0x200000) == 0 || NativeWindow.HitTestHandle(source.Handle, 1, 1) != -1) + throw new InvalidOperationException("Plugin surface click-through style lost: 0x" + style.ToString("X")); + } + finally { window.Close(); } + } + private static void Assert(bool result, string message) { if (!result) throw new InvalidOperationException("Hover lock: " + message); } diff --git a/modules/plugins/platforms/windows/PluginView.cs b/modules/plugins/platforms/windows/PluginView.cs index 2b89692..2b043b8 100644 --- a/modules/plugins/platforms/windows/PluginView.cs +++ b/modules/plugins/platforms/windows/PluginView.cs @@ -34,8 +34,8 @@ private void EnsureWindow() { if(native!=null||!IsLoaded)return;var parent=Window.GetWindow(this);if(parent==null)return; // Keep this popup unowned: owned popups are always above their owner and would cover WPF controls. - var settings=new HwndSourceParameters("Miucs plugin surface") {Width=1,Height=1,WindowStyle=unchecked((int)0x80000000),ExtendedWindowStyle=0x00200000|0x08000000|0x00000080|0x00000020|0x00000008}; - native=new HwndSource(settings);native.AddHook(HitTest);surface=PluginNative.miucs_surface_create(package.Handle,native.Handle,Id); + var settings=new HwndSourceParameters("Miucs plugin surface") {Width=1,Height=1,WindowStyle=unchecked((int)0x80000000),ExtendedWindowStyle=0x00200000|0x08000000|0x00080000|0x00000080|0x00000020|0x00000008}; + native=new HwndSource(settings);Miucs.Windows.NativeWindow.PassThroughSurface(native);surface=PluginNative.miucs_surface_create(package.Handle,native.Handle,Id); var parentWindow=Window.GetWindow(this);if(parentWindow!=null)gpuFrameInterval=1d/Miucs.Windows.NativeWindow.RefreshHz(parentWindow); if(surface!=IntPtr.Zero&&PluginNative.miucs_surface_backend(surface)==0)System.Diagnostics.Trace.WriteLine($"Plugin {Id}: CPU fallback, 30 FPS limit"); } @@ -54,8 +54,6 @@ public override void Update(AudioInput audio,KeyboardInput keyboard,double delta PluginNative.miucs_surface_draw(surface,(uint)w,(uint)h,dpi.DpiScaleX); } public override void Dispose(){if(Disposed)return;base.Dispose();Release();package.Replacing-=Release;host.ReleaseUnused(package);} - private static IntPtr HitTest(IntPtr window,int message,IntPtr wParam,IntPtr lParam,ref bool handled) - {if(message==0x0084){handled=true;return new IntPtr(-1);}return IntPtr.Zero;} protected override void RenderScene(DrawingContext context){} [DllImport("user32.dll")]private static extern bool SetWindowPos(IntPtr window,IntPtr after,int x,int y,int cx,int cy,uint flags); [DllImport("user32.dll")]private static extern bool ShowWindow(IntPtr window,int command);