GPAL - Generally Positive Automation Library v1.0
GPAL The Fluent Automation LIbrary
Loading...
Searching...
No Matches
FileDialogHelper.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 System;
18using System.Collections.Generic;
19using System.Diagnostics;
20using System.Runtime.InteropServices;
21using System.Threading;
22using System.Windows.Automation;
23using static GenerallyPositive.Enums;
25
27{
43 internal static class FileDialogHelper
44 {
45 // the file name field and the accept button. 1148 is the id the field usually carries, on itself or on
46 // the combo box hosting it, and 1152 is the one it carries in some Save dialogs. None of them is
47 // relied on alone
48 private const string FILE_NAME_ID = "1148";
49 private const string FILE_NAME_ALTERNATE_ID = "1152";
50 private const string FILE_NAME_HOST_ID = "FileNameControlHost";
51 private const string ACCEPT_ID = "1";
52
53 // how many of a window's buttons the "what is this dialog" message lists before it stops
54 private const int BUTTONS_LISTED = 8;
55
56 // the Win32 class every dialog button is, whatever UIAutomation decides to call the control
57 private const string BUTTON_CLASS = "Button";
58
59 // IDCANCEL, the id a common dialog's Cancel button carries the way accept carries 1
60 private const string CANCEL_ID = "2";
61
67 [DllImport("user32.dll")]
68 private static extern bool IsWindow(IntPtr hWnd);
69
70 private const int BETWEEN_LOOKS = 250;
71
72 // how long a dialog is given to go away once it has been accepted. A dialog that took what it was
73 // given closes at once, so this is only ever spent on one that did not, and spending ten seconds
74 // deciding that is ten seconds of a workflow looking like it has hung
75 private const int SETTLING_MS = 5000;
76
77 // how often to ask whether the dialog has closed, which is one cheap call to Windows
78 private const int BETWEEN_CLOSING_LOOKS = 50;
79
80
89 internal static bool Answer(Application application, GPALFile file, int waitSeconds)
90 {
91 bool retVal = false;
92
93 AutomationElement dialog = WaitForDialog(application, waitSeconds);
94
95 if (null == dialog)
96 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"No file dialog appeared within [{waitSeconds}]s, so there was nothing to answer. {WindowsSeen(application)}", application, GPALObjectType.Application);
97 else if (null == file || 0 == file.Count)
98 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "A file dialog appeared but no file was named to answer it with.", application, GPALObjectType.Application);
99 else
100 {
101 AutomationElement field = FileNameField(dialog);
102
103 if (null != field)
104 retVal = AnswerByField(application, dialog, field, file);
105 else
106 retVal = AnswerByTree(application, dialog, file);
107 }
108
109 // a dialog that was not answered is still on screen and still modal, so everything the workflow
110 // does next goes into it: a click lands on whatever the dialog has at those coordinates and a
111 // keystroke goes to its file name field. Cancelling leaves the application usable and leaves the
112 // failure as just a failure
113 if (false == retVal && null != dialog)
114 Cancel(application, dialog);
115
116 return retVal;
117 }
118
133 private static AutomationElement WaitForDialog(Application application, int waitSeconds)
134 {
135 AutomationElement retVal = null;
136 Stopwatch waited = Stopwatch.StartNew();
137
138 while (null == retVal && waited.ElapsedMilliseconds < waitSeconds * 1000)
139 {
140 retVal = FirstWindowWhere(application, LooksLikeADialog);
141
142 if (null == retVal)
143 Thread.Sleep(BETWEEN_LOOKS);
144 }
145
146 return retVal;
147 }
148
160 private static AutomationElement FirstWindowWhere(Application application, Func<AutomationElement, bool> matches)
161 {
162 AutomationElement retVal = null;
163 var isWindow = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window);
164
165 if (null != application?.RootAutomationElement)
166 {
167 retVal = FirstWindowUnder(application.RootAutomationElement, TreeScope.Children, isWindow, matches);
168
169 if (null == retVal)
170 retVal = FirstWindowUnder(application.RootAutomationElement, TreeScope.Descendants, isWindow, matches);
171 }
172
173 if (null == retVal)
174 foreach (AutomationElement top in TopLevelWindows(application))
175 if (true == matches(top))
176 {
177 retVal = top;
178 break;
179 }
180
181 return retVal;
182 }
183
194 private static List<AutomationElement> TopLevelWindows(Application application)
195 {
196 List<AutomationElement> retVal = new List<AutomationElement>();
197
198 if (false == application.applicationSettings.HiddenDesktop)
199 {
200 var isWindow = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window);
201
202 foreach (AutomationElement window in AutomationElement.RootElement.FindAll(TreeScope.Children, isWindow))
203 retVal.Add(window);
204 }
205 else
206 foreach (IntPtr handle in Desktops.WindowsOn(application.applicationSettings.HiddenDesktopName))
207 {
208 AutomationElement onDesktop = AutomationElement.FromHandle(handle);
209
210 if (null != onDesktop)
211 retVal.Add(onDesktop);
212 }
213
214 return retVal;
215 }
216
225 private static AutomationElement FirstWindowUnder(AutomationElement under, TreeScope scope, Condition isWindow, Func<AutomationElement, bool> matches)
226 {
227 AutomationElement retVal = null;
228
229 try
230 {
231 foreach (AutomationElement window in under.FindAll(scope, isWindow))
232 if (null == retVal && true == matches(window))
233 retVal = window;
234 }
235 catch (Exception)
236 {
237 // a window that closed while the tree was being read is not the one being waited for
238 }
239
240 return retVal;
241 }
242
255 private static Condition AcceptCondition()
256 {
257 return ButtonCondition(ACCEPT_ID);
258 }
259
263 private static Condition ButtonCondition(string id)
264 {
265 return new AndCondition(
266 new PropertyCondition(AutomationElement.AutomationIdProperty, id),
267 new OrCondition(
268 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
269 new PropertyCondition(AutomationElement.ClassNameProperty, BUTTON_CLASS)));
270 }
271
284 private static AutomationElement AcceptButton(AutomationElement window)
285 {
286 return ButtonWithId(window, ACCEPT_ID);
287 }
288
295 private static AutomationElement ButtonWithId(AutomationElement window, string id)
296 {
297 AutomationElement retVal = window.FindFirst(TreeScope.Descendants, ButtonCondition(id));
298
299 if (null == retVal)
300 {
301 System.Collections.Generic.List<AutomationElement> raw = ElementHelper.RawMatches(window, ButtonCondition(id), false);
302
303 retVal = 0 < raw.Count ? raw[0] : null;
304 }
305
306 return retVal;
307 }
308
319 private static bool LooksLikeADialog(AutomationElement window)
320 {
321 bool retVal = false;
322
323 AutomationElement accept = AcceptButton(window);
324
325 if (null != accept)
326 retVal = null != FileNameField(window)
327 || null != window.FindFirst(TreeScope.Descendants, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Tree));
328
329 return retVal;
330 }
331
341 private static string WindowsSeen(Application application)
342 {
343 var seen = new List<string>();
344
345 if (null != application?.RootAutomationElement)
346 {
347 Describe(seen, "under the application", application.RootAutomationElement, TreeScope.Children);
348 Describe(seen, "deeper in the application", application.RootAutomationElement, TreeScope.Descendants);
349 }
350
351 foreach (AutomationElement top in TopLevelWindows(application))
352 Describe(seen, true == application.applicationSettings.HiddenDesktop
353 ? $"on [{application.applicationSettings.HiddenDesktopName}]"
354 : "on the desktop", top, TreeScope.Element);
355
356 return 0 == seen.Count ? "No windows were found in any of the places a dialog is looked for." : $"Windows looked at: {string.Join(" | ", seen)}";
357 }
358
366 private static void Describe(List<string> seen, string where, AutomationElement under, TreeScope scope)
367 {
368 try
369 {
370 // a window is asked about itself when the scope is the element, because a top level window on a
371 // hidden desktop does not always call itself a Window and a diagnostic that lists nothing is
372 // worse than one that lists something unexpected
373 AutomationElementCollection windows = TreeScope.Element == scope
374 ? under.FindAll(scope, Condition.TrueCondition)
375 : under.FindAll(scope, new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
376
377 foreach (AutomationElement window in windows)
378 {
379 bool accept = null != AcceptButton(window);
380
381 int edits = window.FindAll(TreeScope.Descendants,
382 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit)).Count;
383
384 bool named = null != window.FindFirst(TreeScope.Descendants, new AndCondition(
385 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit),
386 new PropertyCondition(AutomationElement.AutomationIdProperty, FILE_NAME_ID)));
387
388 bool tree = null != window.FindFirst(TreeScope.Descendants,
389 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Tree));
390
391 seen.Add($"{where}: [{window.Current.ClassName}] '{window.Current.Name}' accept button {(true == accept ? "yes" : $"NO, its buttons are {ButtonsIn(window)}")}, field {FILE_NAME_ID} {(true == named ? "yes" : "NO")}, edits [{edits}], tree {(true == tree ? "yes" : "no")}");
392 }
393 }
394 catch (Exception)
395 {
396 // a window that closed while the list was being read is not the one being waited for
397 }
398 }
399
412 private static string ButtonsIn(AutomationElement window)
413 {
414 var named = new System.Collections.Generic.List<string>();
415
416 AutomationElementCollection buttons = window.FindAll(TreeScope.Descendants,
417 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button));
418
419 for (int button = 0; button < buttons.Count && named.Count < BUTTONS_LISTED; button++)
420 named.Add($"[{buttons[button].Current.AutomationId}] '{buttons[button].Current.Name}'");
421
422 if (BUTTONS_LISTED <= named.Count && BUTTONS_LISTED < buttons.Count)
423 named.Add($"and [{buttons.Count - BUTTONS_LISTED}] more");
424
425 return 0 == named.Count ? "none in the control view" : string.Join(" ", named);
426 }
427
442 private static AutomationElement FileNameField(AutomationElement dialog)
443 {
444 var isEdit = new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Edit);
445
446 // by its id, directly
447 AutomationElement retVal = dialog.FindFirst(TreeScope.Descendants, new AndCondition(isEdit,
448 new PropertyCondition(AutomationElement.AutomationIdProperty, FILE_NAME_ID)));
449
450 // by its id, on the combo box that hosts it
451 if (null == retVal)
452 {
453 AutomationElement host = dialog.FindFirst(TreeScope.Descendants, new AndCondition(
454 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.ComboBox),
455 new OrCondition(
456 new PropertyCondition(AutomationElement.AutomationIdProperty, FILE_NAME_ID),
457 new PropertyCondition(AutomationElement.AutomationIdProperty, FILE_NAME_HOST_ID))));
458
459 if (null != host)
460 retVal = host.FindFirst(TreeScope.Descendants, isEdit);
461 }
462
463 // the other id the field has answered to
464 if (null == retVal)
465 retVal = dialog.FindFirst(TreeScope.Descendants, new AndCondition(isEdit,
466 new PropertyCondition(AutomationElement.AutomationIdProperty, FILE_NAME_ALTERNATE_ID)));
467
468 // and by where it sits, which no version or language changes
469 if (null == retVal)
470 retVal = FieldBesideTheButton(dialog, isEdit);
471
472 return retVal;
473 }
474
482 private static AutomationElement FieldBesideTheButton(AutomationElement dialog, Condition isEdit)
483 {
484 AutomationElement retVal = null;
485
486 AutomationElement accept = dialog.FindFirst(TreeScope.Descendants, new AndCondition(
487 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Button),
488 new PropertyCondition(AutomationElement.AutomationIdProperty, ACCEPT_ID)));
489
490 if (null != accept)
491 {
492 System.Windows.Rect button = accept.Current.BoundingRectangle;
493 double nearest = double.MaxValue;
494
495 foreach (AutomationElement edit in dialog.FindAll(TreeScope.Descendants, isEdit))
496 {
497 // it has to be writable, on screen, and to the left of the button rather than above or
498 // below it. The address bar and the search box are on a different line and lose on that
499 if (false == edit.Current.IsOffscreen
500 && true == edit.Current.IsEnabled
501 && true == edit.TryGetCurrentPattern(ValuePattern.Pattern, out object unused))
502 {
503 System.Windows.Rect field = edit.Current.BoundingRectangle;
504 double apart = Math.Abs((field.Top + field.Height / 2) - (button.Top + button.Height / 2));
505
506 if (field.Left < button.Left && apart < button.Height && apart < nearest)
507 {
508 nearest = apart;
509 retVal = edit;
510 }
511 }
512 }
513 }
514
515 return retVal;
516 }
517
526 private static bool AnswerByField(Application application, AutomationElement dialog, AutomationElement field, GPALFile file)
527 {
528 bool retVal = false;
529
530 // one file goes in as it is. Several are quoted and separated by spaces, which is the form a dialog
531 // set to allow more than one reads, and quoting a single path with a space in it changes nothing
532 var names = new List<string>();
533
534 foreach (string name in file.Filenames)
535 names.Add(1 == file.Count ? name : $"\"{name}\"");
536
537 string answer = string.Join(" ", names);
538
539 if (true == field.TryGetCurrentPattern(ValuePattern.Pattern, out object valuePattern))
540 {
541 ((ValuePattern)valuePattern).SetValue(answer);
542
543 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Answered the file dialog with [{answer}]", application, GPALObjectType.Application);
544
545 retVal = Accept(application, dialog);
546 }
547 else
548 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "The file dialog's file name field cannot be written to, so the path could not be put in it.", application, GPALObjectType.Application);
549
550 return retVal;
551 }
552
565 private static bool AnswerByTree(Application application, AutomationElement dialog, GPALFile file)
566 {
567 bool retVal = false;
568
569 AutomationElement tree = dialog.FindFirst(TreeScope.Descendants,
570 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Tree));
571
572 if (null == tree)
573 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The dialog has neither a file name field nor a folder tree, so it is not a common file dialog this can answer. {ElementHelper.WhatIsThere(dialog)}", application, GPALObjectType.Application);
574 else
575 {
576 AutomationElement current = tree;
577 string reached = string.Empty;
578
579 foreach (string segment in file.Filename.Split(new char[] { '\\' }, StringSplitOptions.RemoveEmptyEntries))
580 {
581 AutomationElement next = ChildNamed(current, segment);
582
583 if (null == next)
584 {
585 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The folder tree has no [{segment}] under [{(true == string.IsNullOrEmpty(reached) ? "the root" : reached)}], so [{file.Filename}] cannot be reached. {ElementHelper.WhatIsThere(current)}", application, GPALObjectType.Application);
586 current = null;
587 break;
588 }
589
590 Expand(next);
591
592 current = next;
593 reached = segment;
594 }
595
596 if (null != current)
597 {
598 Select(current);
599
600 GPAL.PublishSimpleEvent(GPALEventType.INFO, $"Walked the folder tree to [{file.Filename}]", application, GPALObjectType.Application);
601
602 retVal = Accept(application, dialog);
603 }
604 }
605
606 return retVal;
607 }
608
616 private static AutomationElement ChildNamed(AutomationElement parent, string segment)
617 {
618 AutomationElement retVal = parent.FindFirst(TreeScope.Children, new AndCondition(
619 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TreeItem),
620 new PropertyCondition(AutomationElement.NameProperty, segment)));
621
622 if (null == retVal)
623 foreach (AutomationElement child in parent.FindAll(TreeScope.Children,
624 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.TreeItem)))
625 {
626 string name = child.Current.Name ?? string.Empty;
627
628 if (null == retVal && (true == name.EndsWith($"({segment})") || true == name.StartsWith($"{segment} ", StringComparison.OrdinalIgnoreCase)))
629 retVal = child;
630 }
631
632 return retVal;
633 }
634
639 private static void Expand(AutomationElement item)
640 {
641 if (true == item.TryGetCurrentPattern(ExpandCollapsePattern.Pattern, out object pattern)
642 && ExpandCollapseState.Collapsed == ((ExpandCollapsePattern)pattern).Current.ExpandCollapseState)
643 ((ExpandCollapsePattern)pattern).Expand();
644 }
645
650 private static void Select(AutomationElement item)
651 {
652 if (true == item.TryGetCurrentPattern(SelectionItemPattern.Pattern, out object pattern))
653 ((SelectionItemPattern)pattern).Select();
654 }
655
666 private static bool Accept(Application application, AutomationElement dialog)
667 {
668 bool retVal = PressAndWait(application, dialog, AcceptButton(dialog), "accept");
669
670 return retVal;
671 }
672
683 private static void Cancel(Application application, AutomationElement dialog)
684 {
685 AutomationElement button = ButtonWithId(dialog, CANCEL_ID);
686
687 if (true == PressAndWait(application, dialog, button, "cancel"))
688 GPAL.PublishSimpleEvent(GPALEventType.WARNING, "The file dialog could not be answered, so it was cancelled. The application is where it was before the dialog was raised.", application, GPALObjectType.Application);
689 else
690 GPAL.PublishSimpleEvent(GPALEventType.ERROR, "The file dialog could not be answered and could not be cancelled either, so it is still on screen and everything after this goes into it.", application, GPALObjectType.Application);
691 }
692
706 private static bool PressAndWait(Application application, AutomationElement dialog, AutomationElement button, string what)
707 {
708 bool retVal = false;
709 bool pressed = false;
710
711 // taken while the dialog is still there. Once it closes, asking it anything is asking a proxy for a
712 // window that has gone
713 IntPtr handle = new IntPtr(dialog.Current.NativeWindowHandle);
714
715 if (null == button)
716 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The file dialog has no {what} button. {ElementHelper.WhatIsThere(dialog)}", application, GPALObjectType.Application);
717 else if (true == button.TryGetCurrentPattern(InvokePattern.Pattern, out object pattern))
718 {
719 ((InvokePattern)pattern).Invoke();
720
721 pressed = true;
722 }
723 else if (true == MsaaHelper.Press(application, button))
724 pressed = true;
725 else if (true == ElementHelper.HardwareReaches(application))
726 {
727 ElementHelper.HardwareClick(application, new GPALAutomationElement(button), ClickType.LeftClick, ModifierKeys.NONE);
728
729 pressed = true;
730 }
731 else
732 GPAL.PublishSimpleEvent(GPALEventType.ERROR, $"The file dialog's {what} button cannot be pressed. It offers no InvokePattern, MSAA would not press it, and the mouse cannot reach it here.", application, GPALObjectType.Application);
733
734 if (true == pressed)
735 {
736 retVal = Settled(handle);
737
738 if (false == retVal)
739 GPAL.PublishSimpleEvent(GPALEventType.WARNING, $"The file dialog's {what} button was pressed but the dialog is still on screen after [{SETTLING_MS / 1000}]s.", application, GPALObjectType.Application);
740 }
741
742 return retVal;
743 }
744
750 private static bool Settled(IntPtr handle)
751 {
752 bool retVal = false;
753 Stopwatch waited = Stopwatch.StartNew();
754
755 while (false == retVal && waited.ElapsedMilliseconds < SETTLING_MS)
756 {
757 retVal = IntPtr.Zero == handle || false == IsWindow(handle);
758
759 if (false == retVal)
760 Thread.Sleep(BETWEEN_CLOSING_LOOKS);
761 }
762
763 return retVal;
764 }
765
766 }
767}