diff --git a/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32Accessibility.cs b/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32Accessibility.cs index 6322f9f6259d..e5c3483cfb41 100644 --- a/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32Accessibility.cs +++ b/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32Accessibility.cs @@ -33,6 +33,12 @@ internal sealed class Win32Accessibility : SkiaAccessibilityBase private readonly ConditionalWeakTable _providers = new(); private readonly ConditionalWeakTable _peerProviders = new(); private readonly HashSet _pendingStructureChanges = new(); + // Strong references to just-invalidated providers. Keeps their COM-callable + // wrappers alive across the window between UiaDisconnectProvider and UIA + // delivering the structure-changed notification, so an out-of-proc client + // that still holds the proxy observes UIA_E_ELEMENTNOTAVAILABLE rather than a + // severed-CCW 0x80070002. Drained once the structure-change flush completes. + private readonly HashSet _disconnectedProviders = new(ReferenceEqualityComparer.Instance); private bool _structureChangeFlushQueued; internal Win32RawElementProvider? RootProvider => _rootProvider; @@ -333,29 +339,14 @@ private void CleanupProviders(UIElement element) if (_providers.TryGetValue(current, out var provider)) { - // Clear cached peer lists so a stale provider cannot keep the - // removed subtree alive. - provider.InvalidateChildrenCache(); - _pendingStructureChanges.Remove(provider); - - _providers.Remove(current); - if (provider.RepresentedPeer is { } representedPeer) - { - _peerProviders.Remove(representedPeer); - } - - // Disconnect the provider from UIA so stale COM references are released. - try - { - _ = Win32UIAutomationInterop.UiaDisconnectProvider(provider); - } - catch (Exception ex) - { - if (this.Log().IsEnabled(LogLevel.Debug)) - { - this.Log().Debug($"UiaDisconnectProvider failed for {provider.DescribeElement()}: {ex.Message}"); - } - } + // Invalidate (mirrors WinUI's CUIAWrapper::Invalidate): clears the + // cached children, cascades the disconnect to virtual children — + // e.g. WCT DataGrid rows/cells not reachable via the element table — + // disconnects from UIA, and updates the lookup tables + tombstone + // via OnProviderInvalidated. Every subsequent call on the provider + // then fails with UIA_E_ELEMENTNOTAVAILABLE instead of the CCW being + // GC'd out from under a client's proxy (0x80070002). + provider.Invalidate(); } foreach (var child in current.GetChildren()) @@ -365,6 +356,34 @@ private void CleanupProviders(UIElement element) } } + /// + /// Bookkeeping invoked by : + /// drops the provider from the lookup tables (so a re-added element is issued + /// a fresh provider) and roots it in the tombstone set until the pending + /// structure-change flush completes, guaranteeing the COM-callable wrapper + /// outlives UIA's processing of the disconnect. + /// + internal void OnProviderInvalidated(Win32RawElementProvider provider) + { + _pendingStructureChanges.Remove(provider); + + if (provider.RepresentedPeer is { } representedPeer) + { + _peerProviders.Remove(representedPeer); + } + + // Only drop the element→provider mapping when this provider owns it. + // Virtual providers share their owner UIElement with the canonical peer's + // provider and must not evict it. + if (_providers.TryGetValue(provider.Owner, out var byElement) + && ReferenceEquals(byElement, provider)) + { + _providers.Remove(provider.Owner); + } + + _disconnectedProviders.Add(provider); + } + private Win32RawElementProvider? FindNearestAncestorProvider(UIElement element) { UIElement? current = element; @@ -401,6 +420,7 @@ private void RaiseStructureChanged(Win32RawElementProvider provider) if (IsDisposed) { _pendingStructureChanges.Clear(); + _disconnectedProviders.Clear(); return; } @@ -409,6 +429,10 @@ private void RaiseStructureChanged(Win32RawElementProvider provider) RaiseStructureChangedCore(pending); } _pendingStructureChanges.Clear(); + + // UIA has now been notified of the structure change and any disconnects + // have been delivered, so it is safe to release the tombstoned providers. + _disconnectedProviders.Clear(); }); } @@ -766,6 +790,7 @@ protected override void DisposeCore() _providers.Clear(); _peerProviders.Clear(); _pendingStructureChanges.Clear(); + _disconnectedProviders.Clear(); } // ────────────────────────────────────────────────────────────── diff --git a/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32RawElementProvider.cs b/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32RawElementProvider.cs index 301d58d4b4aa..82eb4dda30bb 100644 --- a/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32RawElementProvider.cs +++ b/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32RawElementProvider.cs @@ -38,9 +38,11 @@ internal class Win32RawElementProvider : private readonly Win32Accessibility _accessibility; private readonly WeakReference? _representedPeer; private IList? _cachedAutomationChildren; + private bool _isInvalidated; private const int MaxHitTestDepth = 1024; internal UIElement Owner => _owner; + internal bool IsInvalidated => _isInvalidated; internal AutomationPeer? RepresentedPeer => _representedPeer is not null && _representedPeer.TryGetTarget(out var peer) ? peer : null; internal Win32RawElementProvider( @@ -63,13 +65,94 @@ internal Win32RawElementProvider( internal bool RepresentsPeer(AutomationPeer peer) => ReferenceEquals(GetAutomationPeer(), peer); + // MUX Reference AutomationPeer.cpp CAutomationPeer::Deinit → CUIAWrapper::Invalidate + /// + /// Detaches this provider from its element when the underlying subtree is + /// removed. Mirrors WinUI's CUIAWrapper::Invalidate: the provider is + /// disconnected from UIA (UiaDisconnectProvider) and every subsequent + /// call fails with UIA_E_ELEMENTNOTAVAILABLE via . + /// + /// + /// This is the fix for out-of-proc clients (Narrator, FlaUI, WinAppDriver) + /// that cache element references: without a clean disconnect the provider's + /// COM-callable wrapper could be GC'd while the client still held the proxy, + /// so the next call marshalled back to a severed object and surfaced as + /// 0x80070002 (FileNotFound) instead of the expected + /// element-not-available error. The cascade disconnects cached (possibly + /// virtual) children too — e.g. WCT DataGrid row/cell providers that are keyed + /// only by peer and are never reachable through the element table. + /// + internal void Invalidate(HashSet? visited = null) + { + if (_isRoot || _isInvalidated) + { + return; + } + + visited ??= new HashSet(ReferenceEqualityComparer.Instance); + if (!visited.Add(this)) + { + return; + } + + _isInvalidated = true; + + var children = _cachedAutomationChildren; + _cachedAutomationChildren = null; + if (children is not null) + { + for (var i = 0; i < children.Count; i++) + { + var childProvider = _accessibility.TryGetExistingProviderForPeer(children[i]); + if (childProvider is not null && !ReferenceEquals(childProvider, this)) + { + childProvider.Invalidate(visited); + } + } + } + + _accessibility.OnProviderInvalidated(this); + + try + { + _ = Win32UIAutomationInterop.UiaDisconnectProvider(this); + } + catch (Exception ex) + { + if (this.Log().IsEnabled(LogLevel.Debug)) + { + this.Log().Debug($"UiaDisconnectProvider failed for {DescribeElement()}: {ex.Message}"); + } + } + } + + // MUX Reference UIAWrapper.cpp — the `if (!m_pAP) IFC(E_FAIL)` guard at the + // top of each provider method. We use UIA_E_ELEMENTNOTAVAILABLE (the standard + // code UiaDisconnectProvider makes clients observe) rather than E_FAIL. + private void ThrowIfDisconnected() + { + if (_isInvalidated) + { + throw new COMException( + "The UI Automation element is no longer available.", + Win32UIAutomationInterop.UIA_E_ELEMENTNOTAVAILABLE); + } + } + // IRawElementProviderSimple - public ProviderOptions ProviderOptions => - ProviderOptions.ServerSideProvider | ProviderOptions.UseComThreading; + public ProviderOptions ProviderOptions + { + get + { + ThrowIfDisconnected(); + return ProviderOptions.ServerSideProvider | ProviderOptions.UseComThreading; + } + } public object? GetPatternProvider(int patternId) { + ThrowIfDisconnected(); try { var peer = GetAutomationPeer(); @@ -202,6 +285,7 @@ when peer.GetPattern(PatternInterface.Transform2) is ITransformProvider2 transfo public object? GetPropertyValue(int propertyId) { + ThrowIfDisconnected(); try { var peer = GetAutomationPeer(); @@ -218,6 +302,7 @@ when peer.GetPattern(PatternInterface.Transform2) is ITransformProvider2 transfo Win32UIAutomationInterop.UIA_ProviderDescriptionPropertyId => "Uno Platform UIA Provider", Win32UIAutomationInterop.UIA_ProcessIdPropertyId => GetProcessId(), Win32UIAutomationInterop.UIA_NativeWindowHandlePropertyId => _isRoot ? (int)_hwnd : 0, + Win32UIAutomationInterop.UIA_ClickablePointPropertyId => GetClickablePoint(peer), // State Win32UIAutomationInterop.UIA_IsEnabledPropertyId => peer?.IsEnabled() ?? (_owner is Control c ? c.IsEnabled : true), @@ -297,6 +382,7 @@ public IRawElementProviderSimple? HostRawElementProvider { get { + ThrowIfDisconnected(); if (_isRoot) { var hr = Win32UIAutomationInterop.UiaHostProviderFromHwnd(_hwnd, out var hostProvider); @@ -316,6 +402,7 @@ public IRawElementProviderSimple? HostRawElementProvider public IRawElementProviderFragment? Navigate(NavigateDirection direction) { + ThrowIfDisconnected(); try { var result = direction switch @@ -350,6 +437,7 @@ public IRawElementProviderSimple? HostRawElementProvider public int[]? GetRuntimeId() { + ThrowIfDisconnected(); return [Win32UIAutomationInterop.UiaAppendRuntimeId, _runtimeId]; } @@ -357,6 +445,7 @@ public UiaRect BoundingRectangle { get { + ThrowIfDisconnected(); try { Windows.Foundation.Rect logicalRect; @@ -425,10 +514,54 @@ public UiaRect BoundingRectangle } } - public IRawElementProviderFragment[]? GetEmbeddedFragmentRoots() => null; + // MUX Reference UIAWrapper.cpp CUIAWrapper::GetPropertyValueImpl (ClickablePoint_Property) + // Returns the peer-supplied clickable point converted to physical screen + // coordinates. A (0,0) point is treated as unset (VT_EMPTY → null) so UIA + // falls back to its bounding-rectangle heuristic — matching WinUI, whose + // default GetClickablePointCore also returns (0,0). Only peers that override + // GetClickablePointCore produce a non-empty value here. + private double[]? GetClickablePoint(AutomationPeer? peer) + { + if (peer is null) + { + return null; + } + + var point = peer.GetClickablePoint(); + if (point.X == 0 && point.Y == 0) + { + return null; + } + + // Convert logical client coordinates to physical screen coordinates, + // mirroring BoundingRectangle's DPI scale + ClientToScreen step. + float dpiScale = Win32UIAutomationInterop.GetDpiForWindow(_hwnd) + / (float)Win32UIAutomationInterop.USER_DEFAULT_SCREEN_DPI; + if (dpiScale <= 0) + { + dpiScale = 1.0f; + } + + var clientOrigin = new System.Drawing.Point(0, 0); + Win32UIAutomationInterop.ClientToScreen(_hwnd, ref clientOrigin); + + // UIA_ClickablePointPropertyId is a VT_R8 | VT_ARRAY of [x, y]. + return + [ + clientOrigin.X + point.X * dpiScale, + clientOrigin.Y + point.Y * dpiScale, + ]; + } + + public IRawElementProviderFragment[]? GetEmbeddedFragmentRoots() + { + ThrowIfDisconnected(); + return null; + } public void SetFocus() { + ThrowIfDisconnected(); if (_owner is Control control) { control.Focus(FocusState.Programmatic); @@ -441,12 +574,19 @@ public void SetFocus() } public IRawElementProviderFragmentRoot? FragmentRoot - => _accessibility.RootProvider; + { + get + { + ThrowIfDisconnected(); + return _accessibility.RootProvider; + } + } // IRawElementProviderFragmentRoot (only meaningful on root element) public IRawElementProviderFragment? ElementProviderFromPoint(double x, double y) { + ThrowIfDisconnected(); try { var deepest = FindDeepestProviderAtPoint(x, y) ?? this; @@ -557,6 +697,7 @@ private IRawElementProviderFragment WalkUpToContainingControl( public IRawElementProviderFragment? GetFocus() { + ThrowIfDisconnected(); try { var xamlRoot = _owner.XamlRoot; diff --git a/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32UIAutomationInterop.cs b/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32UIAutomationInterop.cs index 5227aaf93080..31f0b5a09980 100644 --- a/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32UIAutomationInterop.cs +++ b/src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32UIAutomationInterop.cs @@ -297,6 +297,9 @@ internal static class Win32UIAutomationInterop internal const int OrientationType_Horizontal = 1; internal const int OrientationType_Vertical = 2; + // UIA error HRESULTs + internal const int UIA_E_ELEMENTNOTAVAILABLE = unchecked((int)0x80040201); + // Win32 helpers for coordinate conversion internal const uint USER_DEFAULT_SCREEN_DPI = 96;