diff --git a/CHANGELOG.md b/CHANGELOG.md index c166a16..62328cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to QuickPods will be documented in this file. The format fol ## [Unreleased] +### Changed + +- The taskbar observer now uses Win32 shell hooks, window event hooks, and registry notifications instead of a UI Automation event subscription, so browsers no longer detect QuickPods as an assistive-technology client or enable accessibility mode because of it. + ## [1.0.0] - 2026-08-08 ### Added diff --git a/build/Test-SelfContainedPayload.ps1 b/build/Test-SelfContainedPayload.ps1 index 7f5bb09..a4e6b4c 100644 --- a/build/Test-SelfContainedPayload.ps1 +++ b/build/Test-SelfContainedPayload.ps1 @@ -44,8 +44,7 @@ $runtimeConfigExpectations = [ordered]@{ "Microsoft.WindowsDesktop.App" ) "QuickPods.TaskbarObserver.runtimeconfig.json" = @( - "Microsoft.NETCore.App", - "Microsoft.WindowsDesktop.App" + "Microsoft.NETCore.App" ) "QuickPods.BluetoothWorker.runtimeconfig.json" = @( "Microsoft.NETCore.App" diff --git a/docs/README.md b/docs/README.md index 7045510..e4b85f0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,8 @@ QuickPods documentation is organized by audience. The current code and automated - [Development](development.md) — clone, restore, build, test, run, and package - [Architecture](architecture/README.md) — process boundaries, Windows integration, and fail-closed design -- [ADR-0001](architecture/adr-0001-uia-watcher-process-boundary.md) — UI Automation observer process boundary +- [ADR-0001](architecture/adr-0001-uia-watcher-process-boundary.md) — taskbar observer process boundary +- [ADR-0002](architecture/adr-0002-shell-signal-observer.md) — shell-signal taskbar observer without UI Automation events - [Release guide](release/README.md) — versioning, packaging, signing, and publication checklist - [Public repository checklist](release/public-repository-checklist.md) — GitHub settings to review before changing visibility - [Installer guide](../installer/README.md) — WiX per-user MSI details diff --git a/docs/architecture/README.md b/docs/architecture/README.md index be7106e..ad94df7 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,7 +12,7 @@ QuickPods.exe (portable launcher) ├─ QuickPods.TaskbarHost.exe │ └─ taskbar discovery, native surface, rendering, input, and versioned IPC ├─ QuickPods.TaskbarObserver.exe - │ └─ short-lived UI Automation subscription for one Explorer generation + │ └─ short-lived Win32 shell-signal subscription for one Explorer generation └─ QuickPods.BluetoothWorker.exe └─ bounded Bluetooth capability or mutation call ``` @@ -31,7 +31,7 @@ The app owns settings, logs, startup registration, and child-process lifetime. H | `QuickPods.Infrastructure` | settings, logging, update metadata retrieval, single-instance, and host supervision | | `QuickPods.Windows` | Core Audio, Bluetooth, registry, and Windows Settings adapters | | `QuickPods.TaskbarHost` | out-of-process taskbar surface and placement policy | -| `QuickPods.TaskbarObserver` | Explorer-generation UI Automation observation | +| `QuickPods.TaskbarObserver` | Explorer-generation shell-signal observation without UI Automation events | | `QuickPods.BluetoothWorker` | isolated Bluetooth driver call boundary | ## Core Audio @@ -52,7 +52,7 @@ Unsupported capability, partial state, stale generation, timeout, and RDP owners QuickPods uses a Win32 surface in verified empty taskbar space. The default policy chooses the nearest verified gap immediately left of the Windows notification area and supports center- and left-aligned horizontal taskbars. A user-selectable taskbar-left policy preserves the previous placement to the left of the center-aligned Start area. Neither policy reserves taskbar space or moves shell controls. Placement is permitted only after taskbar identity, notification-area geometry, monitor, DPI, landmarks, obstacles, and Explorer generation are verified. -Incomplete evidence hides the embedded surface and leaves notification-area access available. A dedicated short-lived observer contains UI Automation provider lifetime across Explorer restarts; see [ADR-0001](adr-0001-uia-watcher-process-boundary.md). +Incomplete evidence hides the embedded surface and leaves notification-area access available. A dedicated short-lived observer owns the per-Explorer-generation shell hook, window event hook, and registry notifications that drive re-discovery; see [ADR-0001](adr-0001-uia-watcher-process-boundary.md) for the process boundary and [ADR-0002](adr-0002-shell-signal-observer.md) for why the observer does not register UI Automation events. ## Settings and diagnostics diff --git a/docs/architecture/adr-0001-uia-watcher-process-boundary.md b/docs/architecture/adr-0001-uia-watcher-process-boundary.md index bac8906..5c87d95 100644 --- a/docs/architecture/adr-0001-uia-watcher-process-boundary.md +++ b/docs/architecture/adr-0001-uia-watcher-process-boundary.md @@ -2,7 +2,7 @@ ## Status -Accepted, 2026-08-06. +Accepted, 2026-08-06. Rationale superseded by [ADR-0002](adr-0002-shell-signal-observer.md) on 2026-09-07; the observer process boundary remains, but the observer no longer registers a UI Automation subscription. ## Context diff --git a/docs/architecture/adr-0002-shell-signal-observer.md b/docs/architecture/adr-0002-shell-signal-observer.md new file mode 100644 index 0000000..d8beb02 --- /dev/null +++ b/docs/architecture/adr-0002-shell-signal-observer.md @@ -0,0 +1,46 @@ +# ADR-0002: Shell-signal taskbar observer without UI Automation events + +## Status + +Accepted, 2026-09-07. Supersedes the rationale of [ADR-0001](adr-0001-uia-watcher-process-boundary.md); the observer process boundary remains. + +## Context + +`QuickPods.TaskbarObserver.exe` previously registered a repeating UI Automation event subscription (structure and property changes) rooted at the primary taskbar. UI Automation event registration is wired desktop-wide by the UI Automation core, and scope filtering happens on the client side. Every UI Automation provider on the desktop therefore learned that a client was listening, and applications that treat any UI Automation client as an assistive technology reacted accordingly. Google Chrome enabled its accessibility mode for the whole browser and listed the observer under `chrome://accessibility`, which produced measurable CPU load inside Chrome even though QuickPods never queried a Chrome window. + +The host's one-shot UI Automation scans of the taskbar only send `WM_GETOBJECT` to Explorer's taskbar window and do not register a listener, so they are not affected. + +The original reason for a separate observer process was that the UI Automation provider retained USER resources across Explorer generations. That reason disappears with the subscription, but a short-lived observer is still the right lifetime for Win32 registrations that must be recreated for every Explorer generation. + +## Decision + +The observer no longer uses UI Automation. It drives invalidation from Win32 signals that do not involve the accessibility infrastructure: + +- A hidden, top-level, unowned window registered with `RegisterShellHookWindow` receives `WM_SHELLHOOKMESSAGE`. Window created, destroyed, and replaced notifications invalidate the taskbar structure. Redraw notifications are ignored while taskbar button labels are hidden (the default combined layout) because some applications redraw their title or icon several times per second; while labels are visible they are throttled to one invalidation per second. Activation, flash, and rude-app notifications are ignored because they do not change geometry. +- A `SetWinEventHook` registration scoped to the Explorer process and filtered to the taskbar HWND tree reports HWND-level create, destroy, reorder, show, hide, and location changes. Location changes only arm the settle timer. +- Registry change notifications on the user's taskbar settings keys (pinned items, Explorer advanced settings, taskbar placement state, and search box mode) invalidate the structure. Every registry watch is optional; a missing key or failed registration degrades silently. +- The hidden window also consumes `WM_SETTINGCHANGE`, `WM_DISPLAYCHANGE`, `WM_THEMECHANGED`, and `TaskbarCreated` broadcasts. +- A 250 ms identity poll continues to detect Explorer generation changes and retires the observer, exactly as before. +- A 400 ms settle timer, restarted by every raw signal, emits one geometry invalidation after Explorer finishes animating so the host re-discovers the final layout. + +The wire contract, protocol version, host lifecycle handling, job-object containment, and authenticated pipe handshake are unchanged. The existing invalidation kinds keep their generic meaning: structure changed, geometry changed, and visibility changed. The observer never reports a process or window identity, and it never classifies a signal as owned by the host because the process-scoped hook cannot observe the host's own child window. + +The host keeps its one-shot UI Automation scans and its 5-second watchdog as the last resort. + +## Consequences + +- QuickPods no longer appears as an assistive-technology client to browsers or other UI Automation providers. +- The observer only sees HWND-level changes inside Explorer. Windows 11 taskbar buttons are XAML elements without HWNDs, so button additions, pin changes, and alignment changes are inferred from the shell hook, registry notifications, and the settle timer rather than observed directly. +- The observer no longer needs the Windows Desktop framework and ships as a plain .NET application. +- The owned-source classification in the protocol is retained for compatibility but is no longer emitted. +- Registry key names may change between Windows releases; because every watch is optional, such a change reduces signal fidelity without breaking the observer. + +## Verification + +Changes to the observer must cover: + +1. `chrome://accessibility` shows no `quickpods.taskbarobserver.exe` client after Chrome is restarted with QuickPods running; +2. taskbar button additions and removals, pin changes, alignment changes, auto-hide changes, notification-area changes, display and DPI changes, and theme changes re-place the surface within about one second; +3. bounded invalidation rate while a window title changes continuously and during auto-hide animations; +4. Explorer restart and generation replacement, including retirement of every old observer; +5. stable long-lived observer USER and GDI resources. diff --git a/docs/development.md b/docs/development.md index 6f710e1..6cbec96 100644 --- a/docs/development.md +++ b/docs/development.md @@ -8,7 +8,7 @@ - Optional: Visual Studio 2022 with the .NET desktop development workload - Optional for MSI packaging: the WiX SDK is restored through the installer project -QuickPods is Windows-specific. WPF, Win32, Core Audio, Configuration Manager, UI Automation, registry, and MSI behavior cannot be validated on Linux or macOS. +QuickPods is Windows-specific. WPF, Win32, shell hooks and window event hooks, Core Audio, Configuration Manager, UI Automation, registry, and MSI behavior cannot be validated on Linux or macOS. ## Clone and restore diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 91a64a7..92280cf 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -9,6 +9,14 @@ Both center- and left-aligned taskbar buttons are supported by the default **Left of notification area** placement. +## Google Chrome reports an accessibility client or uses more CPU + +Releases up to 1.0.0 subscribed to Windows UI Automation events to follow taskbar changes. Chrome treats any UI Automation event client as an assistive technology, enables its accessibility mode, and lists `quickpods.taskbarobserver.exe` under **UIA clients** on `chrome://accessibility`. Later releases use Win32 shell hooks, window event hooks, and registry notifications instead and do not register a UI Automation client. + +- Update QuickPods, then restart Chrome once; Chrome keeps accessibility enabled until it restarts. +- Confirm that `chrome://accessibility` no longer lists `quickpods.taskbarobserver.exe`. +- If you cannot update, exit QuickPods and restart Chrome to release the accessibility mode. + ## A Bluetooth device is missing 1. Confirm that the device is paired in Windows Settings. diff --git a/src/QuickPods.Launcher/packages.lock.json b/src/QuickPods.Launcher/packages.lock.json index eb0191c..d09da69 100644 --- a/src/QuickPods.Launcher/packages.lock.json +++ b/src/QuickPods.Launcher/packages.lock.json @@ -4,31 +4,31 @@ "net10.0-windows10.0.26100": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "yloFUsIQOzspIsn2cDHrbgikKaWHAZspzj0EWxnWJJpFC89IBW64OXgCECsrrnzGWQvy1iLX9r5/R4zBf7Am3g==" }, "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "f5VCIE7AJpd5YvzNTeMGVzQIgyE9tX+AreTYwQF+REbu+DZo/2Ae+jNSwhPEYrVz6RRkd7y8ubXjk6Nn6Ka+Cg==" + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "IBf7lbovvjGWVWXZX5cJ/cO0WXbId0Zq4BuSeT94mGZuOAP66oMeH9PTBZ9Jpp3Jb6jtK0qm/NyUbPRo1gC/wQ==" } }, "net10.0-windows10.0.26100/win-x64": { "Microsoft.DotNet.ILCompiler": { "type": "Direct", - "requested": "[10.0.10, )", - "resolved": "10.0.10", - "contentHash": "tnG8ntt/Bk6odvHREnGLMo3PEiihy5iSlIFVp0JbIo00GKtNRt2k73eKZbPqR5yaJNIa3z8R86YLwbxfqpb17g==", + "requested": "[10.0.11, )", + "resolved": "10.0.11", + "contentHash": "yloFUsIQOzspIsn2cDHrbgikKaWHAZspzj0EWxnWJJpFC89IBW64OXgCECsrrnzGWQvy1iLX9r5/R4zBf7Am3g==", "dependencies": { - "runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.10" + "runtime.win-x64.Microsoft.DotNet.ILCompiler": "10.0.11" } }, "runtime.win-x64.Microsoft.DotNet.ILCompiler": { "type": "Transitive", - "resolved": "10.0.10", - "contentHash": "e5P00ObvdmAl49KkvGvqDLE5+EXu/IdXCDsLghgKFXKrNybyxMmH12HuGrsO7LTIGjXI58tyN3Hwxwr/MR0d8A==" + "resolved": "10.0.11", + "contentHash": "D6ZKMo3NeTnos7l5S8bMmiPm4LK+y15Q/uduyPw7FX+xKbeWVWdwlcZGkLLPjLvW4ZO6o3ACanq5HsSXJgrt0g==" } } } diff --git a/src/QuickPods.TaskbarObserver/ObserverNativeMethods.cs b/src/QuickPods.TaskbarObserver/ObserverNativeMethods.cs index 68da6de..6c22631 100644 --- a/src/QuickPods.TaskbarObserver/ObserverNativeMethods.cs +++ b/src/QuickPods.TaskbarObserver/ObserverNativeMethods.cs @@ -1,11 +1,80 @@ +using System.Diagnostics.CodeAnalysis; using System.Runtime.InteropServices; +using Microsoft.Win32.SafeHandles; namespace QuickPods.TaskbarObserver; +[SuppressMessage( + "Interoperability", + "SYSLIB1054:Use 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time", + Justification = "Window procedures, WinEvent hooks, and window enumeration require runtime delegate marshalling and classic Win32 signatures.")] internal static class ObserverNativeMethods { private const string PrimaryTaskbarClassName = "Shell_TrayWnd"; + internal const uint WmDestroy = 0x0002; + internal const uint WmClose = 0x0010; + internal const uint WmQuit = 0x0012; + internal const uint WmSettingChange = 0x001A; + internal const uint WmDisplayChange = 0x007E; + internal const uint WmNcCreate = 0x0081; + internal const uint WmNcDestroy = 0x0082; + internal const uint WmTimer = 0x0113; + internal const uint WmThemeChanged = 0x031A; + + internal const uint WindowStylePopup = 0x80000000; + internal const uint WindowExtendedStyleToolWindow = 0x00000080; + internal const uint WindowExtendedStyleNoActivate = 0x08000000; + internal const int GwlpUserData = -21; + internal const int ErrorClassAlreadyExists = 1410; + internal const uint PeekMessageRemove = 0x0001; + + internal const uint GetAncestorRoot = 2; + internal const int ObjectIdWindow = 0; + internal const int ChildIdSelf = 0; + + internal const uint EventObjectCreate = 0x8000; + internal const uint EventObjectDestroy = 0x8001; + internal const uint EventObjectShow = 0x8002; + internal const uint EventObjectHide = 0x8003; + internal const uint EventObjectReorder = 0x8004; + internal const uint EventObjectLocationChange = 0x800B; + internal const uint WinEventOutOfContext = 0x0000; + internal const uint WinEventSkipOwnProcess = 0x0002; + + internal const nuint ShellHookWindowCreated = 1; + internal const nuint ShellHookWindowDestroyed = 2; + internal const nuint ShellHookRedraw = 6; + internal const nuint ShellHookWindowReplaced = 13; + + internal const uint RegistryNotifyChangeName = 0x00000001; + internal const uint RegistryNotifyChangeLastSet = 0x00000004; + internal const uint RegistryNotifyThreadAgnostic = 0x10000000; + + internal const uint QueueStatusAllInput = 0x04FF; + internal const uint MessageWaitInputAvailable = 0x0004; + internal const uint WaitFailed = 0xFFFFFFFF; + internal const uint Infinite = 0xFFFFFFFF; + + internal const nuint IdentityTimerId = 1; + internal const nuint SettleTimerId = 2; + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + internal delegate nint NativeWindowProcedure(nint window, uint message, nuint wParam, nint lParam); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + internal delegate void WinEventProcedure( + nint hook, + uint eventId, + nint window, + int objectId, + int childId, + uint eventThread, + uint eventTime); + + [UnmanagedFunctionPointer(CallingConvention.Winapi)] + internal delegate bool EnumWindowsProcedure(nint windowHandle, nint parameter); + internal static PrimaryTaskbarIdentity FindPrimaryTaskbar() { var matches = new List(); @@ -39,12 +108,20 @@ internal static PrimaryTaskbarIdentity FindPrimaryTaskbar() internal readonly record struct PrimaryTaskbarIdentity(nint WindowHandle, uint ExplorerProcessId); - private delegate bool EnumWindowsProcedure(nint windowHandle, nint parameter); + [DllImport("kernel32.dll", EntryPoint = "GetModuleHandleW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern nint GetModuleHandle(string? moduleName); + + [DllImport("kernel32.dll", EntryPoint = "SetLastError", ExactSpelling = true)] + internal static extern void SetLastError(uint errorCode); [DllImport("user32.dll", EntryPoint = "EnumWindows", SetLastError = true)] [return: MarshalAs(UnmanagedType.Bool)] private static extern bool EnumWindows(EnumWindowsProcedure callback, nint parameter); + [DllImport("user32.dll", EntryPoint = "EnumChildWindows", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool EnumChildWindows(nint parent, EnumWindowsProcedure callback, nint parameter); + [DllImport("user32.dll", EntryPoint = "GetClassNameW", SetLastError = true, CharSet = CharSet.Unicode)] private static extern int GetClassName( nint windowHandle, @@ -55,6 +132,195 @@ private static extern int GetClassName( [return: MarshalAs(UnmanagedType.Bool)] private static extern bool IsWindowVisible(nint windowHandle); + [DllImport("user32.dll", EntryPoint = "IsWindow", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool IsWindow(nint windowHandle); + [DllImport("user32.dll", EntryPoint = "GetWindowThreadProcessId", SetLastError = true)] private static extern uint GetWindowThreadProcessId(nint windowHandle, out uint processId); + + [DllImport("user32.dll", EntryPoint = "GetAncestor", ExactSpelling = true)] + internal static extern nint GetAncestor(nint windowHandle, uint flags); + + [DllImport("user32.dll", EntryPoint = "GetParent", ExactSpelling = true)] + internal static extern nint GetParent(nint windowHandle); + + [DllImport("user32.dll", EntryPoint = "RegisterClassExW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern ushort RegisterClass(ref NativeWindowClass windowClass); + + [DllImport("user32.dll", EntryPoint = "GetClassInfoExW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool GetClassInfo( + nint instance, + string className, + ref NativeWindowClassInfo windowClass); + + [DllImport("user32.dll", EntryPoint = "CreateWindowExW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern nint CreateWindow( + uint extendedStyle, + string className, + string windowName, + uint style, + int x, + int y, + int width, + int height, + nint parent, + nint menu, + nint instance, + nint parameter); + + [DllImport("user32.dll", EntryPoint = "DestroyWindow", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DestroyWindow(nint window); + + [DllImport("user32.dll", EntryPoint = "DefWindowProcW", CharSet = CharSet.Unicode)] + internal static extern nint DefWindowProcedure(nint window, uint message, nuint wParam, nint lParam); + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern nint GetWindowLongPointer(nint window, int index); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern nint SetWindowLongPointer(nint window, int index, nint newValue); + + [DllImport("user32.dll", EntryPoint = "PeekMessageW", CharSet = CharSet.Unicode)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool PeekMessage( + out NativeMessage message, + nint window, + uint minimum, + uint maximum, + uint removal); + + [DllImport("user32.dll", EntryPoint = "TranslateMessage", ExactSpelling = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool TranslateMessage(ref NativeMessage message); + + [DllImport("user32.dll", EntryPoint = "DispatchMessageW", CharSet = CharSet.Unicode)] + internal static extern nint DispatchMessage(ref NativeMessage message); + + [DllImport("user32.dll", EntryPoint = "PostMessageW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool PostMessage(nint window, uint message, nuint wParam, nint lParam); + + [DllImport("user32.dll", EntryPoint = "PostQuitMessage", ExactSpelling = true)] + internal static extern void PostQuitMessage(int exitCode); + + [DllImport("user32.dll", EntryPoint = "MsgWaitForMultipleObjectsEx", ExactSpelling = true, SetLastError = true)] + internal static extern uint MessageWaitForMultipleObjects( + uint count, + nint[] handles, + uint milliseconds, + uint wakeMask, + uint flags); + + [DllImport("user32.dll", EntryPoint = "RegisterWindowMessageW", CharSet = CharSet.Unicode, SetLastError = true)] + internal static extern uint RegisterWindowMessage(string messageName); + + [DllImport("user32.dll", EntryPoint = "RegisterShellHookWindow", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool RegisterShellHookWindow(nint window); + + [DllImport("user32.dll", EntryPoint = "DeregisterShellHookWindow", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool DeregisterShellHookWindow(nint window); + + [DllImport("user32.dll", EntryPoint = "SetWinEventHook", ExactSpelling = true, SetLastError = true)] + internal static extern nint SetWinEventHook( + uint minimumEvent, + uint maximumEvent, + nint module, + WinEventProcedure callback, + uint processId, + uint threadId, + uint flags); + + [DllImport("user32.dll", EntryPoint = "UnhookWinEvent", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool UnhookWinEvent(nint hook); + + [DllImport("user32.dll", EntryPoint = "SetTimer", ExactSpelling = true, SetLastError = true)] + internal static extern nuint SetTimer(nint window, nuint timerId, uint elapseMilliseconds, nint callback); + + [DllImport("user32.dll", EntryPoint = "KillTimer", ExactSpelling = true, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool KillTimer(nint window, nuint timerId); + + [DllImport("advapi32.dll", EntryPoint = "RegNotifyChangeKeyValue", ExactSpelling = true)] + internal static extern int RegistryNotifyChangeKeyValue( + SafeRegistryHandle key, + [MarshalAs(UnmanagedType.Bool)] bool watchSubtree, + uint notifyFilter, + SafeWaitHandle eventHandle, + [MarshalAs(UnmanagedType.Bool)] bool asynchronous); + + [StructLayout(LayoutKind.Sequential)] + internal struct NativePoint + { + internal int X; + internal int Y; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + internal struct NativeWindowClass + { + internal uint Size; + internal uint Style; + internal nint WindowProcedure; + internal int ClassExtraBytes; + internal int WindowExtraBytes; + internal nint Instance; + internal nint Icon; + internal nint Cursor; + internal nint BackgroundBrush; + internal string? MenuName; + internal string ClassName; + internal nint SmallIcon; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeWindowClassInfo + { + internal uint Size; + internal uint Style; + internal nint WindowProcedure; + internal int ClassExtraBytes; + internal int WindowExtraBytes; + internal nint Instance; + internal nint Icon; + internal nint Cursor; + internal nint BackgroundBrush; + internal nint MenuName; + internal nint ClassName; + internal nint SmallIcon; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeCreateStruct + { + internal nint CreateParameters; + internal nint Instance; + internal nint Menu; + internal nint Parent; + internal int Height; + internal int Width; + internal int Y; + internal int X; + internal int Style; + internal nint Name; + internal nint ClassName; + internal uint ExtendedStyle; + } + + [StructLayout(LayoutKind.Sequential)] + internal struct NativeMessage + { + internal nint Window; + internal uint Message; + internal nuint WParam; + internal nint LParam; + internal uint Time; + internal NativePoint Point; + internal uint Private; + } } diff --git a/src/QuickPods.TaskbarObserver/ObserverRuntime.cs b/src/QuickPods.TaskbarObserver/ObserverRuntime.cs index 0b17117..bcfe9b7 100644 --- a/src/QuickPods.TaskbarObserver/ObserverRuntime.cs +++ b/src/QuickPods.TaskbarObserver/ObserverRuntime.cs @@ -1,9 +1,7 @@ -using System.Collections.Concurrent; using System.Diagnostics; using System.IO; using System.IO.Pipes; using System.Runtime.InteropServices; -using System.Windows.Automation; using QuickPods.Contracts; namespace QuickPods.TaskbarObserver; @@ -18,7 +16,7 @@ internal sealed class ObserverRuntime : IDisposable private readonly StreamReader reader; private readonly StreamWriter writer; private readonly ObserverSessionRequest request; - private readonly ObserverSubscription subscription; + private readonly TaskbarSignalSubscription subscription; private long nextSequence; private bool disposed; @@ -41,7 +39,7 @@ private ObserverRuntime(string pipeName, int parentProcessId) string message = reader.ReadLine() ?? throw new InvalidDataException("The taskbar host closed before the observer handshake."); request = QuickPodsProtocolJson.DeserializeObserverSession(message); - subscription = ObserverSubscription.Start(parentProcessId, WorkerTimeout); + subscription = TaskbarSignalSubscription.Start(WorkerTimeout); } internal static int Run(string pipeName, int parentProcessId) @@ -57,7 +55,7 @@ InvalidDataException or InvalidOperationException or ArgumentException or COMException or - ElementNotAvailableException or + TimeoutException or System.ComponentModel.Win32Exception) { return 6; @@ -159,207 +157,4 @@ private static ObserverSourceClassification Stronger( ? ObserverSourceClassification.External : ObserverSourceClassification.Owned; } - - private sealed class ObserverSubscription : IDisposable - { - private readonly BlockingCollection signals = []; - private readonly ManualResetEventSlim stop = new(false); - // Start waits synchronously with a product timeout. Complete inline on - // this dedicated MTA so unrelated ThreadPool load cannot cause a false - // observer-start timeout after UIA registration has already succeeded. - private readonly TaskCompletionSource ready = new(); - private readonly Thread worker; - private readonly int ownedProcessId; - private Exception? failure; - private bool disposed; - - private ObserverSubscription(int ownedProcessId) - { - this.ownedProcessId = ownedProcessId; - worker = new Thread(WorkerEntry) - { - IsBackground = true, - Name = "QuickPods taskbar observer UIA MTA", - }; - worker.SetApartmentState(ApartmentState.MTA); - worker.Start(); - } - - internal bool IsAlive => worker.IsAlive; - - internal Exception? Failure => Volatile.Read(ref failure); - - internal static ObserverSubscription Start(int ownedProcessId, TimeSpan timeout) - { - var subscription = new ObserverSubscription(ownedProcessId); - try - { - subscription.ready.Task.WaitAsync(timeout).GetAwaiter().GetResult(); - return subscription; - } - catch - { - subscription.Dispose(); - throw; - } - } - - internal bool TryTake(out ObserverSignal signal, TimeSpan timeout) => - signals.TryTake(out signal, timeout); - - public void Dispose() - { - if (disposed) - { - return; - } - - disposed = true; - stop.Set(); - if (worker.Join(WorkerTimeout)) - { - signals.Dispose(); - stop.Dispose(); - } - } - - private void WorkerEntry() - { - bool registrationAttempted = false; - try - { - if (Thread.CurrentThread.GetApartmentState() != ApartmentState.MTA) - { - throw new InvalidOperationException("The observer worker is not MTA."); - } - - ObserverNativeMethods.PrimaryTaskbarIdentity identity = - ObserverNativeMethods.FindPrimaryTaskbar(); - AutomationElement root = AutomationElement.FromHandle(identity.WindowHandle) ?? - throw new InvalidOperationException("The taskbar UI Automation root is unavailable."); - - StructureChangedEventHandler structureHandler = (sender, _) => - Signal(sender, ObserverInvalidationKind.StructureChanged, placementButtonsOnly: false); - AutomationPropertyChangedEventHandler propertyHandler = (sender, eventArgs) => - Signal( - sender, - eventArgs.Property == AutomationElement.BoundingRectangleProperty - ? ObserverInvalidationKind.BoundingRectangleChanged - : ObserverInvalidationKind.IsOffscreenChanged, - placementButtonsOnly: true); - var cache = new CacheRequest - { - AutomationElementMode = AutomationElementMode.None, - TreeFilter = Automation.ControlViewCondition, - TreeScope = TreeScope.Element, - }; - cache.Add(AutomationElement.ProcessIdProperty); - cache.Add(AutomationElement.ControlTypeProperty); - using IDisposable activation = cache.Activate(); - registrationAttempted = true; - Automation.AddStructureChangedEventHandler(root, TreeScope.Subtree, structureHandler); - Automation.AddAutomationPropertyChangedEventHandler( - root, - TreeScope.Subtree, - propertyHandler, - AutomationElement.BoundingRectangleProperty, - AutomationElement.IsOffscreenProperty); - ready.TrySetResult(true); - - while (!stop.Wait(TimeSpan.FromMilliseconds(250))) - { - ObserverNativeMethods.PrimaryTaskbarIdentity current = - ObserverNativeMethods.FindPrimaryTaskbar(); - if (current != identity) - { - signals.Add(new( - ObserverInvalidationKind.ExplorerGenerationChanged, - ObserverSourceClassification.External)); - return; - } - } - } - catch (Exception exception) - { - Volatile.Write(ref failure, exception); - ready.TrySetException(exception); - } - finally - { - if (registrationAttempted) - { - try - { - Automation.RemoveAllEventHandlers(); - } - catch (Exception exception) - { - Volatile.Write(ref failure, exception); - } - } - } - } - - private void Signal( - object sender, - ObserverInvalidationKind kind, - bool placementButtonsOnly) - { - int processId = 0; - bool controlTypeKnown = false; - bool isButton = false; - try - { - if (sender is AutomationElement element) - { - object processValue = element.GetCachedPropertyValue( - AutomationElement.ProcessIdProperty, - true); - if (processValue is int senderProcessId) - { - processId = senderProcessId; - } - - object typeValue = element.GetCachedPropertyValue( - AutomationElement.ControlTypeProperty, - true); - controlTypeKnown = typeValue is ControlType; - isButton = typeValue is ControlType type && type == ControlType.Button; - } - } - catch (Exception) - { - processId = 0; - controlTypeKnown = false; - } - - if (placementButtonsOnly && controlTypeKnown && !isButton) - { - return; - } - - ObserverSourceClassification source = processId == ownedProcessId - ? ObserverSourceClassification.Owned - : processId != 0 - ? ObserverSourceClassification.External - : ObserverSourceClassification.Unknown; - if (source == ObserverSourceClassification.Owned && - kind != ObserverInvalidationKind.BoundingRectangleChanged) - { - return; - } - - try - { - signals.Add(new(kind, source)); - } - catch (InvalidOperationException) when (signals.IsAddingCompleted) - { - } - } - } - - private readonly record struct ObserverSignal( - ObserverInvalidationKind Kind, - ObserverSourceClassification Source); } diff --git a/src/QuickPods.TaskbarObserver/ObserverSignal.cs b/src/QuickPods.TaskbarObserver/ObserverSignal.cs new file mode 100644 index 0000000..68e9c18 --- /dev/null +++ b/src/QuickPods.TaskbarObserver/ObserverSignal.cs @@ -0,0 +1,7 @@ +using QuickPods.Contracts; + +namespace QuickPods.TaskbarObserver; + +internal readonly record struct ObserverSignal( + ObserverInvalidationKind Kind, + ObserverSourceClassification Source); diff --git a/src/QuickPods.TaskbarObserver/ObserverSignalWindowClass.cs b/src/QuickPods.TaskbarObserver/ObserverSignalWindowClass.cs new file mode 100644 index 0000000..9e3d48c --- /dev/null +++ b/src/QuickPods.TaskbarObserver/ObserverSignalWindowClass.cs @@ -0,0 +1,60 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace QuickPods.TaskbarObserver; + +internal static class ObserverSignalWindowClass +{ + internal const string ClassName = "QuickPods.Observer.Signal"; + + // The delegate must outlive every window of this class; a static field keeps + // the marshalled thunk alive for the process lifetime. + private static readonly ObserverNativeMethods.NativeWindowProcedure Procedure = + TaskbarSignalSubscription.StaticWindowProcedure; + private static readonly Lazy Registered = new(Register, true); + + internal static Registration GetRegistration() => Registered.Value; + + private static Registration Register() + { + nint instance = ObserverNativeMethods.GetModuleHandle(null); + if (instance == nint.Zero) + { + throw new Win32Exception(); + } + + nint procedure = Marshal.GetFunctionPointerForDelegate(Procedure); + var windowClass = new ObserverNativeMethods.NativeWindowClass + { + Size = (uint)Marshal.SizeOf(), + WindowProcedure = procedure, + Instance = instance, + ClassName = ClassName, + }; + + if (ObserverNativeMethods.RegisterClass(ref windowClass) == 0) + { + int error = Marshal.GetLastWin32Error(); + if (error != ObserverNativeMethods.ErrorClassAlreadyExists) + { + throw new Win32Exception(error); + } + + var existingClass = new ObserverNativeMethods.NativeWindowClassInfo + { + Size = (uint)Marshal.SizeOf(), + }; + if (!ObserverNativeMethods.GetClassInfo(instance, ClassName, ref existingClass) || + existingClass.Instance != instance || + existingClass.WindowProcedure != procedure) + { + throw new InvalidOperationException( + "The observer signal window class is already registered with different ownership."); + } + } + + return new(ClassName, instance); + } + + internal readonly record struct Registration(string ClassName, nint Instance); +} diff --git a/src/QuickPods.TaskbarObserver/Properties/AssemblyInfo.cs b/src/QuickPods.TaskbarObserver/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..01e9ef8 --- /dev/null +++ b/src/QuickPods.TaskbarObserver/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("QuickPods.Foundation.Tests")] diff --git a/src/QuickPods.TaskbarObserver/QuickPods.TaskbarObserver.csproj b/src/QuickPods.TaskbarObserver/QuickPods.TaskbarObserver.csproj index d95bc48..0e13835 100644 --- a/src/QuickPods.TaskbarObserver/QuickPods.TaskbarObserver.csproj +++ b/src/QuickPods.TaskbarObserver/QuickPods.TaskbarObserver.csproj @@ -4,7 +4,6 @@ WinExe net10.0-windows QuickPods.TaskbarObserver - true app.manifest diff --git a/src/QuickPods.TaskbarObserver/TaskbarSignalPolicy.cs b/src/QuickPods.TaskbarObserver/TaskbarSignalPolicy.cs new file mode 100644 index 0000000..5e762a7 --- /dev/null +++ b/src/QuickPods.TaskbarObserver/TaskbarSignalPolicy.cs @@ -0,0 +1,117 @@ +using QuickPods.Contracts; + +namespace QuickPods.TaskbarObserver; + +/// +/// Pure classification of Win32 shell signals into observer invalidation kinds. +/// No signal produced here ever identifies a process or window; the host only +/// learns that the taskbar may have changed and re-runs its own discovery. +/// +internal static class TaskbarSignalPolicy +{ + internal static readonly TimeSpan SettleDelay = TimeSpan.FromMilliseconds(400); + internal static readonly TimeSpan RedrawThrottle = TimeSpan.FromMilliseconds(1000); + internal static readonly TimeSpan IdentityPollInterval = TimeSpan.FromMilliseconds(250); + + /// + /// Taskbar labels are hidden unless Explorer's TaskbarGlomLevel is a + /// non-zero value; a missing value means the default combined layout. + /// + internal static bool AreTaskbarLabelsVisible(object? taskbarGlomLevel) => + taskbarGlomLevel is int level && level != 0; + + internal static SignalDisposition ClassifyShellHook(ulong code, bool taskbarLabelsVisible) => + code switch + { + ObserverNativeMethods.ShellHookWindowCreated or + ObserverNativeMethods.ShellHookWindowDestroyed or + ObserverNativeMethods.ShellHookWindowReplaced => + new(ObserverInvalidationKind.StructureChanged, ObserverSourceClassification.External, true), + // Title and icon redraws fire continuously for some applications and + // only change taskbar geometry while button labels are visible. + ObserverNativeMethods.ShellHookRedraw when taskbarLabelsVisible => + new(ObserverInvalidationKind.StructureChanged, ObserverSourceClassification.External, true), + _ => SignalDisposition.None, + }; + + internal static bool IsThrottledShellHook(ulong code) => + code == ObserverNativeMethods.ShellHookRedraw; + + internal static SignalDisposition ClassifyWinEvent( + uint eventId, + int objectId, + int childId, + bool isInTaskbarTree) + { + if (objectId != ObserverNativeMethods.ObjectIdWindow || + childId != ObserverNativeMethods.ChildIdSelf || + !isInTaskbarTree) + { + return SignalDisposition.None; + } + + return eventId switch + { + ObserverNativeMethods.EventObjectCreate or + ObserverNativeMethods.EventObjectDestroy or + ObserverNativeMethods.EventObjectReorder => + new(ObserverInvalidationKind.StructureChanged, ObserverSourceClassification.External, true), + ObserverNativeMethods.EventObjectShow or + ObserverNativeMethods.EventObjectHide => + new(ObserverInvalidationKind.IsOffscreenChanged, ObserverSourceClassification.External, true), + ObserverNativeMethods.EventObjectLocationChange => + new(null, ObserverSourceClassification.Unknown, true), + _ => SignalDisposition.None, + }; + } + + internal static SignalDisposition ClassifyWindowMessage(uint message, uint taskbarCreatedMessage) + { + if (taskbarCreatedMessage != 0 && message == taskbarCreatedMessage) + { + return SettingsChanged; + } + + return message switch + { + ObserverNativeMethods.WmSettingChange or + ObserverNativeMethods.WmDisplayChange or + ObserverNativeMethods.WmThemeChanged => SettingsChanged, + _ => SignalDisposition.None, + }; + } + + internal static SignalDisposition ClassifyRegistryChange() => SettingsChanged; + + internal static SignalDisposition Settle => + new(ObserverInvalidationKind.BoundingRectangleChanged, ObserverSourceClassification.Unknown, false); + + internal static bool ShouldEmitThrottled( + TimeSpan now, + TimeSpan? lastEmitted, + TimeSpan window, + out bool scheduleTrailing) + { + if (lastEmitted is null || now - lastEmitted.Value >= window) + { + scheduleTrailing = false; + return true; + } + + scheduleTrailing = true; + return false; + } + + private static SignalDisposition SettingsChanged => + new(ObserverInvalidationKind.StructureChanged, ObserverSourceClassification.Unknown, true); +} + +internal readonly record struct SignalDisposition( + ObserverInvalidationKind? Kind, + ObserverSourceClassification Source, + bool ArmSettle) +{ + internal static SignalDisposition None => new(null, ObserverSourceClassification.Unknown, false); + + internal bool IsNone => Kind is null && !ArmSettle; +} diff --git a/src/QuickPods.TaskbarObserver/TaskbarSignalSubscription.cs b/src/QuickPods.TaskbarObserver/TaskbarSignalSubscription.cs new file mode 100644 index 0000000..62a3ca1 --- /dev/null +++ b/src/QuickPods.TaskbarObserver/TaskbarSignalSubscription.cs @@ -0,0 +1,722 @@ +using System.Collections.Concurrent; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Security; +using Microsoft.Win32; +using QuickPods.Contracts; + +namespace QuickPods.TaskbarObserver; + +/// +/// Owns every Win32 signal source for one verified Explorer generation on a +/// dedicated worker thread: a hidden top-level window that receives shell hook +/// and shell broadcast messages, a WinEvent hook scoped to the Explorer process +/// and the taskbar HWND tree, registry change notifications for taskbar +/// settings, an identity poll, and a settle timer. Nothing here registers a +/// UI Automation client, so browsers and other UIA providers never see the +/// observer as an assistive technology. +/// +internal sealed class TaskbarSignalSubscription : IDisposable +{ + private const string TaskbarCreatedMessageName = "TaskbarCreated"; + private const string ShellHookMessageName = "SHELLHOOK"; + private const string ExplorerAdvancedRegistryPath = + @"Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced"; + private const string TaskbarGlomLevelValueName = "TaskbarGlomLevel"; + private const int MaximumTrackedTaskbarWindows = 2048; + private static readonly string[] WatchedRegistryPaths = + [ + @"Software\Microsoft\Windows\CurrentVersion\Explorer\Taskband", + ExplorerAdvancedRegistryPath, + @"Software\Microsoft\Windows\CurrentVersion\Explorer\StuckRects3", + @"Software\Microsoft\Windows\CurrentVersion\Search", + ]; + + private readonly BlockingCollection signals = []; + private readonly ManualResetEventSlim stop = new(false); + // Start waits synchronously with a product timeout. Complete inline on the + // worker so unrelated ThreadPool load cannot cause a false start timeout + // after the hooks have already been registered. + private readonly TaskCompletionSource ready = new(); + private readonly Thread worker; + private readonly TimeSpan workerTimeout; + // The hook callback delegate must stay reachable for as long as the hook + // exists; the field roots it for the lifetime of this subscription. + private readonly ObserverNativeMethods.WinEventProcedure winEventProcedure; + private readonly HashSet taskbarTreeWindows = []; + private readonly List registryWatches = []; + private nint[] registryWaitHandles = []; + private GCHandle instanceHandle; + private nint windowHandle; + private nint taskbarWindowHandle; + private ObserverNativeMethods.PrimaryTaskbarIdentity identity; + private nint winEventHook; + private uint taskbarCreatedMessage; + private uint shellHookMessage; + private bool shellHookRegistered; + private bool taskbarLabelsVisible; + private bool generationRetired; + private TimeSpan? lastRedrawEmitted; + private Exception? failure; + private bool disposed; + + private TaskbarSignalSubscription(TimeSpan workerTimeout) + { + this.workerTimeout = workerTimeout; + winEventProcedure = OnWinEvent; + worker = new Thread(WorkerEntry) + { + IsBackground = true, + Name = "QuickPods taskbar observer shell signals", + }; + worker.Start(); + } + + internal bool IsAlive => worker.IsAlive; + + internal Exception? Failure => Volatile.Read(ref failure); + + internal static TaskbarSignalSubscription Start(TimeSpan timeout) + { + var subscription = new TaskbarSignalSubscription(timeout); + try + { + subscription.ready.Task.WaitAsync(timeout).GetAwaiter().GetResult(); + return subscription; + } + catch + { + subscription.Dispose(); + throw; + } + } + + internal bool TryTake(out ObserverSignal signal, TimeSpan timeout) => + signals.TryTake(out signal, timeout); + + public void Dispose() + { + if (disposed) + { + return; + } + + disposed = true; + stop.Set(); + nint window = Volatile.Read(ref windowHandle); + if (window != nint.Zero) + { + _ = ObserverNativeMethods.PostMessage(window, ObserverNativeMethods.WmClose, 0, nint.Zero); + } + + if (worker.Join(workerTimeout)) + { + signals.Dispose(); + stop.Dispose(); + } + } + + internal static nint StaticWindowProcedure( + nint window, + uint message, + nuint wParam, + nint lParam) + { + TaskbarSignalSubscription? target = null; + try + { + if (message == ObserverNativeMethods.WmNcCreate) + { + ObserverNativeMethods.NativeCreateStruct creation = + Marshal.PtrToStructure(lParam); + if (creation.CreateParameters == nint.Zero) + { + return nint.Zero; + } + + ObserverNativeMethods.SetLastError(0); + nint previous = ObserverNativeMethods.SetWindowLongPointer( + window, + ObserverNativeMethods.GwlpUserData, + creation.CreateParameters); + if (previous == nint.Zero && Marshal.GetLastWin32Error() != 0) + { + return nint.Zero; + } + } + + nint instancePointer = ObserverNativeMethods.GetWindowLongPointer( + window, + ObserverNativeMethods.GwlpUserData); + if (instancePointer != nint.Zero) + { + target = GCHandle.FromIntPtr(instancePointer).Target as TaskbarSignalSubscription; + if (target is not null) + { + if (message == ObserverNativeMethods.WmNcCreate) + { + Volatile.Write(ref target.windowHandle, window); + } + + nint result = target.WindowProcedure(window, message, wParam, lParam); + if (message == ObserverNativeMethods.WmNcDestroy) + { + _ = ObserverNativeMethods.SetWindowLongPointer( + window, + ObserverNativeMethods.GwlpUserData, + nint.Zero); + if (target.windowHandle == window) + { + Volatile.Write(ref target.windowHandle, nint.Zero); + } + } + + return result; + } + } + } + catch (Exception exception) + { + target?.RecordFailure(exception); + } + + return ObserverNativeMethods.DefWindowProcedure(window, message, wParam, lParam); + } + + private void WorkerEntry() + { + try + { + identity = ObserverNativeMethods.FindPrimaryTaskbar(); + taskbarWindowHandle = identity.WindowHandle; + taskbarCreatedMessage = ObserverNativeMethods.RegisterWindowMessage(TaskbarCreatedMessageName); + shellHookMessage = ObserverNativeMethods.RegisterWindowMessage(ShellHookMessageName); + if (taskbarCreatedMessage == 0 || shellHookMessage == 0) + { + throw new Win32Exception(); + } + + ObserverSignalWindowClass.Registration registration = ObserverSignalWindowClass.GetRegistration(); + instanceHandle = GCHandle.Alloc(this, GCHandleType.Normal); + nint window = ObserverNativeMethods.CreateWindow( + ObserverNativeMethods.WindowExtendedStyleToolWindow | + ObserverNativeMethods.WindowExtendedStyleNoActivate, + registration.ClassName, + "QuickPods taskbar signal window", + ObserverNativeMethods.WindowStylePopup, + 0, + 0, + 0, + 0, + nint.Zero, + nint.Zero, + registration.Instance, + GCHandle.ToIntPtr(instanceHandle)); + if (window == nint.Zero) + { + throw new Win32Exception(); + } + + ThrowIfFailed(); + if (ObserverNativeMethods.GetParent(window) != nint.Zero) + { + throw new InvalidOperationException("The observer signal window must remain top-level."); + } + + if (stop.IsSet) + { + return; + } + + if (!ObserverNativeMethods.RegisterShellHookWindow(window)) + { + throw new Win32Exception(); + } + + shellHookRegistered = true; + winEventHook = ObserverNativeMethods.SetWinEventHook( + ObserverNativeMethods.EventObjectCreate, + ObserverNativeMethods.EventObjectLocationChange, + nint.Zero, + winEventProcedure, + identity.ExplorerProcessId, + 0, + ObserverNativeMethods.WinEventOutOfContext | ObserverNativeMethods.WinEventSkipOwnProcess); + if (winEventHook == nint.Zero) + { + throw new Win32Exception(); + } + + SeedTaskbarTree(); + if (ObserverNativeMethods.SetTimer( + window, + ObserverNativeMethods.IdentityTimerId, + (uint)TaskbarSignalPolicy.IdentityPollInterval.TotalMilliseconds, + nint.Zero) == 0) + { + throw new Win32Exception(); + } + + ArmRegistryWatches(); + RefreshTaskbarSettings(); + ready.TrySetResult(true); + RunMessageLoop(); + } + catch (Exception exception) + { + Volatile.Write(ref failure, exception); + ready.TrySetException(exception); + } + finally + { + Cleanup(); + } + } + + private void RunMessageLoop() + { + while (true) + { + nint[] handles = registryWaitHandles; + uint result = ObserverNativeMethods.MessageWaitForMultipleObjects( + (uint)handles.Length, + handles, + ObserverNativeMethods.Infinite, + ObserverNativeMethods.QueueStatusAllInput, + ObserverNativeMethods.MessageWaitInputAvailable); + if (result == ObserverNativeMethods.WaitFailed) + { + throw new Win32Exception(); + } + + if (result < handles.Length) + { + OnRegistryChanged((int)result); + ThrowIfFailed(); + continue; + } + + while (ObserverNativeMethods.PeekMessage( + out ObserverNativeMethods.NativeMessage message, + nint.Zero, + 0, + 0, + ObserverNativeMethods.PeekMessageRemove)) + { + if (message.Message == ObserverNativeMethods.WmQuit) + { + return; + } + + _ = ObserverNativeMethods.TranslateMessage(ref message); + _ = ObserverNativeMethods.DispatchMessage(ref message); + } + + ThrowIfFailed(); + } + } + + private nint WindowProcedure(nint window, uint message, nuint wParam, nint lParam) + { + if (window != windowHandle) + { + return ObserverNativeMethods.DefWindowProcedure(window, message, wParam, lParam); + } + + if (message == shellHookMessage && shellHookMessage != 0) + { + OnShellHook((ulong)wParam); + return nint.Zero; + } + + switch (message) + { + case ObserverNativeMethods.WmTimer: + OnTimer(wParam); + return nint.Zero; + case ObserverNativeMethods.WmClose: + _ = ObserverNativeMethods.DestroyWindow(window); + return nint.Zero; + case ObserverNativeMethods.WmDestroy: + ObserverNativeMethods.PostQuitMessage(0); + return nint.Zero; + default: + SignalDisposition disposition = + TaskbarSignalPolicy.ClassifyWindowMessage(message, taskbarCreatedMessage); + if (!disposition.IsNone) + { + Apply(disposition); + return nint.Zero; + } + + return ObserverNativeMethods.DefWindowProcedure(window, message, wParam, lParam); + } + } + + private void OnShellHook(ulong code) + { + SignalDisposition disposition = TaskbarSignalPolicy.ClassifyShellHook(code, taskbarLabelsVisible); + if (disposition.IsNone) + { + return; + } + + if (TaskbarSignalPolicy.IsThrottledShellHook(code)) + { + TimeSpan now = TimeSpan.FromMilliseconds(Environment.TickCount64); + if (!TaskbarSignalPolicy.ShouldEmitThrottled( + now, + lastRedrawEmitted, + TaskbarSignalPolicy.RedrawThrottle, + out bool scheduleTrailing)) + { + if (scheduleTrailing) + { + ArmSettle(); + } + + return; + } + + lastRedrawEmitted = now; + } + + Apply(disposition); + } + + private void OnTimer(nuint timerId) + { + if (timerId == ObserverNativeMethods.SettleTimerId) + { + _ = ObserverNativeMethods.KillTimer(windowHandle, ObserverNativeMethods.SettleTimerId); + Apply(TaskbarSignalPolicy.Settle); + return; + } + + if (timerId != ObserverNativeMethods.IdentityTimerId || generationRetired) + { + return; + } + + bool changed; + try + { + changed = ObserverNativeMethods.FindPrimaryTaskbar() != identity; + } + catch (InvalidOperationException) + { + changed = true; + } + + if (!changed) + { + return; + } + + generationRetired = true; + _ = ObserverNativeMethods.KillTimer(windowHandle, ObserverNativeMethods.IdentityTimerId); + Enqueue(new( + ObserverInvalidationKind.ExplorerGenerationChanged, + ObserverSourceClassification.External)); + ObserverNativeMethods.PostQuitMessage(0); + } + + private void OnWinEvent( + nint hook, + uint eventId, + nint window, + int objectId, + int childId, + uint eventThread, + uint eventTime) + { + try + { + if (window == nint.Zero || + objectId != ObserverNativeMethods.ObjectIdWindow || + childId != ObserverNativeMethods.ChildIdSelf) + { + return; + } + + bool isInTaskbarTree; + if (eventId == ObserverNativeMethods.EventObjectDestroy) + { + // The HWND is already gone when an out-of-context DESTROY arrives, + // so ancestry cannot be queried; rely on the tracked tree instead. + isInTaskbarTree = taskbarTreeWindows.Remove(window); + } + else + { + isInTaskbarTree = window == taskbarWindowHandle || + ObserverNativeMethods.GetAncestor(window, ObserverNativeMethods.GetAncestorRoot) == + taskbarWindowHandle; + if (isInTaskbarTree) + { + TrackTaskbarWindow(window); + } + } + + Apply(TaskbarSignalPolicy.ClassifyWinEvent(eventId, objectId, childId, isInTaskbarTree)); + } + catch (Exception exception) + { + RecordFailure(exception); + } + } + + private void OnRegistryChanged(int index) + { + if (index < 0 || index >= registryWatches.Count) + { + return; + } + + RefreshTaskbarSettings(); + Apply(TaskbarSignalPolicy.ClassifyRegistryChange()); + RegistryWatch watch = registryWatches[index]; + if (!watch.TryArm()) + { + registryWatches.RemoveAt(index); + watch.Dispose(); + RebuildRegistryWaitHandles(); + } + } + + private void Apply(SignalDisposition disposition) + { + if (disposition.Kind is { } kind) + { + Enqueue(new(kind, disposition.Source)); + } + + if (disposition.ArmSettle) + { + ArmSettle(); + } + } + + private void ArmSettle() + { + nint window = windowHandle; + if (window == nint.Zero) + { + return; + } + + // Re-arming an existing timer id restarts it, so bursts collapse into one + // settle signal after the last raw signal. Failure is tolerated because + // the host watchdog remains the last resort. + _ = ObserverNativeMethods.SetTimer( + window, + ObserverNativeMethods.SettleTimerId, + (uint)TaskbarSignalPolicy.SettleDelay.TotalMilliseconds, + nint.Zero); + } + + private void SeedTaskbarTree() + { + taskbarTreeWindows.Clear(); + taskbarTreeWindows.Add(taskbarWindowHandle); + _ = ObserverNativeMethods.EnumChildWindows( + taskbarWindowHandle, + (child, _) => + { + TrackTaskbarWindow(child); + return true; + }, + nint.Zero); + } + + private void TrackTaskbarWindow(nint window) + { + if (taskbarTreeWindows.Count >= MaximumTrackedTaskbarWindows) + { + taskbarTreeWindows.Clear(); + taskbarTreeWindows.Add(taskbarWindowHandle); + } + + taskbarTreeWindows.Add(window); + } + + private void ArmRegistryWatches() + { + foreach (string path in WatchedRegistryPaths) + { + RegistryWatch? watch = RegistryWatch.TryOpen(path); + if (watch is null) + { + continue; + } + + if (watch.TryArm()) + { + registryWatches.Add(watch); + } + else + { + watch.Dispose(); + } + } + + RebuildRegistryWaitHandles(); + } + + private void RebuildRegistryWaitHandles() => + registryWaitHandles = [.. registryWatches.Select(watch => watch.WaitHandle)]; + + private void RefreshTaskbarSettings() + { + object? glomLevel = null; + try + { + using RegistryKey? advanced = Registry.CurrentUser.OpenSubKey( + ExplorerAdvancedRegistryPath, + writable: false); + glomLevel = advanced?.GetValue(TaskbarGlomLevelValueName); + } + catch (Exception exception) when ( + exception is IOException or + SecurityException or + UnauthorizedAccessException) + { + // Unreadable settings fall back to the default combined layout. + } + + taskbarLabelsVisible = TaskbarSignalPolicy.AreTaskbarLabelsVisible(glomLevel); + } + + private void Enqueue(ObserverSignal signal) + { + try + { + signals.Add(signal); + } + catch (InvalidOperationException) when (signals.IsAddingCompleted) + { + } + catch (ObjectDisposedException) + { + } + } + + private void RecordFailure(Exception exception) => + Interlocked.CompareExchange(ref failure, exception, null); + + private void ThrowIfFailed() + { + if (Volatile.Read(ref failure) is { } pending) + { + throw new InvalidOperationException("The observer signal window procedure failed.", pending); + } + } + + private void Cleanup() + { + if (winEventHook != nint.Zero) + { + _ = ObserverNativeMethods.UnhookWinEvent(winEventHook); + winEventHook = nint.Zero; + } + + nint window = windowHandle; + if (window != nint.Zero && ObserverNativeMethods.IsWindow(window)) + { + if (shellHookRegistered) + { + _ = ObserverNativeMethods.DeregisterShellHookWindow(window); + } + + _ = ObserverNativeMethods.KillTimer(window, ObserverNativeMethods.IdentityTimerId); + _ = ObserverNativeMethods.KillTimer(window, ObserverNativeMethods.SettleTimerId); + _ = ObserverNativeMethods.DestroyWindow(window); + } + + shellHookRegistered = false; + Volatile.Write(ref windowHandle, nint.Zero); + if (instanceHandle.IsAllocated) + { + instanceHandle.Free(); + } + + foreach (RegistryWatch watch in registryWatches) + { + watch.Dispose(); + } + + registryWatches.Clear(); + registryWaitHandles = []; + } + + private sealed class RegistryWatch : IDisposable + { + private const uint NotifyFilter = + ObserverNativeMethods.RegistryNotifyChangeName | + ObserverNativeMethods.RegistryNotifyChangeLastSet | + ObserverNativeMethods.RegistryNotifyThreadAgnostic; + + private readonly RegistryKey key; + private readonly AutoResetEvent changed; + + private RegistryWatch(RegistryKey key, AutoResetEvent changed) + { + this.key = key; + this.changed = changed; + } + + // The AutoResetEvent stays alive for the lifetime of this watch, so the + // raw handle remains valid while it sits in the wait array. + internal nint WaitHandle => changed.SafeWaitHandle.DangerousGetHandle(); + + internal static RegistryWatch? TryOpen(string path) + { + RegistryKey? key = null; + try + { + key = Registry.CurrentUser.OpenSubKey(path, writable: false); + if (key is null) + { + return null; + } + + return new RegistryWatch(key, new AutoResetEvent(false)); + } + catch (Exception exception) when ( + exception is IOException or + SecurityException or + UnauthorizedAccessException) + { + key?.Dispose(); + return null; + } + } + + internal bool TryArm() + { + try + { + return ObserverNativeMethods.RegistryNotifyChangeKeyValue( + key.Handle, + true, + NotifyFilter, + changed.SafeWaitHandle, + true) == 0; + } + catch (Exception exception) when ( + exception is IOException or + ObjectDisposedException or + UnauthorizedAccessException) + { + return false; + } + } + + public void Dispose() + { + // Closing the key cancels any pending notification before the event + // handle is released. + key.Dispose(); + changed.Dispose(); + } + } +} diff --git a/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj b/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj index 4fe3fb8..d099735 100644 --- a/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj +++ b/tests/QuickPods.Foundation.Tests/QuickPods.Foundation.Tests.csproj @@ -29,6 +29,7 @@ + diff --git a/tests/QuickPods.Foundation.Tests/TaskbarSignalPolicyTests.cs b/tests/QuickPods.Foundation.Tests/TaskbarSignalPolicyTests.cs new file mode 100644 index 0000000..1ec55bb --- /dev/null +++ b/tests/QuickPods.Foundation.Tests/TaskbarSignalPolicyTests.cs @@ -0,0 +1,189 @@ +using QuickPods.Contracts; +using QuickPods.TaskbarObserver; +using Xunit; + +namespace QuickPods.Foundation.Tests; + +public sealed class TaskbarSignalPolicyTests +{ + private const uint TaskbarCreatedMessage = 0xC0A1; + + [Theory] + [InlineData(1UL, false)] + [InlineData(2UL, false)] + [InlineData(13UL, false)] + [InlineData(1UL, true)] + [InlineData(2UL, true)] + [InlineData(13UL, true)] + public void TaskbarListChangesFromTheShellHookInvalidateStructure(ulong code, bool labelsVisible) + { + SignalDisposition disposition = TaskbarSignalPolicy.ClassifyShellHook(code, labelsVisible); + + Assert.Equal(ObserverInvalidationKind.StructureChanged, disposition.Kind); + Assert.Equal(ObserverSourceClassification.External, disposition.Source); + Assert.True(disposition.ArmSettle); + Assert.False(TaskbarSignalPolicy.IsThrottledShellHook(code)); + } + + [Fact] + public void ShellRedrawInvalidatesStructureOnlyWhileLabelsAreVisibleAndIsThrottled() + { + SignalDisposition withLabels = TaskbarSignalPolicy.ClassifyShellHook(6UL, taskbarLabelsVisible: true); + SignalDisposition withoutLabels = TaskbarSignalPolicy.ClassifyShellHook(6UL, taskbarLabelsVisible: false); + + Assert.Equal(ObserverInvalidationKind.StructureChanged, withLabels.Kind); + Assert.True(withoutLabels.IsNone); + Assert.True(TaskbarSignalPolicy.IsThrottledShellHook(6UL)); + } + + [Theory] + [InlineData(null, false)] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(2, true)] + [InlineData("2", false)] + public void TaskbarLabelsAreVisibleOnlyForANonZeroGlomLevel(object? glomLevel, bool expected) + { + Assert.Equal(expected, TaskbarSignalPolicy.AreTaskbarLabelsVisible(glomLevel)); + } + + [Theory] + [InlineData(3UL)] + [InlineData(4UL)] + [InlineData(5UL)] + [InlineData(14UL)] + [InlineData(16UL)] + [InlineData(0x8004UL)] + [InlineData(0x8006UL)] + public void ActivationFlashAndUnrelatedShellHooksAreIgnored(ulong code) + { + Assert.True(TaskbarSignalPolicy.ClassifyShellHook(code, taskbarLabelsVisible: true).IsNone); + Assert.True(TaskbarSignalPolicy.ClassifyShellHook(code, taskbarLabelsVisible: false).IsNone); + } + + [Theory] + [InlineData(0x8000u, ObserverInvalidationKind.StructureChanged)] + [InlineData(0x8001u, ObserverInvalidationKind.StructureChanged)] + [InlineData(0x8004u, ObserverInvalidationKind.StructureChanged)] + [InlineData(0x8002u, ObserverInvalidationKind.IsOffscreenChanged)] + [InlineData(0x8003u, ObserverInvalidationKind.IsOffscreenChanged)] + public void TaskbarTreeWindowEventsInvalidateImmediatelyAndArmSettle( + uint eventId, + ObserverInvalidationKind expected) + { + SignalDisposition disposition = TaskbarSignalPolicy.ClassifyWinEvent(eventId, 0, 0, true); + + Assert.Equal(expected, disposition.Kind); + Assert.Equal(ObserverSourceClassification.External, disposition.Source); + Assert.True(disposition.ArmSettle); + } + + [Fact] + public void LocationChangesOnlyArmTheSettleTimer() + { + SignalDisposition disposition = TaskbarSignalPolicy.ClassifyWinEvent(0x800Bu, 0, 0, true); + + Assert.Null(disposition.Kind); + Assert.True(disposition.ArmSettle); + Assert.False(disposition.IsNone); + } + + [Theory] + [InlineData(0x800Bu, -9, 0, true)] + [InlineData(0x800Bu, 0, 3, true)] + [InlineData(0x8000u, 0, 0, false)] + [InlineData(0x8005u, 0, 0, true)] + [InlineData(0x800Cu, 0, 0, true)] + public void NonWindowChildOutsideTreeAndUnknownEventsAreIgnored( + uint eventId, + int objectId, + int childId, + bool isInTaskbarTree) + { + Assert.True( + TaskbarSignalPolicy.ClassifyWinEvent(eventId, objectId, childId, isInTaskbarTree).IsNone); + } + + [Theory] + [InlineData(0x001Au)] + [InlineData(0x007Eu)] + [InlineData(0x031Au)] + [InlineData(TaskbarCreatedMessage)] + public void ShellBroadcastsInvalidateStructureWithoutIdentity(uint message) + { + SignalDisposition disposition = + TaskbarSignalPolicy.ClassifyWindowMessage(message, TaskbarCreatedMessage); + + Assert.Equal(ObserverInvalidationKind.StructureChanged, disposition.Kind); + Assert.Equal(ObserverSourceClassification.Unknown, disposition.Source); + Assert.True(disposition.ArmSettle); + } + + [Theory] + [InlineData(0x0113u)] + [InlineData(0x000Fu)] + [InlineData(TaskbarCreatedMessage)] + public void UnrelatedWindowMessagesAreIgnored(uint message) + { + Assert.True(TaskbarSignalPolicy.ClassifyWindowMessage(message, 0).IsNone); + } + + [Fact] + public void RegistryAndSettleSignalsAreUnknownSourced() + { + SignalDisposition registry = TaskbarSignalPolicy.ClassifyRegistryChange(); + SignalDisposition settle = TaskbarSignalPolicy.Settle; + + Assert.Equal(ObserverInvalidationKind.StructureChanged, registry.Kind); + Assert.Equal(ObserverSourceClassification.Unknown, registry.Source); + Assert.True(registry.ArmSettle); + Assert.Equal(ObserverInvalidationKind.BoundingRectangleChanged, settle.Kind); + Assert.Equal(ObserverSourceClassification.Unknown, settle.Source); + Assert.False(settle.ArmSettle); + } + + [Fact] + public void ThrottleEmitsLeadingEdgeThenTrailsUntilTheWindowElapses() + { + TimeSpan window = TimeSpan.FromSeconds(1); + TimeSpan start = TimeSpan.FromSeconds(10); + + Assert.True(TaskbarSignalPolicy.ShouldEmitThrottled(start, null, window, out bool trailing)); + Assert.False(trailing); + + Assert.False(TaskbarSignalPolicy.ShouldEmitThrottled( + start + TimeSpan.FromMilliseconds(300), + start, + window, + out trailing)); + Assert.True(trailing); + + Assert.True(TaskbarSignalPolicy.ShouldEmitThrottled(start + window, start, window, out trailing)); + Assert.False(trailing); + } + + [Fact] + public void NoDispositionEverClaimsAnOwnedSource() + { + var dispositions = new List + { + TaskbarSignalPolicy.ClassifyRegistryChange(), + TaskbarSignalPolicy.Settle, + TaskbarSignalPolicy.ClassifyWindowMessage(0x001Au, TaskbarCreatedMessage), + }; + for (ulong code = 0; code <= 0x8010UL; code++) + { + dispositions.Add(TaskbarSignalPolicy.ClassifyShellHook(code, taskbarLabelsVisible: true)); + dispositions.Add(TaskbarSignalPolicy.ClassifyShellHook(code, taskbarLabelsVisible: false)); + } + + for (uint eventId = 0x8000u; eventId <= 0x8010u; eventId++) + { + dispositions.Add(TaskbarSignalPolicy.ClassifyWinEvent(eventId, 0, 0, true)); + } + + Assert.DoesNotContain( + dispositions, + disposition => disposition.Source == ObserverSourceClassification.Owned); + } +} diff --git a/tests/QuickPods.Foundation.Tests/packages.lock.json b/tests/QuickPods.Foundation.Tests/packages.lock.json index b487716..0eeef5c 100644 --- a/tests/QuickPods.Foundation.Tests/packages.lock.json +++ b/tests/QuickPods.Foundation.Tests/packages.lock.json @@ -111,27 +111,33 @@ "quickpods.infrastructure": { "type": "Project", "dependencies": { - "QuickPods.Contracts": "[0.1.1, )", - "QuickPods.Core": "[0.1.1, )" + "QuickPods.Contracts": "[1.0.0, )", + "QuickPods.Core": "[1.0.0, )" } }, "quickpods.presentation": { "type": "Project", "dependencies": { - "QuickPods.Contracts": "[0.1.1, )", - "QuickPods.Core": "[0.1.1, )" + "QuickPods.Contracts": "[1.0.0, )", + "QuickPods.Core": "[1.0.0, )" } }, "quickpods.taskbarhost": { "type": "Project", "dependencies": { - "QuickPods.Contracts": "[0.1.1, )" + "QuickPods.Contracts": "[1.0.0, )" + } + }, + "quickpods.taskbarobserver": { + "type": "Project", + "dependencies": { + "QuickPods.Contracts": "[1.0.0, )" } }, "quickpods.windows": { "type": "Project", "dependencies": { - "QuickPods.Core": "[0.1.1, )" + "QuickPods.Core": "[1.0.0, )" } } }