Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 48 additions & 23 deletions src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32Accessibility.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ internal sealed class Win32Accessibility : SkiaAccessibilityBase
private readonly ConditionalWeakTable<UIElement, Win32RawElementProvider> _providers = new();
private readonly ConditionalWeakTable<AutomationPeer, Win32RawElementProvider> _peerProviders = new();
private readonly HashSet<Win32RawElementProvider> _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<Win32RawElementProvider> _disconnectedProviders = new(ReferenceEqualityComparer.Instance);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_pendingStructureChanges (declared just above) holds the same reference-typed Win32RawElementProvider values but is constructed without an explicit comparer, while this field spells out ReferenceEqualityComparer.Instance. Since Win32RawElementProvider doesn't override Equals/GetHashCode the behaviour is identical, but for clarity and consistency it's worth aligning them:

Suggested change
private readonly HashSet<Win32RawElementProvider> _disconnectedProviders = new(ReferenceEqualityComparer.Instance);
private readonly HashSet<Win32RawElementProvider> _pendingStructureChanges = new(ReferenceEqualityComparer.Instance);
// 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<Win32RawElementProvider> _disconnectedProviders = new(ReferenceEqualityComparer.Instance);

private bool _structureChangeFlushQueued;

internal Win32RawElementProvider? RootProvider => _rootProvider;
Expand Down Expand Up @@ -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())
Expand All @@ -365,6 +356,34 @@ private void CleanupProviders(UIElement element)
}
}

/// <summary>
/// Bookkeeping invoked by <see cref="Win32RawElementProvider.Invalidate"/>:
/// 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.
/// </summary>
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;
Expand Down Expand Up @@ -401,6 +420,7 @@ private void RaiseStructureChanged(Win32RawElementProvider provider)
if (IsDisposed)
{
_pendingStructureChanges.Clear();
_disconnectedProviders.Clear();
return;
}

Expand All @@ -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();
});
}

Expand Down Expand Up @@ -766,6 +790,7 @@ protected override void DisposeCore()
_providers.Clear();
_peerProviders.Clear();
_pendingStructureChanges.Clear();
_disconnectedProviders.Clear();
}

// ──────────────────────────────────────────────────────────────
Expand Down
134 changes: 132 additions & 2 deletions src/Uno.UI.Runtime.Skia.Win32/Accessibility/Win32RawElementProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ internal class Win32RawElementProvider :
private readonly Win32Accessibility _accessibility;
private readonly WeakReference<AutomationPeer>? _representedPeer;
private IList<AutomationPeer>? _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(
Expand All @@ -63,13 +65,94 @@ internal Win32RawElementProvider(
internal bool RepresentsPeer(AutomationPeer peer)
=> ReferenceEquals(GetAutomationPeer(), peer);

// MUX Reference AutomationPeer.cpp CAutomationPeer::Deinit β†’ CUIAWrapper::Invalidate
/// <summary>
/// Detaches this provider from its element when the underlying subtree is
/// removed. Mirrors WinUI's <c>CUIAWrapper::Invalidate</c>: the provider is
/// disconnected from UIA (<c>UiaDisconnectProvider</c>) and every subsequent
/// call fails with <c>UIA_E_ELEMENTNOTAVAILABLE</c> via <see cref="ThrowIfDisconnected"/>.
/// </summary>
/// <remarks>
/// 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
/// <c>0x80070002</c> (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.
/// </remarks>
internal void Invalidate(HashSet<Win32RawElementProvider>? visited = null)
{
if (_isRoot || _isInvalidated)
{
return;
}

visited ??= new HashSet<Win32RawElementProvider>(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();
Comment thread
morning4coffe-dev marked this conversation as resolved.
return ProviderOptions.ServerSideProvider | ProviderOptions.UseComThreading;
}
}

public object? GetPatternProvider(int patternId)
{
ThrowIfDisconnected();
try
{
var peer = GetAutomationPeer();
Expand Down Expand Up @@ -202,6 +285,7 @@ when peer.GetPattern(PatternInterface.Transform2) is ITransformProvider2 transfo

public object? GetPropertyValue(int propertyId)
{
ThrowIfDisconnected();
try
{
var peer = GetAutomationPeer();
Expand All @@ -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),
Expand Down Expand Up @@ -316,6 +401,7 @@ public IRawElementProviderSimple? HostRawElementProvider

public IRawElementProviderFragment? Navigate(NavigateDirection direction)
{
ThrowIfDisconnected();
try
{
var result = direction switch
Expand Down Expand Up @@ -350,13 +436,15 @@ public IRawElementProviderSimple? HostRawElementProvider

public int[]? GetRuntimeId()
{
ThrowIfDisconnected();
return [Win32UIAutomationInterop.UiaAppendRuntimeId, _runtimeId];
}

public UiaRect BoundingRectangle
{
get
{
ThrowIfDisconnected();
try
{
Windows.Foundation.Rect logicalRect;
Expand Down Expand Up @@ -425,10 +513,50 @@ public UiaRect BoundingRectangle
}
}

// 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() => null;
Comment thread
Copilot marked this conversation as resolved.
Outdated

public void SetFocus()
{
ThrowIfDisconnected();
if (_owner is Control control)
{
control.Focus(FocusState.Programmatic);
Expand All @@ -447,6 +575,7 @@ public IRawElementProviderFragmentRoot? FragmentRoot

public IRawElementProviderFragment? ElementProviderFromPoint(double x, double y)
{
ThrowIfDisconnected();
Comment thread
morning4coffe-dev marked this conversation as resolved.
try
{
var deepest = FindDeepestProviderAtPoint(x, y) ?? this;
Expand Down Expand Up @@ -557,6 +686,7 @@ private IRawElementProviderFragment WalkUpToContainingControl(

public IRawElementProviderFragment? GetFocus()
{
ThrowIfDisconnected();
try
{
var xamlRoot = _owner.XamlRoot;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading