Home

dev / openkara

publicthedavidweng/OpenKara· sync paused
Overview Code History Branches Pull requestsIssuesInsights
main
HomeOverview Code PRsIssues
dev/openkara/tools/windows-accessibility/OpenKara.AccessibilityProbe/Program.cs
Raw
1using System;2using System.Collections.Generic;3using System.Diagnostics;4using System.IO;5using System.Linq;6using System.Runtime.InteropServices;7using System.Text.Encodings.Web;8using System.Text.Json;9using System.Windows.Automation;1011namespace OpenKara.AccessibilityProbe;12
13
class Program
14{
15 [DllImport("user32.dll")]
16 private static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
17
18 [DllImport("user32.dll")]
19 [return: MarshalAs(UnmanagedType.Bool)]
20 private static extern bool SetForegroundWindow(IntPtr hWnd);
21
22 [DllImport("user32.dll")]
23 [return: MarshalAs(UnmanagedType.Bool)]
24 private static extern bool SetCursorPos(int X, int Y);
25
26 [DllImport("user32.dll")]
27 private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
28
29 private const int INPUT_KEYBOARD = 1;
30 private const int INPUT_MOUSE = 0;
31 private const uint KEYEVENTF_KEYUP = 0x0002;
32 private const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
33 private const uint MOUSEEVENTF_LEFTUP = 0x0004;
34 private const uint MOUSEEVENTF_ABSOLUTE = 0x8000;
35 private const uint MOUSEEVENTF_MOVE = 0x0001;
36
37 [StructLayout(LayoutKind.Sequential)]
38 private struct INPUT
39 {
40 public int type;
41 public InputUnion U;
42 }
43
44 [StructLayout(LayoutKind.Explicit)]
45 private struct InputUnion
46 {
47 [FieldOffset(0)] public KEYBDINPUT ki;
48 }
49
50 [StructLayout(LayoutKind.Sequential)]
51 private struct KEYBDINPUT
52 {
53 public ushort wVk;
54 public ushort wScan;
55 public uint dwFlags;
56 public uint time;
57 public IntPtr dwExtraInfo;
58 }
59
60 [STAThread]
61 static int Main(string[] args)
62 {
63 int? processId = null;
64 string? processName = null;
65 string? outputPath = null;
66 string? windowTitle = null;
67 string action = "snapshot";
68 string? targetName = null;
69 string? controlTypeFilter = null;
70 string? valueText = null;
71 string? keyName = null;
72 int timeoutMs = 0;
73
74 for (int i = 0; i < args.Length; i++)
75 {
76 var arg = args[i];
77 if (arg == "--process-id" && i + 1 < args.Length)
78 {
79 if (!int.TryParse(args[++i], out int id))
80 {
81 Console.Error.WriteLine("Invalid process ID.");
82 return 1;
83 }
84 processId = id;
85 }
86 else if (arg == "--process-name" && i + 1 < args.Length)
87 {
88 processName = args[++i];
89 }
90 else if (arg == "--output" && i + 1 < args.Length)
91 {
92 outputPath = args[++i];
93 }
94 else if (arg == "--window-title" && i + 1 < args.Length)
95 {
96 windowTitle = args[++i];
97 }
98 else if (arg == "--timeout" && i + 1 < args.Length)
99 {
100 if (!int.TryParse(args[++i], out int timeout))
101 {
102 Console.Error.WriteLine("Invalid timeout value.");
103 return 1;
104 }
105 timeoutMs = timeout;
106 }
107 else if (arg == "--action" && i + 1 < args.Length)
108 {
109 action = args[++i].Trim().ToLowerInvariant();
110 }
111 else if (arg == "--name" && i + 1 < args.Length)
112 {
113 targetName = args[++i];
114 }
115 else if (arg == "--control-type" && i + 1 < args.Length)
116 {
117 controlTypeFilter = args[++i];
118 }
119 else if (arg == "--value" && i + 1 < args.Length)
120 {
121 valueText = args[++i];
122 }
123 else if (arg == "--key" && i + 1 < args.Length)
124 {
125 keyName = args[++i];
126 }
127 else
128 {
129 Console.Error.WriteLine($"Unknown or incomplete argument: {arg}");
130 Console.Error.WriteLine(
131 "Usage: OpenKara.AccessibilityProbe --process-id <id> | --process-name <name> " +
132 "[--output <path>] [--timeout <ms>] [--window-title <title>] " +
133 "[--action snapshot|set-focus|invoke|toggle|set-value|press-key|click|double-click] [--name <substring>] " +
134 "[--control-type <type>] [--value <text>] [--key <name>]");
135 return 1;
136 }
137 }
138
139 if (processId is null && string.IsNullOrWhiteSpace(processName) &&
140 string.IsNullOrWhiteSpace(windowTitle))
141 {
142 Console.Error.WriteLine("Specify --process-id, --process-name, or --window-title.");
143 return 1;
144 }
145
146 if (action is not ("snapshot" or "set-focus" or "invoke" or "toggle" or "set-value" or "press-key" or "click" or "double-click"))
147 {
148 Console.Error.WriteLine($"Unsupported action: {action}");
149 return 1;
150 }
151
152 if (action is "set-focus" or "invoke" or "toggle" or "set-value" or "click" or "double-click")
153 {
154 if (string.IsNullOrWhiteSpace(targetName))
155 {
156 Console.Error.WriteLine($"Action '{action}' requires --name <substring>.");
157 return 1;
158 }
159 }
160
161 if (action == "set-value" && valueText is null)
162 {
163 Console.Error.WriteLine("Action 'set-value' requires --value <text>.");
164 return 1;
165 }
166
167 if (action == "press-key" && string.IsNullOrWhiteSpace(keyName))
168 {
169 Console.Error.WriteLine("Action 'press-key' requires --key <name> (enter|escape|space|tab|m|q|f|...).");
170 return 1;
171 }
172
173 if (processId is null && !string.IsNullOrWhiteSpace(processName))
174 {
175 var matched = Process.GetProcessesByName(processName!);
176 if (matched.Length == 0)
177 {
178 Console.Error.WriteLine($"Process not found: {processName}");
179 return 1;
180 }
181 processId = matched[0].Id;
182 foreach (var process in matched)
183 {
184 process.Dispose();
185 }
186 }
187
188 // System file pickers may live outside the app process. Allow title-only lookup.
189 var root = FindWindow(processId, timeoutMs, windowTitle);
190 if (root is null)
191 {
192 Console.Error.WriteLine("Top-level window not found.");
193 return 1;
194 }
195
196 if (action is "set-focus" or "invoke" or "toggle" or "set-value" or "press-key" or "click" or "double-click")
197 {
198 AutomationElement? target = null;
199 if (!string.IsNullOrWhiteSpace(targetName))
200 {
201 target = FindNamedElement(root, targetName!, controlTypeFilter);
202 if (target is null)
203 {
204 Console.Error.WriteLine(
205 $"No matching element for name '{targetName}'" +
206 (string.IsNullOrWhiteSpace(controlTypeFilter) ? "" : $" control-type '{controlTypeFilter}'") +
207 ".");
208 return 2;
209 }
210 }
211 else if (action == "press-key")
212 {
213 // Global app shortcuts: focus the window root before injecting keys.
214 target = root;
215 }
216
217 try
218 {
219 if (action == "set-focus")
220 {
221 target!.SetFocus();
222 Console.WriteLine($"set-focus ok: {Describe(target)}");
223 }
224 else if (action == "invoke")
225 {
226 if (!target!.TryGetCurrentPattern(InvokePattern.Pattern, out object? pattern) || pattern is null)
227 {
228 Console.Error.WriteLine($"Element does not support Invoke: {Describe(target)}");
229 return 3;
230 }
231 ((InvokePattern)pattern).Invoke();
232 Console.WriteLine($"invoke ok: {Describe(target)}");
233 }
234 else if (action == "toggle")
235 {
236 if (!target!.TryGetCurrentPattern(TogglePattern.Pattern, out object? pattern) || pattern is null)
237 {
238 Console.Error.WriteLine($"Element does not support Toggle: {Describe(target)}");
239 return 3;
240 }
241 ((TogglePattern)pattern).Toggle();
242 Console.WriteLine($"toggle ok: {Describe(target)}");
243 }
244 else if (action is "click" or "double-click")
245 {
246 if (!TryMouseClick(root, target!, doubleClick: action == "double-click", out string? clickError))
247 {
248 Console.Error.WriteLine(clickError ?? $"Mouse {action} failed");
249 return 3;
250 }
251 Console.WriteLine($"{action} ok: {Describe(target!)}");
252 }
253 else if (action == "press-key")
254 {
255 if (target is not null)
256 {
257 try
258 {
259 // Bring the hosting top-level window forward so SendInput
260 // lands in the WebView, then set UIA focus on the control.
261 try
262 {
263 var hwnd = new IntPtr(root.Current.NativeWindowHandle);
264 if (hwnd != IntPtr.Zero)
265 {
266 SetForegroundWindow(hwnd);
267 }
268 }
269 catch (ElementNotAvailableException)
270 {
271 }
272
273 target.SetFocus();
274 System.Threading.Thread.Sleep(80);
275 }
276 catch (Exception ex)
277 {
278 Console.Error.WriteLine($"press-key focus warning: {ex.Message}");
279 }
280 }
281 else
282 {
283 try
284 {
285 var hwnd = new IntPtr(root.Current.NativeWindowHandle);
286 if (hwnd != IntPtr.Zero)
287 {
288 SetForegroundWindow(hwnd);
289 System.Threading.Thread.Sleep(50);
290 }
291 }
292 catch (ElementNotAvailableException)
293 {
294 }
295 }
296
297 if (!TrySendKeyCombo(keyName!, out string? sendError))
298 {
299 Console.Error.WriteLine(sendError ?? $"Unknown key combo: {keyName}");
300 return 3;
301 }
302 Console.WriteLine(
303 $"press-key ok: key='{keyName}'" +
304 (target is null ? "" : $" on {Describe(target)}"));
305 }
306 else
307 {
308 if (!target!.TryGetCurrentPattern(ValuePattern.Pattern, out object? pattern) || pattern is null)
309 {
310 Console.Error.WriteLine($"Element does not support Value: {Describe(target)}");
311 return 3;
312 }
313 ((ValuePattern)pattern).SetValue(valueText!);
314 Console.WriteLine($"set-value ok: {Describe(target)} value='{valueText}'");
315 }
316 }
317 catch (Exception ex)
318 {
319 Console.Error.WriteLine($"Action '{action}' failed: {ex.Message}");
320 return 4;
321 }
322
323 // Optional snapshot after the action for diagnostics.
324 if (string.IsNullOrWhiteSpace(outputPath))
325 {
326 return 0;
327 }
328 }
329
330 var nodes = new List<Node>();
331 Walk(root, string.Empty, string.Empty, 0, nodes);
332
333 var sorted = nodes
334 .OrderBy(n => n.Path, StringComparer.Ordinal)
335 .ToList();
336
337 string json = JsonSerializer.Serialize(sorted, new JsonSerializerOptions
338 {
339 WriteIndented = true,
340 Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
341 PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
342 });
343
344 if (!string.IsNullOrWhiteSpace(outputPath))
345 {
346 var directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
347 if (!string.IsNullOrEmpty(directory))
348 {
349 Directory.CreateDirectory(directory);
350 }
351 File.WriteAllText(outputPath, json);
352 }
353 else if (action == "snapshot")
354 {
355 Console.WriteLine(json);
356 }
357
358 return 0;
359 }
360
361 private static string Describe(AutomationElement element)
362 {
363 var current = TryGetCurrent(element);
364 return $"{GetControlTypeName(current.ControlType)} name='{current.Name}'";
365 }
366
367 private static AutomationElement? FindNamedElement(
368 AutomationElement root,
369 string nameSubstring,
370 string? controlTypeFilter)
371 {
372 var matches = new List<(AutomationElement Element, int Score)>();
373 CollectNamedMatches(root, nameSubstring, controlTypeFilter, matches);
374 if (matches.Count == 0)
375 {
376 return null;
377 }
378
379 // Prefer exact (case-insensitive) name, then shortest name, then first.
380 return matches
381 .OrderBy(m => m.Score)
382 .ThenBy(m =>
383 {
384 try { return (m.Element.Current.Name ?? string.Empty).Length; }
385 catch { return int.MaxValue; }
386 })
387 .Select(m => m.Element)
388 .First();
389 }
390
391 private static void CollectNamedMatches(
392 AutomationElement? element,
393 string nameSubstring,
394 string? controlTypeFilter,
395 List<(AutomationElement Element, int Score)> matches)
396 {
397 if (element is null || IsStale(element))
398 {
399 return;
400 }
401
402 var current = TryGetCurrent(element);
403 string name = current.Name ?? string.Empty;
404 string controlType = GetControlTypeName(current.ControlType);
405
406 if (!string.IsNullOrWhiteSpace(name) &&
407 name.IndexOf(nameSubstring, StringComparison.OrdinalIgnoreCase) >= 0 &&
408 (string.IsNullOrWhiteSpace(controlTypeFilter) ||
409 controlType.Equals(controlTypeFilter, StringComparison.OrdinalIgnoreCase)) &&
410 current.IsOffscreen == false)
411 {
412 int score = name.Equals(nameSubstring, StringComparison.OrdinalIgnoreCase) ? 0 : 1;
413 // Prefer keyboard-focusable interactive controls when set-focusing.
414 if (!current.IsKeyboardFocusable)
415 {
416 score += 10;
417 }
418 matches.Add((element, score));
419 }
420
421 AutomationElementCollection? children = null;
422 try
423 {
424 children = element.FindAll(TreeScope.Children, Condition.TrueCondition);
425 }
426 catch (ElementNotAvailableException)
427 {
428 }
429
430 if (children is null)
431 {
432 return;
433 }
434
435 for (int i = 0; i < children.Count; i++)
436 {
437 CollectNamedMatches(children[i], nameSubstring, controlTypeFilter, matches);
438 }
439 }
440
441 private static AutomationElement? FindWindow(int? processId, int timeoutMs, string? windowTitle = null)
442 {
443 var deadline = DateTime.UtcNow.AddMilliseconds(Math.Max(timeoutMs, 0));
444 do
445 {
446 try
447 {
448 var window = FindWindowOnce(processId, windowTitle);
449 if (window is not null)
450 {
451 return window;
452 }
453 }
454 catch (ElementNotAvailableException)
455 {
456 }
457
458 if (timeoutMs > 0)
459 {
460 System.Threading.Thread.Sleep(100);
461 }
462 } while (DateTime.UtcNow < deadline);
463
464 return null;
465 }
466
467 private static AutomationElement? FindWindowOnce(int? processId, string? windowTitle)
468 {
469 // Prefer exact process match when provided.
470 if (processId is int pid && pid > 0)
471 {
472 Condition condition;
473 if (string.IsNullOrWhiteSpace(windowTitle))
474 {
475 condition = new AndCondition(
476 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window),
477 new PropertyCondition(AutomationElement.ProcessIdProperty, pid));
478 }
479 else
480 {
481 condition = new AndCondition(
482 new AndCondition(
483 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window),
484 new PropertyCondition(AutomationElement.ProcessIdProperty, pid)),
485 new PropertyCondition(AutomationElement.NameProperty, windowTitle));
486 }
487
488 var match = AutomationElement.RootElement.FindFirst(TreeScope.Children, condition);
489 if (match is not null)
490 {
491 return match;
492 }
493
494 // Title may be localized or partial; scan children by substring.
495 if (!string.IsNullOrWhiteSpace(windowTitle))
496 {
497 match = FindWindowByTitleSubstring(pid, windowTitle);
498 if (match is not null)
499 {
500 return match;
501 }
502 }
503 }
504
505 // Cross-process title lookup (Windows common file dialog host).
506 if (!string.IsNullOrWhiteSpace(windowTitle))
507 {
508 return FindWindowByTitleSubstring(null, windowTitle);
509 }
510
511 return null;
512 }
513
514 private static AutomationElement? FindWindowByTitleSubstring(int? processId, string windowTitle)
515 {
516 var windows = AutomationElement.RootElement.FindAll(
517 TreeScope.Children,
518 new PropertyCondition(AutomationElement.ControlTypeProperty, ControlType.Window));
519
520 for (int i = 0; i < windows.Count; i++)
521 {
522 var window = windows[i];
523 if (window is null || IsStale(window))
524 {
525 continue;
526 }
527
528 try
529 {
530 var current = window.Current;
531 if (processId is int pid && pid > 0 && current.ProcessId != pid)
532 {
533 continue;
534 }
535
536 string name = current.Name ?? string.Empty;
537 if (name.Equals(windowTitle, StringComparison.OrdinalIgnoreCase) ||
538 name.IndexOf(windowTitle, StringComparison.OrdinalIgnoreCase) >= 0)
539 {
540 return window;
541 }
542 }
543 catch (ElementNotAvailableException)
544 {
545 }
546 }
547
548 return null;
549 }
550
551 private static AutomationElement.AutomationElementInformation TryGetCurrent(AutomationElement element)
552 {
553 try
554 {
555 return element.Current;
556 }
557 catch (ElementNotAvailableException)
558 {
559 return default;
560 }
561 }
562
563 private static bool IsStale(AutomationElement? element)
564 {
565 if (element is null) return true;
566 try
567 {
568 _ = element.Current.ControlType;
569 return false;
570 }
571 catch (ElementNotAvailableException)
572 {
573 return true;
574 }
575 }
576
577 private static void Walk(AutomationElement? element, string parentPath, string parent, int index, List<Node> nodes)
578 {
579 if (element is null || IsStale(element))
580 {
581 return;
582 }
583
584 var current = TryGetCurrent(element);
585 string controlType = GetControlTypeName(current.ControlType);
586 string name = current.Name ?? string.Empty;
587 string automationId = current.AutomationId ?? string.Empty;
588
589 string segment = $"{controlType}[{index}]";
590 string path = parentPath.Length == 0 ? $"/{segment}" : $"{parentPath}/{segment}";
591
592 var node = new Node
593 {
594 Path = path,
595 ControlType = controlType,
596 Name = name,
597 AutomationId = automationId,
598 IsEnabled = current.IsEnabled,
599 IsFocusable = current.IsKeyboardFocusable,
600 HasKeyboardFocus = current.HasKeyboardFocus,
601 IsOffscreen = current.IsOffscreen,
602 BoundingRectangle = GetBoundingRectangle(current),
603 SupportedPatterns = GetPatternNames(element),
604 IsSelected = TryGetSelected(element),
605 ExpandCollapseState = TryGetExpandState(element),
606 RangeValue = TryGetRangeValue(element),
607 Value = TryGetValue(element),
608 ToggleState = TryGetToggle(element),
609 Parent = parent.Length == 0 ? null : parent,
610 };
611
612 nodes.Add(node);
613
614 AutomationElementCollection? children = null;
615 try
616 {
617 children = element.FindAll(TreeScope.Children, Condition.TrueCondition);
618 }
619 catch (ElementNotAvailableException)
620 {
621 }
622
623 if (children is not null)
624 {
625 for (int i = 0; i < children.Count; i++)
626 {
627 var child = children[i];
628 if (child is null || IsStale(child)) continue;
629
630 Walk(child, path, path, i, nodes);
631
632 var childCurrent = TryGetCurrent(child);
633 string childType = GetControlTypeName(childCurrent.ControlType);
634 node.Children.Add($"{path}/{childType}[{i}]");
635 }
636 }
637
638 node.Children.Sort(StringComparer.Ordinal);
639 }
640
641 private static string GetControlTypeName(ControlType? controlType)
642 {
643 if (controlType is null)
644 {
645 return "Unknown";
646 }
647 string name = controlType.ProgrammaticName;
648 const string prefix = "ControlType.";
649 return name.StartsWith(prefix, StringComparison.Ordinal)
650 ? name.Substring(prefix.Length)
651 : name;
652 }
653
654 private static List<string> GetPatternNames(AutomationElement element)
655 {
656 var names = new List<string>();
657 try
658 {
659 foreach (var pattern in element.GetSupportedPatterns())
660 {
661 string? name = Automation.PatternName(pattern);
662 names.Add(name ?? pattern.ProgrammaticName ?? "Unknown");
663 }
664 names.Sort(StringComparer.Ordinal);
665 }
666 catch (ElementNotAvailableException)
667 {
668 }
669 return names;
670 }
671
672 private static string? GetBoundingRectangle(AutomationElement.AutomationElementInformation current)
673 {
674 try
675 {
676 if (current.BoundingRectangle.IsEmpty)
677 {
678 return null;
679 }
680 var rect = current.BoundingRectangle;
681 return FormattableString.Invariant($"{rect.Left:F1},{rect.Top:F1},{rect.Width:F1},{rect.Height:F1}");
682 }
683 catch (ElementNotAvailableException)
684 {
685 return null;
686 }
687 }
688
689 private static bool? TryGetSelected(AutomationElement element)
690 {
691 try
692 {
693 if (element.TryGetCurrentPattern(SelectionItemPattern.Pattern, out object? pattern) && pattern is not null)
694 {
695 return ((SelectionItemPattern)pattern).Current.IsSelected;
696 }
697 }
698 catch (ElementNotAvailableException)
699 {
700 }
701 return null;
702 }
703
704 private static string? TryGetExpandState(AutomationElement element)
705 {
706 try
707 {
708 if (element.TryGetCurrentPattern(ExpandCollapsePattern.Pattern, out object? pattern) && pattern is not null)
709 {
710 return ((ExpandCollapsePattern)pattern).Current.ExpandCollapseState.ToString();
711 }
712 }
713 catch (ElementNotAvailableException)
714 {
715 }
716 return null;
717 }
718
719 private static double? TryGetRangeValue(AutomationElement element)
720 {
721 try
722 {
723 if (element.TryGetCurrentPattern(RangeValuePattern.Pattern, out object? pattern) && pattern is not null)
724 {
725 return ((RangeValuePattern)pattern).Current.Value;
726 }
727 }
728 catch (ElementNotAvailableException)
729 {
730 }
731 return null;
732 }
733
734 private static string? TryGetValue(AutomationElement element)
735 {
736 try
737 {
738 if (element.TryGetCurrentPattern(ValuePattern.Pattern, out object? pattern) && pattern is not null)
739 {
740 return ((ValuePattern)pattern).Current.Value;
741 }
742 }
743 catch (ElementNotAvailableException)
744 {
745 }
746 return null;
747 }
748
749 private static string? TryGetToggle(AutomationElement element)
750 {
751 try
752 {
753 if (element.TryGetCurrentPattern(TogglePattern.Pattern, out object? pattern) && pattern is not null)
754 {
755 return ((TogglePattern)pattern).Current.ToggleState.ToString();
756 }
757 }
758 catch (ElementNotAvailableException)
759 {
760 }
761 return null;
762 }
763
764 private static bool TryMouseClick(
765 AutomationElement root,
766 AutomationElement target,
767 bool doubleClick,
768 out string? error)
769 {
770 error = null;
771 try
772 {
773 try
774 {
775 var hwnd = new IntPtr(root.Current.NativeWindowHandle);
776 if (hwnd != IntPtr.Zero)
777 {
778 SetForegroundWindow(hwnd);
779 }
780 }
781 catch (ElementNotAvailableException)
782 {
783 }
784
785 int x;
786 int y;
787 try
788 {
789 var point = target.GetClickablePoint();
790 x = (int)Math.Round(point.X);
791 y = (int)Math.Round(point.Y);
792 }
793 catch (NoClickablePointException)
794 {
795 var rect = target.Current.BoundingRectangle;
796 if (rect.IsEmpty || rect.Width <= 0 || rect.Height <= 0)
797 {
798 error = $"No clickable point for {Describe(target)}";
799 return false;
800 }
801 x = (int)Math.Round(rect.Left + rect.Width / 2.0);
802 y = (int)Math.Round(rect.Top + rect.Height / 2.0);
803 }
804
805 if (!SetCursorPos(x, y))
806 {
807 error = $"SetCursorPos({x},{y}) failed";
808 return false;
809 }
810
811 System.Threading.Thread.Sleep(40);
812 mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero);
813 mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero);
814 if (doubleClick)
815 {
816 System.Threading.Thread.Sleep(40);
817 mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, UIntPtr.Zero);
818 mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, UIntPtr.Zero);
819 }
820
821 return true;
822 }
823 catch (Exception ex)
824 {
825 error = ex.Message;
826 return false;
827 }
828 }
829
830 private static bool TrySendKeyCombo(string keySpec, out string? error)
831 {
832 error = null;
833 var parts = keySpec
834 .Split(new[] { '+', '-' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
835 .Select(p => p.Trim().ToLowerInvariant())
836 .Where(p => p.Length > 0)
837 .ToArray();
838
839 if (parts.Length == 0)
840 {
841 error = "Empty key combo.";
842 return false;
843 }
844
845 var modifiers = new List<ushort>();
846 ushort? mainVk = null;
847
848 foreach (var part in parts)
849 {
850 if (part is "ctrl" or "control")
851 {
852 modifiers.Add(0x11); // VK_CONTROL
853 continue;
854 }
855 if (part is "shift")
856 {
857 modifiers.Add(0x10); // VK_SHIFT
858 continue;
859 }
860 if (part is "alt" or "menu")
861 {
862 modifiers.Add(0x12); // VK_MENU
863 continue;
864 }
865 if (part is "win" or "meta" or "cmd")
866 {
867 modifiers.Add(0x5B); // VK_LWIN
868 continue;
869 }
870
871 if (mainVk is not null)
872 {
873 error = $"Multiple non-modifier keys in combo '{keySpec}'.";
874 return false;
875 }
876
877 mainVk = part switch
878 {
879 "enter" or "return" => (ushort)0x0D,
880 "escape" or "esc" => (ushort)0x1B,
881 "space" => (ushort)0x20,
882 "tab" => (ushort)0x09,
883 "left" => (ushort)0x25,
884 "up" => (ushort)0x26,
885 "right" => (ushort)0x27,
886 "down" => (ushort)0x28,
887 "f" => (ushort)0x46,
888 "m" => (ushort)0x4D,
889 "q" => (ushort)0x51,
890 "s" => (ushort)0x53,
891 "o" => (ushort)0x4F,
892 "b" => (ushort)0x42,
893 "comma" or "," => (ushort)0xBC,
894 "period" or "." => (ushort)0xBE,
895 _ when part.Length == 1 && char.IsLetterOrDigit(part[0]) =>
896 (ushort)char.ToUpperInvariant(part[0]),
897 _ => (ushort)0,
898 };
899
900 if (mainVk == 0)
901 {
902 error = $"Unsupported key '{part}' in combo '{keySpec}'.";
903 return false;
904 }
905 }
906
907 if (mainVk is null)
908 {
909 error = $"No main key in combo '{keySpec}'.";
910 return false;
911 }
912
913 var inputs = new List<INPUT>();
914 foreach (var mod in modifiers)
915 {
916 inputs.Add(KeyDown(mod));
917 }
918 inputs.Add(KeyDown(mainVk.Value));
919 inputs.Add(KeyUp(mainVk.Value));
920 for (int i = modifiers.Count - 1; i >= 0; i--)
921 {
922 inputs.Add(KeyUp(modifiers[i]));
923 }
924
925 uint sent = SendInput((uint)inputs.Count, inputs.ToArray(), Marshal.SizeOf<INPUT>());
926 if (sent != inputs.Count)
927 {
928 error = $"SendInput sent {sent}/{inputs.Count} events.";
929 return false;
930 }
931
932 return true;
933 }
934
935 private static INPUT KeyDown(ushort vk) => new INPUT
936 {
937 type = INPUT_KEYBOARD,
938 U = new InputUnion
939 {
940 ki = new KEYBDINPUT
941 {
942 wVk = vk,
943 wScan = 0,
944 dwFlags = 0,
945 time = 0,
946 dwExtraInfo = IntPtr.Zero,
947 },
948 },
949 };
950
951 private static INPUT KeyUp(ushort vk) => new INPUT
952 {
953 type = INPUT_KEYBOARD,
954 U = new InputUnion
955 {
956 ki = new KEYBDINPUT
957 {
958 wVk = vk,
959 wScan = 0,
960 dwFlags = KEYEVENTF_KEYUP,
961 time = 0,
962 dwExtraInfo = IntPtr.Zero,
963 },
964 },
965 };
966
967 private sealed class Node
968 {
969 public string Path { get; set; } = string.Empty;
970 public string ControlType { get; set; } = string.Empty;
971 public string Name { get; set; } = string.Empty;
972 public string AutomationId { get; set; } = string.Empty;
973 public bool IsEnabled { get; set; }
974 public bool IsFocusable { get; set; }
975 public bool HasKeyboardFocus { get; set; }
976 public bool IsOffscreen { get; set; }
977 public string? BoundingRectangle { get; set; }
978 public List<string> SupportedPatterns { get; set; } = new List<string>();
979 public bool? IsSelected { get; set; }
980 public string? ExpandCollapseState { get; set; }
981 public double? RangeValue { get; set; }
982 public string? Value { get; set; }
983 public string? ToggleState { get; set; }
984 public string? Parent { get; set; }
985 public List<string> Children { get; set; } = new List<string>();
986 }
987}
988