GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
MsaaHelper.cs
1// =============================================================================
2// GPAL - Generally Positive Automation Library
3// Copyright © 2026 Software Decisions, Inc. All rights reserved.
4//
5// This file is part of GPAL.
6// Licensed under the Business Source License 1.1
7//
8// Primary development, architecture, and vision by Michael B. Vederman,
9// CEO of Software Decisions, Inc., Texas.
10//
11// Internal development maintained privately.
12// Public releases appear on GitHub: https://github.com/SoftwareDecisionsInc/GPAL.
13//
14// See LICENSE for full terms, including Additional Use Grant.
15// =============================================================================
16
17using Accessibility;
18using System;
19using System.Runtime.InteropServices;
20using System.Windows;
21using System.Windows.Automation;
22using static GenerallyPositive.Enums;
23
25{
41 internal static class MsaaHelper
42 {
43 private const uint OBJID_CLIENT = 0xFFFFFFFC;
44 private const int STATE_SYSTEM_SELECTED = 0x00000002;
45 private const int SELFLAG_TAKEFOCUS = 0x00000001;
46 private const int SELFLAG_TAKESELECTION = 0x00000002;
47 private const int CHILDID_SELF = 0;
48
49 // how far the accessible tree is walked looking for the node, and how many nodes are looked at on the
50 // way. A ribbon tab is four deep from the window and these bounds are not near anything real, they stop
51 // a provider that answers its own parent as its child from walking forever
52 private const int DepthLimit = 12;
53 private const int NodeLimit = 4000;
54
55 // two rectangles describe the same control when they agree to the pixel. A point of slack covers a
56 // provider that rounds where the other truncates
57 private const double RectSlack = 2.0;
58
59 private static Guid IID_IAccessible = new Guid("618736E0-3C3D-11CF-810C-00AA00389B71");
60
61 [DllImport("oleacc.dll")]
62 private static extern int AccessibleObjectFromWindow(IntPtr window, uint objectId, ref Guid interfaceId,
63 [MarshalAs(UnmanagedType.Interface)] out object accessible);
64
65 [DllImport("oleacc.dll")]
66 private static extern int AccessibleChildren([MarshalAs(UnmanagedType.Interface)] IAccessible container,
67 int startingChild, int wanted,
68 [Out, MarshalAs(UnmanagedType.LPArray, ArraySubType = UnmanagedType.Struct)] object[] children,
69 out int obtained);
70
79 private class Node
80 {
81 internal IAccessible Owner;
82 internal object ChildId;
83 }
84
91 internal static bool? Selected(Application application, AutomationElement element)
92 {
93 bool? retVal = null;
94 Node node = NodeFor(application, element);
95
96 if (null != node)
97 {
98 int state = StateOf(node);
99
100 retVal = STATE_SYSTEM_SELECTED == (state & STATE_SYSTEM_SELECTED);
101 }
102
103 return retVal;
104 }
105
118 internal static bool Select(Application application, AutomationElement element)
119 {
120 bool retVal = false;
121 Node node = NodeFor(application, element);
122
123 if (null != node)
124 {
125 try
126 {
127 node.Owner.accSelect(SELFLAG_TAKEFOCUS | SELFLAG_TAKESELECTION, node.ChildId);
128
129 retVal = STATE_SYSTEM_SELECTED == (StateOf(node) & STATE_SYSTEM_SELECTED);
130 }
131 catch (Exception)
132 {
133 // a provider is allowed to refuse a selection it publishes a state for, and says so by
134 // throwing. That is an answer, not a breakage: the caller goes on to the mouse, so this is
135 // said on the channel the routes are said on and not as an exception
136 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"MSAA would not select [{NameOf(node)}], so it was not selected that way.", application, GPALObjectType.Application);
137 }
138 }
139
140 return retVal;
141 }
142
155 internal static bool Press(Application application, AutomationElement element)
156 {
157 bool retVal = false;
158 Node node = NodeFor(application, element);
159
160 if (null != node)
161 {
162 try
163 {
164 node.Owner.accDoDefaultAction(node.ChildId);
165
166 retVal = true;
167 }
168 catch (Exception)
169 {
170 // a control is allowed to have no default action, and says so by throwing. That is an
171 // answer, not a breakage: the caller goes on to the mouse
172 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"MSAA has no default action for [{NameOf(node)}], so it was not pressed that way.", application, GPALObjectType.Application);
173 }
174 }
175
176 return retVal;
177 }
178
186 private static Node NodeFor(Application application, AutomationElement element)
187 {
188 Node retVal = null;
189 Rect wanted = Rect.Empty;
190 IntPtr window = IntPtr.Zero;
191
192 // a dialog closes the instant its button is pressed, and a button on a closed dialog answers every
193 // question by throwing. Asking is how this finds out the control has gone, which is not a failure,
194 // it is the answer
195 try
196 {
197 wanted = null == element ? Rect.Empty : element.Current.BoundingRectangle;
198 window = WindowOf(element);
199 }
200 catch (Exception)
201 {
202 wanted = Rect.Empty;
203 }
204
205 // a control with no rectangle is one nothing can be matched on, and a control in no window is one
206 // there is no accessible tree to search
207 if (false == wanted.IsEmpty && IntPtr.Zero != window)
208 {
209 object root;
210
211 if (0 == AccessibleObjectFromWindow(window, OBJID_CLIENT, ref IID_IAccessible, out root) && root is IAccessible accessible)
212 {
213 int looked = 0;
214
215 try
216 {
217 retVal = Search(new Node { Owner = accessible, ChildId = CHILDID_SELF }, wanted, 0, ref looked);
218 }
219 catch (Exception)
220 {
221 // the same again for the tree itself: a window that closes while it is being walked
222 // takes its nodes with it
223 retVal = null;
224 }
225 }
226 }
227
228 GPAL.PublishSimpleEvent(GPALEventType.DEBUG, $"MSAA node at [{wanted}] in window [{window}] was [{(null == retVal ? "not found" : NameOf(retVal))}].", application, GPALObjectType.Application);
229
230 return retVal;
231 }
232
241 private static Node Search(Node node, Rect wanted, int depth, ref int looked)
242 {
243 Node retVal = null;
244
245 if (depth <= DepthLimit && ++looked <= NodeLimit)
246 {
247 if (true == Matches(node, wanted))
248 retVal = node;
249 else
250 {
251 foreach (Node child in ChildrenOf(node))
252 {
253 retVal = Search(child, wanted, depth + 1, ref looked);
254
255 if (null != retVal)
256 break;
257 }
258 }
259 }
260
261 return retVal;
262 }
263
274 private static System.Collections.Generic.List<Node> ChildrenOf(Node node)
275 {
276 var retVal = new System.Collections.Generic.List<Node>();
277
278 // only an object of its own has children. A numbered child is a leaf by definition, and asking the
279 // owner for its children again from inside one is how a walk goes round in circles
280 if (true == IsSelf(node.ChildId))
281 {
282 int count = 0;
283
284 try
285 {
286 count = node.Owner.accChildCount;
287 }
288 catch (Exception)
289 {
290 // a provider that will not count its children has none as far as this is concerned
291 count = 0;
292 }
293
294 if (0 < count)
295 {
296 object[] children = new object[count];
297 int obtained;
298
299 if (0 == AccessibleChildren(node.Owner, 0, count, children, out obtained))
300 for (int child = 0; child < obtained; child++)
301 if (children[child] is IAccessible ownObject)
302 retVal.Add(new Node { Owner = ownObject, ChildId = CHILDID_SELF });
303 else if (null != children[child])
304 retVal.Add(new Node { Owner = node.Owner, ChildId = children[child] });
305 }
306 }
307
308 return retVal;
309 }
310
315 private static bool Matches(Node node, Rect wanted)
316 {
317 bool retVal = false;
318 int left, top, width, height;
319
320 try
321 {
322 node.Owner.accLocation(out left, out top, out width, out height, node.ChildId);
323
324 retVal = RectSlack > Math.Abs(left - wanted.Left)
325 && RectSlack > Math.Abs(top - wanted.Top)
326 && RectSlack > Math.Abs(width - wanted.Width)
327 && RectSlack > Math.Abs(height - wanted.Height);
328 }
329 catch (Exception)
330 {
331 // MSAA answers "I do not implement that" by failing the call, which the interop raises. A node
332 // that will not give its location is a node that cannot be the one being looked for
333 retVal = false;
334 }
335
336 return retVal;
337 }
338
342 private static int StateOf(Node node)
343 {
344 int retVal = 0;
345
346 try
347 {
348 object state = node.Owner.get_accState(node.ChildId);
349
350 if (state is int bits)
351 retVal = bits;
352 }
353 catch (Exception)
354 {
355 retVal = 0;
356 }
357
358 return retVal;
359 }
360
364 private static string NameOf(Node node)
365 {
366 string retVal = "unnamed";
367
368 try
369 {
370 string name = node.Owner.get_accName(node.ChildId);
371
372 if (false == string.IsNullOrWhiteSpace(name))
373 retVal = name;
374 }
375 catch (Exception)
376 {
377 retVal = "unnamed";
378 }
379
380 return retVal;
381 }
382
386 private static bool IsSelf(object childId)
387 {
388 return childId is int id && CHILDID_SELF == id;
389 }
390
401 private static IntPtr WindowOf(AutomationElement element)
402 {
403 IntPtr retVal = IntPtr.Zero;
404 AutomationElement current = element;
405
406 for (int level = 0; level <= DepthLimit && IntPtr.Zero == retVal && null != current; level++)
407 {
408 if (0 != current.Current.NativeWindowHandle)
409 retVal = new IntPtr(current.Current.NativeWindowHandle);
410 else
411 current = TreeWalker.ControlViewWalker.GetParent(current);
412 }
413
414 return retVal;
415 }
416 }
417}