externe .Net-Programme auslesen und manipulieren

    • VB.NET
    • .NET (FX) 4.5–4.8

    Es gibt 1 Antwort in diesem Thema. Der letzte Beitrag () ist von VaporiZed.

      externe .Net-Programme auslesen und manipulieren

      Dies ist eine andere Herangehensweise zur Problematik des andere Programme fernsteuern-Threads von @RodFromGermany, basiert auf den Infos vom Thread von Hourmin und beantwortet meine einst gestellte Frage über das Auslesen anderer .Net-Anwendungen.
      Mit der hier beschriebenen Vorgehensweise können externe .Net-Programme (größtenteils) ausgelesen und auch beeinflusst werden.

      Anmerkung für diesen Text: CE = control element (Button, TextBox, DGV, …)

      Einschränkungen: Dieses Vorgehen funktioniert nicht bei allen propriäteren WinForms-CEs, da diese explizit solche Automatisierungen unterstützen müssen. Bei einigen Anwendungen habe ich schon Konstrukte gesehen, die einfach eine Zusammenarbeit verweigern. Dafür habe ich noch keine Lösung gefunden. Bei WPF-Anwendungen ist die Automatisierungsunterstützung wohl schon nativ bei den CEs eingebaut, aber da bin ich momentan noch raus, dies zu prüfen.



      Als Vorbereitung werden über die Verweise die folgenden DLLs in das Projekt eingebunden: UIAutomationClient, UIAutomationTypes

      Das Prinzip der Automatisierung hier ist, dass man sich die passende Anwendung und dort wiederum das passende CE raussucht, welches man dann beeinflussen oder auslesen will. Da bei der hiesigen Art der Automatisierung das Denkprinzip so ist, dass alle Objekte Subelemente von anderen Elementen sind, beginnt man mit dem Stamm-/Wurzelelement, was alles andere beinhaltet. Dies ist definitionsgemäß laut Microsoft für diese Art von Automatisierung der Desktop, dargestellt durch AutomationElement.RootElement. (Wenn man (Sub-)Elemente durch »Fenster« ersetzt, wird wieder ein Windows-Schuh draus.)

      Zum Durchsuchen eines AutomationElements gibt es zwei Funktionen: FindFirst und FindAll. FindFirst wird immer dann verwendet, wenn man weiß, wonach man suchen muss. Man erhält als Resultat das passende AutomationElement (oder Nothing). FindAll wird hingegen genutzt, wenn man sich erstmal alles Passende auflisten lassen will, logischerweise erhält man eine AutomationElementCollection. Desweiteren muss man einen Scope, also einen Bereich angeben, den man durchsuchen will. Da der Desktop ganz oben in der Nahrungskette steht, kann man nur dessen Kinder, Nachkommen und sich selbst suchen. Alles andere endet in einer Exception.

      Will man alle laufenden Fenster/Programme auflisten, lässt man alle Kinder des Desktops suchen:

      VB.NET-Quellcode

      1. Dim RunningApps = AutomationElement.RootElement.FindAll(TreeScope.Children, Condition.TrueCondition)

      Würde man hier stattdessen als Scope TreeScope.Descendants angeben, würde man alle Fenster und all deren CEs erhalten. Dauert etwas und ist meist überflüssig. Man will ja ein Programm in die Zange nehmen, nicht alle. Der Descendants-Scope wird nachher relevant, wenn wir unser Programm haben und da alle CEs auflisten wollen.
      FindFirst können wir natürlich auch verwenden. Dazu müssen wir die ein oder andere Bedingung angeben, die zutreffen soll. Dazu gibt es neben dem Scope-Parameter den 2. Parameter. Eine Möglichkeit unter vielen ist, dass man den Titelleistentext des Zielelements angibt:

      VB.NET-Quellcode

      1. Dim Notepad = AutomationElement.RootElement.FindFirst(TreeScope.Children, New PropertyCondition(AutomationElement.NameProperty, "Unbenannt - Editor")

      Alternativ kann man auch zur Programmsuche die AutomationElement.ClassNameProperty nehmen. Während allerdings bei Notepad dies noch »Notepad« ist, ist es bei Word 2016 bei mir schon »OpusApp«. Also leider nicht sonderlich instinktiv. Ein Zugriff auf den Dateinamen ist verständlicherweise nicht (direkt) möglich, da es hier um Fensterobjekte geht, nicht um Programmobjekte. Über die ProcessID etc. kommt man aber über Umwege auch an Dateipfad und Co.

      Nachdem wir uns nun unsere TargetApp mithilfe von

      VB.NET-Quellcode

      1. Dim TargetApp = AutomationElement.RootElement.FindFirst(TreeScope.Children, New PropertyCondition(AutomationElement.NameProperty, TitelleistentextDerApp)
      herausgesucht haben, können wir die App nun wiederum durchsuchen, denn TargetApp ist ja auch wieder vom Typ AutomationElement. Nutzen wir hingegen die FindAll-Funktion, können wir die AutomationElementCollection auch nach unserer ZielApp durchforsten:

      VB.NET-Quellcode

      1. Dim PossibleTargetApps = AutomationElement.RootElement.FindAll(TreeScope.Children, New PropertyCondition(AutomationElement.NameProperty, TitelleistentextDerApp))
      2. For Each TargetApp As AutomationElement In PossibleTargetApps
      3. '…
      4. Next

      Nun wäre es meist erstmal sinnvoll, alle CEs aufzusammeln, wenn man noch nicht genau weiß, welches CE man jetzt manipulieren will:

      VB.NET-Quellcode

      1. Dim ListOfAllCEs = TargetApp.FindAll(TreeScope.Descendants, Condition.TrueCondition)

      Diese kann man nun durchforsten und sich das passende CE raussuchen und auslesen. Da kann man sich dann für spätere Automatisierung oder Manipulation die AutomationID des Ziel-CEs merken und später gezielt nach dieser suchen lassen.

      Man kann natürlich auch zahlreiche Einschränkungen bei der Suche machen, indem man als 2. Parameter der Find-Funktionen eine PropertyCondition angibt, bei der man dann viele Möglichkeiten hat. Will man zum Beispiel das 1. CE haben, welches die AutomationID 15 hat, schreibt man:

      VB.NET-Quellcode

      1. Dim MyTargetCE = TargetApp.FindFirst(TreeScope.Descendants, New PropertyCondition(AutomationElement.AutomationIdProperty, "15"))

      (Die AutomationID ist vom Typ String!)
      Will man alle CEs haben, die alte Menühauptpunkte sind, wie im Editor, schreibt man

      VB.NET-Quellcode

      1. TargetApp.FindAll(TreeScope.Descendants, New PropertyCondition(AutomationElement.LocalizedControlTypeProperty, "Menüelement"))

      Alternativ kann man sich auch einfach alle CEs geben lassen und diese selber mit entsprechenden IF-Statements filtern.

      Sobald man sich ein CE rausgesucht hat, will man damit natürlich auch was damit machen. Die einfachsten Sachen sind z.B. einen Button drücken oder einen TextBox-Text auslesen. Dazu bedient man sich der sogenannten Patterns (Muster). Ein Button unterstützt z.B. das InvokePattern, eine TextBox das TextPattern. Um herauszufinden, welche Muster ein CE unterstützt, nutzt man die Funktion GetSupportedPatterns:

      VB.NET-Quellcode

      1. Dim SupportedPatterns = MyTargetCE.GetSupportedPatterns

      Das InvokePattern nimmt man her, um einen Button zu klicken:

      VB.NET-Quellcode

      1. DirectCast(MyTargetCE.GetCurrentPattern(InvokePattern.Pattern), InvokePattern).Invoke()

      Hat man eine TextBox und braucht dessen Text, nimmt man das TextPattern:

      VB.NET-Quellcode

      1. DirectCast(MyTargetCE.GetCurrentPattern(TextPattern.Pattern), TextPattern).GetVisibleRanges(0).GetText(Short.MaxValue)


      Das für den Anfang. Kleine Vereinfachungen für die Patterns, für die FindAll-Funktion und das zurück-zum-Elternteil-Navigieren kommen nach der Freischaltung.
      Dieser Beitrag wurde bereits 5 mal editiert, zuletzt von „VaporiZed“, mal wieder aus Grammatikgründen.

      Aufgrund spontaner Selbsteintrübung sind all meine Glaskugeln beim Hersteller. Lasst mich daher bitte nicht den Spekulatiusbackmodus wechseln.

      ein kleines Sammelsurium an Informationen drumherum

      Hat man eine App oder ein CE ausgewählt, kann man natürlich über FindFirst und FindAll auch an das Parent-Element rankommen. Für ein CE ist dies der Container oder die App, für eine App ist dies der Desktop. Einfacher geht es aber mit einem Treewalker. Auf die jedoch einzugehen, ist hier nicht sinnvoll, es reicht wohl erstmal, dass man mit folgender Codezeile das Parent eines AutomationElements bekommt:

      VB.NET-Quellcode

      1. Dim ParentAutomationElement = TreeWalker.RawViewWalker.GetParent(MyAutomationElement)
      Wer das mit dem Desktop ausprobiert, bekommt natürlich ein Nothing.



      Man kann Patterns herholen und auch gezielt ausführen, wenn man weiß, was man vorhat. Im Zuge der Entwicklung eines Mini-CE-Spy (also angehaucht an Spy++, UISpy, Inspect und Co.) kam ich aber doch an einen Punkt kommen, wo ich im Code die Patterns etwas genereller auflisten und auch austesten wollte. Dafür gibt es zunächst mal GetCurrentPattern und TryGetCurrentPattern. Bei ersterem gibt das MSDN folgendes Beispiel

      VB.NET-Quellcode

      1. If Not (elementItem Is Nothing) Then
      2. Dim pattern As SelectionItemPattern
      3. Try
      4. pattern = DirectCast(elementItem.GetCurrentPattern(SelectionItemPattern.Pattern), SelectionItemPattern)
      5. Catch ex As InvalidOperationException
      6. Console.WriteLine(ex.Message) ' Most likely "Pattern not supported."
      7. Return
      8. End Try
      9. pattern.Select()
      10. End If

      TryGetCurrentPattern können wir überspringen, da wir ja bereits wissen, wie man die verfügbaren Patterns eines CEs bekommen.
      Damit man da sich nicht erst was basteln muss, kann man gleich folgende Extension verwenden:
      Spoiler anzeigen

      VB.NET-Quellcode

      1. <Extension>
      2. Public Function TryToGetPattern(Of T As BasePattern)(Box As AutomationElement) As T
      3. Dim Pattern As AutomationPattern = Nothing
      4. Select Case True
      5. Case GetType(T) Is GetType(DockPattern) : Pattern = DockPattern.Pattern
      6. Case GetType(T) Is GetType(ExpandCollapsePattern) : Pattern = ExpandCollapsePattern.Pattern
      7. Case GetType(T) Is GetType(GridItemPattern) : Pattern = GridItemPattern.Pattern
      8. Case GetType(T) Is GetType(GridPattern) : Pattern = GridPattern.Pattern
      9. Case GetType(T) Is GetType(InvokePattern) : Pattern = InvokePattern.Pattern
      10. Case GetType(T) Is GetType(ItemContainerPattern) : Pattern = ItemContainerPattern.Pattern
      11. Case GetType(T) Is GetType(MultipleViewPattern) : Pattern = MultipleViewPattern.Pattern
      12. Case GetType(T) Is GetType(RangeValuePattern) : Pattern = RangeValuePattern.Pattern
      13. Case GetType(T) Is GetType(ScrollItemPattern) : Pattern = ScrollItemPattern.Pattern
      14. Case GetType(T) Is GetType(ScrollPattern) : Pattern = ScrollPattern.Pattern
      15. Case GetType(T) Is GetType(SelectionItemPattern) : Pattern = SelectionItemPattern.Pattern
      16. Case GetType(T) Is GetType(SelectionPattern) : Pattern = SelectionPattern.Pattern
      17. Case GetType(T) Is GetType(SynchronizedInputPattern) : Pattern = SynchronizedInputPattern.Pattern
      18. Case GetType(T) Is GetType(TextPattern) : Pattern = TextPattern.Pattern
      19. Case GetType(T) Is GetType(TogglePattern) : Pattern = TogglePattern.Pattern
      20. Case GetType(T) Is GetType(TransformPattern) : Pattern = TransformPattern.Pattern
      21. Case GetType(T) Is GetType(ValuePattern) : Pattern = ValuePattern.Pattern
      22. Case GetType(T) Is GetType(VirtualizedItemPattern) : Pattern = VirtualizedItemPattern.Pattern
      23. Case GetType(T) Is GetType(WindowPattern) : Pattern = WindowPattern.Pattern
      24. 'Case GetType(T) Is GetType(LegacyIAccessiblePattern) : Pattern = LegacyIAccessiblePattern.Pattern
      25. End Select
      26. If Box.GetSupportedPatterns().Contains(Pattern) Then Return DirectCast(Box.GetCurrentPattern(Pattern), T)
      27. Return Nothing
      28. End Function

      Das letzte Pattern (LegacyIAccessiblePattern) ist absichtlich auskommentiert und erst mit einem Zusatzpaket verfügbar, dazu später mehr.

      Das verkürzt weiteren Code auf z.B.:

      VB.NET-Quellcode

      1. MyButton.TryToGetPattern(Of InvokePattern)?.Invoke()
      da die Extension (etwas umständlich, aber m.E. nicht effektiver zusammenfassbar) das passende Pattern typisiert zurückgibt, man also nicht mehr im Arbeitscode casten muss. Wird das Pattern vom gewählten CE nicht unterstützt, passiert nix, da der Null-Conditional-Operator ? eine null reference exception verhindert und einfach dazu führt, dass Invoke gar nicht erst ausprobiert wird.



      Die FindAll-Funktion kann geringfügig sperrig sein, da sie zum einen eine Collection wiedergibt und immer auch eine Property will, auch wenn man das erreichen will, was die Funktion auf den ersten Blick suggeriert: Gib mir alles. Mir ist klar, dass das letztenendes das gleiche Prinzip wie bei LINQ ist: MyList.All(Function(x) Bedingung) - Gib mir alle, die jene Bedingung(en) erfüllen. Trotzdem kann eine eigene Extension auch hier das Leben etwas erleichtern:

      VB.NET-Quellcode

      1. <Extension>
      2. Public Function FindAllIn(Box As AutomationElement, Scope As TreeScope) As IEnumerable(Of AutomationElement)
      3. If Box.NotExists Then Return Nothing
      4. Return Box.FindAll(Scope, Condition.TrueCondition).Cast(Of AutomationElement)
      5. End Function

      Die Verwendung im Code sollte klar sein:

      VB.NET-Quellcode

      1. Dim AllCEsInMyApp = MyApp.FindAllIn(Scope.Descendants)




      Die AutomationElements haben zahlreiche Propertys, die man sich natürlich auch ausgeben kann. Eine interessante Information ist z.B. die FrameworkID-Property. Damit sieht man, ob ein Programm (oder ein CE) z.B. ein Win32-, ein WinForms- oder ein WPF-Objekt ist. Dies wird spätestens dann relevant, wenn man versucht, an ein Menü eines Programms ranzukommen. Während WPF-Menüs direkt und WinForms-Menüs mit einem kleinen Trick zugänglich sind, hat man bei Win32-Menüs keine Chance. Da muss man ganz alte Kamellen raussuchen, um die in die Zange zu nehmen. Aber wir bleiben bei .Net. Um an ein WinForms-Menü zu kommen, benötigt es neben den direkt angebotenen Patterns als Zusatz noch das LegacyIAccessiblePattern - welches leider nicht in den o.a. DLLs bekannt ist. Wenn man die beiden DLLs hingegen entfernt und sich stattdessen externe Hilfe durch Nuget-Runterladen des UIAComWrappers von Michael Bernstein holt, hat man auch Zugriff auf WinForm-Menüs.
      Wenn die DLLs gewechselt werden: nicht vergessen den System.Windows.Automation-Namespace (wieder) zu importieren, sonst hagelt es meist Fehlermeldungen ;)







      »Und wie kann ich jetzt zusammenfassend eine MessageBox in einem anderen Programm wegklicken?«
      1. Ziel-App anvisieren
      2. Ziel-CE herholen
      3. Invoke-Pattern herholen und Invoke ausführen
      Als Beispiel: notepad mit leerer Datei öffnen, etwas Text schreiben, notepad schließen. Es kommt die Frage, ob die Änderungen gespeichert werden sollen. Nun will man automatisiert auf [Speichern] klicken.

      VB.NET-Quellcode

      1. Dim Editor = AutomationElement.RootElement.FindFirst(TreeScope.Children, New PropertyCondition(AutomationElement.NameProperty, "Unbenannt - Editor"))
      2. Dim SaveButton = Editor?.FindFirst(TreeScope.Descendants, New PropertyCondition(AutomationElement.NameProperty, "Speichern"))
      3. SaveButton?.TryToGetPattern(Of InvokePattern)?.Invoke()

      Fertig. Man sollte nur beachten, dass es möglicherweise mehr als ein CE gibt, welches in anderen Programmen wie das Ziel-CE heißt. In notepad gibt es nämlich noch den Menüeintrag [Speichern] - der aber "dank" Win32 nicht über Windows.Automation zugänglich ist und somit unsichtbar bleibt.
      Dieser Beitrag wurde bereits 5 mal editiert, zuletzt von „VaporiZed“, mal wieder aus Grammatikgründen.

      Aufgrund spontaner Selbsteintrübung sind all meine Glaskugeln beim Hersteller. Lasst mich daher bitte nicht den Spekulatiusbackmodus wechseln.