From 05a5ba4dd326481545ebcc72dbe6c4b7289ad65b Mon Sep 17 00:00:00 2001 From: Juanjo Date: Wed, 30 Sep 2026 22:57:47 +0100 Subject: [PATCH] Fix inverted NoClientNotifications check in Control.AccessibilityNotifyClients (#15177) The rename of LocalAppContextSwitches to AppContextSwitches in #12840 dropped the negation on this line (and only on this one of the seven that use the switch), so controls only call NotifyWinEvent when the app has asked for NO client notifications, that is, never by default. MSAA clients (JAWS among them) stopped receiving the WinEvents raised through this method: menu popup start/end, focus of ToolStrip items, state and selection changes, etc. .NET 9: if (IsHandleCreated && !LocalAppContextSwitches.NoClientNotifications) .NET 10: if (IsHandleCreated && AppContextSwitches.NoClientNotifications) Adds tests that listen to the WinEvent; the existing ones only checked that the call did not throw. --- .../System/Windows/Forms/Control.cs | 2 +- ...ControlTests.AccessibilityNotifyClients.cs | 108 ++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.AccessibilityNotifyClients.cs diff --git a/src/System.Windows.Forms/System/Windows/Forms/Control.cs b/src/System.Windows.Forms/System/Windows/Forms/Control.cs index cb67f146d3e..e500ea5e2d1 100644 --- a/src/System.Windows.Forms/System/Windows/Forms/Control.cs +++ b/src/System.Windows.Forms/System/Windows/Forms/Control.cs @@ -4438,7 +4438,7 @@ protected internal void AccessibilityNotifyClients(AccessibleEvents accEvent, in [EditorBrowsable(EditorBrowsableState.Advanced)] protected void AccessibilityNotifyClients(AccessibleEvents accEvent, int objectID, int childID) { - if (IsHandleCreated && AppContextSwitches.NoClientNotifications) + if (IsHandleCreated && !AppContextSwitches.NoClientNotifications) { PInvoke.NotifyWinEvent((uint)accEvent, this, objectID, childID + 1); } diff --git a/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.AccessibilityNotifyClients.cs b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.AccessibilityNotifyClients.cs new file mode 100644 index 00000000000..c21fcdd9239 --- /dev/null +++ b/src/test/unit/System.Windows.Forms/System/Windows/Forms/ControlTests.AccessibilityNotifyClients.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using Windows.Win32.UI.Accessibility; + +namespace System.Windows.Forms.Tests; + +public partial class ControlTests +{ + [WinFormsFact] + public void Control_AccessibilityNotifyClients_WithHandle_RaisesWinEvent() + { + using NoClientNotificationsScope scope = new(enable: false); + using SubControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + + using WinEventListener listener = new(AccessibleEvents.Focus); + control.AccessibilityNotifyClients(AccessibleEvents.Focus, 2); + + WinEventListener.WinEvent winEvent = Assert.Single(listener.EventsOf(control)); + Assert.Equal((uint)AccessibleEvents.Focus, winEvent.Event); + Assert.Equal((int)OBJECT_IDENTIFIER.OBJID_CLIENT, winEvent.ObjectId); + + // MSAA child ids are 1-based. + Assert.Equal(3, winEvent.ChildId); + } + + [WinFormsFact] + public void Control_AccessibilityNotifyClients_ObjectId_WithHandle_RaisesWinEvent() + { + using NoClientNotificationsScope scope = new(enable: false); + using SubControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + + using WinEventListener listener = new(AccessibleEvents.SystemMenuStart); + control.AccessibilityNotifyClients(AccessibleEvents.SystemMenuStart, (int)OBJECT_IDENTIFIER.OBJID_MENU, -1); + + WinEventListener.WinEvent winEvent = Assert.Single(listener.EventsOf(control)); + Assert.Equal((int)OBJECT_IDENTIFIER.OBJID_MENU, winEvent.ObjectId); + Assert.Equal(0, winEvent.ChildId); + } + + [WinFormsFact] + public void Control_AccessibilityNotifyClients_NoClientNotifications_DoesNotRaiseWinEvent() + { + using NoClientNotificationsScope scope = new(enable: true); + using SubControl control = new(); + Assert.NotEqual(IntPtr.Zero, control.Handle); + + using WinEventListener listener = new(AccessibleEvents.Focus); + control.AccessibilityNotifyClients(AccessibleEvents.Focus, 2); + + Assert.Empty(listener.EventsOf(control)); + } + + /// + /// Listens, on the current thread, to the WinEvents of one kind raised by this process. + /// The hook is in-context, so the callback runs synchronously inside NotifyWinEvent. + /// + private sealed unsafe class WinEventListener : IDisposable + { + public readonly record struct WinEvent(uint Event, HWND Hwnd, int ObjectId, int ChildId); + + // The hook is installed for the current thread only, and WinForms tests do not run in parallel. + private static List? s_events; + + private readonly HWINEVENTHOOK _hook; + + public WinEventListener(AccessibleEvents accEvent) + { + s_events = []; + _hook = PInvoke.SetWinEventHook( + (uint)accEvent, + (uint)accEvent, + PInvoke.GetModuleHandle((PCWSTR)null), + &WinEventProc, + (uint)Environment.ProcessId, + PInvokeCore.GetCurrentThreadId(), + PInvoke.WINEVENT_INCONTEXT); + + Assert.False(_hook.IsNull); + } + + public IEnumerable EventsOf(Control control) + { + HWND hwnd = control.HWND; + return s_events!.Where(e => e.Hwnd == hwnd); + } + + [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)])] + private static void WinEventProc( + HWINEVENTHOOK hook, + uint @event, + HWND hwnd, + int idObject, + int idChild, + uint idEventThread, + uint dwmsEventTime) => s_events?.Add(new(@event, hwnd, idObject, idChild)); + + public void Dispose() + { + PInvoke.UnhookWinEvent(_hook); + s_events = null; + } + } +}