From 3ca571109493aacc485376964ee1f95daf575f04 Mon Sep 17 00:00:00 2001 From: NIk Date: Sun, 22 Feb 2026 02:44:14 +0100 Subject: [PATCH] Update library --- .../Calc/Pages/BasePageObject.cs | 40 +++ .../Calc/Pages/CalcNavigationPage.cs | 22 +- .../Calc/Pages/CalcPage.cs | 44 +-- .../Calc/Pages/CalcSettingsPage.cs | 15 +- UiAutomationGRPC.Client/Program.cs | 11 +- .../Elements/IAutomationElement.cs | 39 ++- .../Elements/UiElement.cs | 317 ++++++++++++------ UiAutomationGRPC.Library/Helpers/Logger.cs | 33 +- UiAutomationGRPC.Library/README.md | 60 +++- .../UiAutomationDriver.cs | 93 ++++- .../UiAutomationGRPC.Library.csproj | 1 + 11 files changed, 461 insertions(+), 214 deletions(-) diff --git a/UiAutomationGRPC.Client/Calc/Pages/BasePageObject.cs b/UiAutomationGRPC.Client/Calc/Pages/BasePageObject.cs index eb78596..255fe5e 100644 --- a/UiAutomationGRPC.Client/Calc/Pages/BasePageObject.cs +++ b/UiAutomationGRPC.Client/Calc/Pages/BasePageObject.cs @@ -8,6 +8,14 @@ public abstract class BasePageObject where T : BasePageObject public UiAutomationDriver _driver; protected VirtualKeyboard Keyboard { get; } protected VirtualMouse Mouse { get; } + + /// + /// Internal pipeline of queued async actions. + /// Action methods append to this chain and return this synchronously, + /// enabling fluent chaining. The pipeline is flushed (awaited and reset) + /// when a value-returning or page-transitioning method is called. + /// + private Task _pipeline = Task.CompletedTask; protected BasePageObject(UiAutomationDriver driver) { @@ -15,4 +23,36 @@ protected BasePageObject(UiAutomationDriver driver) Keyboard = new VirtualKeyboard(driver); Mouse = new VirtualMouse(driver); } + + /// + /// Appends an async action to the internal pipeline. + /// The action will execute sequentially after all previously enqueued actions. + /// + protected void Enqueue(Func action) + { + var previous = _pipeline; + _pipeline = InternalContinueAsync(previous, action); + } + + private static async Task InternalContinueAsync(Task previous, Func next) + { + await previous.ConfigureAwait(false); + await next().ConfigureAwait(false); + } + + /// + /// Drains the pipeline (awaits all enqueued actions), then executes the + /// result-producing . + /// Use in every terminal method (value-returning or page-transitioning) + /// to ensure queued actions complete before reading state. + /// + protected async Task ResolveAsync(Func> resultAction) + { + var pending = _pipeline; + _pipeline = Task.CompletedTask; + await pending.ConfigureAwait(false); + return await resultAction().ConfigureAwait(false); + } + } + diff --git a/UiAutomationGRPC.Client/Calc/Pages/CalcNavigationPage.cs b/UiAutomationGRPC.Client/Calc/Pages/CalcNavigationPage.cs index d6f71dd..6eb6152 100644 --- a/UiAutomationGRPC.Client/Calc/Pages/CalcNavigationPage.cs +++ b/UiAutomationGRPC.Client/Calc/Pages/CalcNavigationPage.cs @@ -14,21 +14,19 @@ public CalcNavigationPaget(UiAutomationDriver driver, TPage previousPage) : base _driver = driver ?? throw new ArgumentNullException(nameof(driver)); _previousPage = previousPage; _locators = new CalcNavigationPagetLocators(driver); - // Optional: Wait for the app to be ready in constructor - _locators.ButtonSettings.WaitForElementExist(); } - public CalcSettingsPage ClickSettings() - { - _locators.ButtonSettings.Click(); - return new CalcSettingsPage(_driver, _previousPage); - } + /// + /// Waits for the page to be ready. Call after construction. + /// + public Task> WaitForReady() => + ResolveAsync(async () => { await _locators.ButtonSettings.WaitForElementExistAsync(); return this; }); + + public Task> ClickSettings() => + ResolveAsync(async () => { await _locators.ButtonSettings.ClickAsync(); return new CalcSettingsPage(_driver, _previousPage); }); - public TPage ClickNavigationButton() - { - _locators.ButtonNavigation.Click(); - return _previousPage; - } + public Task ClickNavigationButton() => + ResolveAsync(async () => { await _locators.ButtonNavigation.ClickAsync(); return _previousPage; }); } public class CalcNavigationPagetLocators diff --git a/UiAutomationGRPC.Client/Calc/Pages/CalcPage.cs b/UiAutomationGRPC.Client/Calc/Pages/CalcPage.cs index cc12c78..66f89ab 100644 --- a/UiAutomationGRPC.Client/Calc/Pages/CalcPage.cs +++ b/UiAutomationGRPC.Client/Calc/Pages/CalcPage.cs @@ -10,64 +10,52 @@ public class CalcPage : BasePageObject public CalcPage(UiAutomationDriver driver) : base(driver) { - // Optional: Wait for the app to be ready in constructor _driver = driver; _locators = new CalcPageLocators(driver); - _locators.ResultText.WaitForElementExist(); } + /// + /// Waits for the page to be ready. Call after construction. + /// + public Task WaitForReady() => + ResolveAsync(async () => { await _locators.ResultText.WaitForElementExistAsync(); return this; }); + public CalcPage ClickTwo() { - _locators.ButtonTwo.Click(); + Enqueue(() => _locators.ButtonTwo.ClickAsync()); return this; } public CalcPage ClickPlus() { - _locators.ButtonPlus.Click(); + Enqueue(() => _locators.ButtonPlus.ClickAsync()); return this; } public CalcPage ClickEqual() { - _locators.ButtonEqual.Click(); + Enqueue(() => _locators.ButtonEqual.ClickAsync()); return this; } - public string GetResult() - { - return _locators.ResultText.Name(); - } + public Task GetResult() => + ResolveAsync(() => _locators.ResultText.NameAsync()); - public CalcNavigationPaget ClickNavigationButton() - { - _locators.ResultText.Name(); - return new CalcNavigationPaget(_driver, this); - } + public Task> ClickNavigationButton() => + ResolveAsync(async () => { await _locators.ResultText.NameAsync(); return new CalcNavigationPaget(_driver, this); }); public CalcPage ClickResultText() { - _locators.ResultText.Click(); + Enqueue(() => _locators.ResultText.ClickAsync()); return this; } /// - /// Sends a key using the VirtualKeyboard (async). - /// For the fluent sync pattern, this returns immediately after queuing. + /// Sends a key using VirtualKeyboard. /// public CalcPage SendKey(string key) { - // Fire and forget for fluent pattern - use SendKeyAsync directly for proper async handling - _ = Keyboard.SendKeyAsync(key); - return this; - } - - /// - /// Sends a key asynchronously using VirtualKeyboard. - /// - public async Task SendKeyAsync(string key) - { - await Keyboard.SendKeyAsync(key); + Enqueue(() => Keyboard.SendKeyAsync(key)); return this; } } diff --git a/UiAutomationGRPC.Client/Calc/Pages/CalcSettingsPage.cs b/UiAutomationGRPC.Client/Calc/Pages/CalcSettingsPage.cs index ea7f17b..8f50c2d 100644 --- a/UiAutomationGRPC.Client/Calc/Pages/CalcSettingsPage.cs +++ b/UiAutomationGRPC.Client/Calc/Pages/CalcSettingsPage.cs @@ -12,15 +12,16 @@ public CalcSettingsPage(UiAutomationDriver driver, TPage previousPage) : base(dr { _driver = driver ?? throw new ArgumentNullException(nameof(driver)); _locators = new CalcSettingsPageLocators(driver); - // Optional: Wait for the app to be ready in constructor - _locators.BackButton.WaitForElementExist(); } - public CalcPage ClickBack() - { - _locators.BackButton.Click(); - return new CalcPage(_driver); - } + /// + /// Waits for the page to be ready. Call after construction. + /// + public Task> WaitForReady() => + ResolveAsync(async () => { await _locators.BackButton.WaitForElementExistAsync(); return this; }); + + public Task ClickBack() => + ResolveAsync(async () => { await _locators.BackButton.ClickAsync(); return new CalcPage(_driver); }); } public class CalcSettingsPageLocators diff --git a/UiAutomationGRPC.Client/Program.cs b/UiAutomationGRPC.Client/Program.cs index d151389..ff6a855 100644 --- a/UiAutomationGRPC.Client/Program.cs +++ b/UiAutomationGRPC.Client/Program.cs @@ -103,6 +103,7 @@ private static async Task PerformCalculatorInteractions(UiAutomationDriver drive // CalcPage internally uses CalcPageLocators where selectors are defined. var calcPage = new CalcPage(driver); + await calcPage.WaitForReady(); // A. Click Interactions try @@ -114,7 +115,7 @@ private static async Task PerformCalculatorInteractions(UiAutomationDriver drive .ClickTwo() .ClickEqual(); - var resultName = calcPage.GetResult(); + var resultName = await calcPage.GetResult(); Console.WriteLine($"Click Result: {resultName}"); } catch (Exception ex) @@ -138,7 +139,7 @@ private static async Task PerformCalculatorInteractions(UiAutomationDriver drive await Task.Delay(1000); // Wait for calculation - var resultName = calcPage.GetResult(); + var resultName = await calcPage.GetResult(); Console.WriteLine($"Keyboard Result Name: {resultName}"); } catch (Exception ex) @@ -165,7 +166,7 @@ async Task ClickElementGlobally(UiAutomationGRPC.Library.Elements.IAutomationEle { try { - var rect = element.GetRectangle(); + var rect = await element.GetRectangleAsync(); int centerX = rect.Left + rect.Width / 2; int centerY = rect.Top + rect.Height / 2; @@ -188,7 +189,7 @@ async Task ClickElementGlobally(UiAutomationGRPC.Library.Elements.IAutomationEle await ClickElementGlobally(locators.ButtonEqual, "Button Equal"); await Task.Delay(1000); - var resultName = calcPage.GetResult(); + var resultName = await calcPage.GetResult(); Console.WriteLine($"Global Mouse Result: {resultName}"); Console.WriteLine("Mouse interactions completed."); @@ -211,7 +212,7 @@ private static async Task TakeScreenshots(UiAutomationDriver driver) var btn2 = locators.ButtonTwo; // Get Runtime ID to identify the specific element for the screenshot service - string btnId = btn2.GetRuntimeId(); + string btnId = await btn2.GetRuntimeIdAsync(); Console.WriteLine($"Button Two Runtime ID: {btnId}"); // 1. Element Screenshot diff --git a/UiAutomationGRPC.Library/Elements/IAutomationElement.cs b/UiAutomationGRPC.Library/Elements/IAutomationElement.cs index cff764b..2a532b1 100644 --- a/UiAutomationGRPC.Library/Elements/IAutomationElement.cs +++ b/UiAutomationGRPC.Library/Elements/IAutomationElement.cs @@ -1,90 +1,93 @@ -using UiAutomationGRPC.Library.Helpers; -using System.Drawing; +using System.Drawing; +using UiAutomationGRPC.Library.Helpers; namespace UiAutomationGRPC.Library.Elements { /// /// Interface for automation elements. + /// All methods are async-only because every operation involves a gRPC network call + /// to the server. Async prevents thread-blocking during network I/O and avoids + /// deadlocks when consumed from UI threads or synchronization-context-bound callers. /// public interface IAutomationElement { /// /// Processes a click on an element. /// - void Click(); + Task ClickAsync(); /// - /// Processes a click on an element relative to Top Left coordinates. + /// Processes a click at specific screen coordinates (moves cursor to x,y then clicks). /// - void Click(int x, int y); + Task ClickAsync(int x, int y); /// /// Processes a double click on an element. /// - void DoubleClick(); + Task DoubleClickAsync(); /// /// Processes a hover on an element. /// - void Hover(); + Task HoverAsync(); /// /// Returns the Name field of the element. /// - string Name(); + Task NameAsync(); /// /// Returns the ClassName field of the element. /// - string ClassName(); + Task ClassNameAsync(); /// /// Returns the AutomationId field of the element. /// - string AutomationId(); + Task AutomationIdAsync(); /// /// Waits until the element becomes clickable/interactable. /// - void WaitForElementIsClickable(); + Task WaitForElementIsClickableAsync(); /// /// Waits until the element exists. /// - void WaitForElementExist(); + Task WaitForElementExistAsync(); /// /// Checks if the element exists. /// - bool IsElementExist(); + Task IsElementExistAsync(); /// /// Checks if the element is clickable. /// - bool IsElementClicable(); + Task IsElementClickableAsync(); /// /// Checks if the element exists for a period of time. /// /// Expected status. /// Time in seconds to wait. - bool WaitElementExistStatusForTime(bool status, int time); + Task WaitElementExistStatusForTimeAsync(bool status, int time); /// /// Checks if the element is clickable for a period of time. /// /// Expected status. /// Time in seconds to wait. - bool WaitElementClickableStatusForTime(bool status, int time = UsabilityTimeLimits.ApplicationLoadLimit); + Task WaitElementClickableStatusForTimeAsync(bool status, int time = UsabilityTimeLimits.ApplicationLoadLimit); /// /// Returns the rectangle of the element. /// - Rectangle GetRectangle(); + Task GetRectangleAsync(); /// /// Returns the runtime ID of the element. /// - string GetRuntimeId(); + Task GetRuntimeIdAsync(); } } diff --git a/UiAutomationGRPC.Library/Elements/UiElement.cs b/UiAutomationGRPC.Library/Elements/UiElement.cs index 46ea2d9..c0d5921 100644 --- a/UiAutomationGRPC.Library/Elements/UiElement.cs +++ b/UiAutomationGRPC.Library/Elements/UiElement.cs @@ -1,4 +1,5 @@ using System.Drawing; +using Grpc.Core; using UiAutomationGRPC.Library.Helpers; using UiAutomationGRPC.Library.Selectors; using Uia = global::UiAutomation; @@ -7,12 +8,15 @@ namespace UiAutomationGRPC.Library.Elements { /// /// Represents a UI element that interacts with the UI Automation gRPC service. + /// All public methods are async-only because every operation involves a gRPC network + /// call to the server. Async prevents thread-blocking during network I/O and avoids + /// deadlocks when consumed from UI threads or synchronization-context-bound callers. /// public class UiElement : IAutomationElement { private readonly Uia.UiAutomationService.UiAutomationServiceClient _client; private readonly List _selectors; - + /// /// Initializes a new instance of the class. /// @@ -21,206 +25,309 @@ public class UiElement : IAutomationElement public UiElement(UiAutomationDriver driver, Func selectorFunc) { _client = driver.Client; - var baseSelector = selectorFunc(); - _selectors = baseSelector.GetSelectors(); + var baseSelector = selectorFunc(); + _selectors = baseSelector.GetSelectors(); } - private string ResolveElement() + #region Element Resolution + + /// + /// Resolves the runtime ID of the element by walking the selector chain. + /// Validates each step and throws descriptive exceptions on failure. + /// + private async Task ResolveElementAsync() { string currentRuntimeId = ""; // Desktop - + foreach (var selector in _selectors) { - var req = new Uia.FindElementRequest - { - StartRuntimeId = currentRuntimeId, - Scope = ToProtoScope(selector.SearchType), - }; - - if (selector.Condition != null && selector.Condition.Count > 0) + var req = BuildFindRequest(currentRuntimeId, selector); + var resp = await _client.FindElementAsync(req); + + ValidateRuntimeId(resp.RuntimeId, selector); + currentRuntimeId = resp.RuntimeId; + } + return currentRuntimeId; + } + + /// + /// Builds a from a selector model. + /// + private static Uia.FindElementRequest BuildFindRequest(string startRuntimeId, SelectorModel selector) + { + var req = new Uia.FindElementRequest + { + StartRuntimeId = startRuntimeId, + Scope = ToProtoScope(selector.SearchType), + }; + + if (selector.Condition is { Count: > 0 }) + { + if (selector.Condition.Count == 1) { - if (selector.Condition.Count == 1) - { - req.Condition = selector.Condition[0]; - } - else - { - var boolCond = new Uia.BoolCondition(); - boolCond.Conditions.AddRange(selector.Condition); - req.Condition = new Uia.Condition { AndCondition = boolCond }; - } + req.Condition = selector.Condition[0]; } else { - req.Condition = new Uia.Condition { TrueCondition = true }; + var boolCond = new Uia.BoolCondition(); + boolCond.Conditions.AddRange(selector.Condition); + req.Condition = new Uia.Condition { AndCondition = boolCond }; } + } + else + { + req.Condition = new Uia.Condition { TrueCondition = true }; + } - var resp = _client.FindElement(req); - currentRuntimeId = resp.RuntimeId; + return req; + } + + /// + /// Validates that a RuntimeId is non-empty after a FindElement call. + /// Throws with selector detail on failure. + /// + private static void ValidateRuntimeId(string runtimeId, SelectorModel selector) + { + if (!string.IsNullOrEmpty(runtimeId)) + return; + + var conditionDescription = BuildConditionDescription(selector); + throw new InvalidOperationException( + $"FindElement returned an empty RuntimeId. " + + $"Selector conditions: [{conditionDescription}], " + + $"SearchType: {selector.SearchType?.ToString() ?? "null"}. " + + "The element was not found in the UI tree."); + } + + /// + /// Builds a human-readable description of selector conditions for error diagnostics. + /// + private static string BuildConditionDescription(SelectorModel selector) + { + if (selector.Condition is null || selector.Condition.Count == 0) + return "TrueCondition (match all)"; + + var parts = new List(); + foreach (var cond in selector.Condition) + { + if (cond.ConditionTypeCase == Uia.Condition.ConditionTypeOneofCase.PropertyCondition) + { + var pc = cond.PropertyCondition; + parts.Add($"{pc.PropertyName}='{pc.PropertyValue}'"); + } + else + { + parts.Add(cond.ConditionTypeCase.ToString()); + } } - return currentRuntimeId; + return string.Join(", ", parts); } - private Uia.TreeScope ToProtoScope(SearchType? type) + private static Uia.TreeScope ToProtoScope(SearchType? type) { if (type == SearchType.Children) return Uia.TreeScope.Children; return Uia.TreeScope.Descendants; // Default } - private string GetId() + #endregion + + #region Actions + + /// + public async Task ClickAsync() { - return ResolveElement(); + var id = await ResolveElementAsync(); + await _client.PerformActionAsync(new Uia.PerformActionRequest { RuntimeId = id, Action = Uia.ActionType.Click }); } /// - public void Click() + public async Task ClickAsync(int x, int y) { - var id = GetId(); - _client.PerformAction(new Uia.PerformActionRequest { RuntimeId = id, Action = Uia.ActionType.Click }); + // Move cursor to absolute screen coordinates, then left-click (global mouse actions) + await _client.PerformActionAsync(new Uia.PerformActionRequest + { + RuntimeId = "", + Action = Uia.ActionType.Move, + Arguments = { x.ToString(), y.ToString() } + }); + await _client.PerformActionAsync(new Uia.PerformActionRequest + { + RuntimeId = "", + Action = Uia.ActionType.LeftClick + }); } /// - public void Click(int x, int y) + public async Task DoubleClickAsync() { - Click(); + var id = await ResolveElementAsync(); + await _client.PerformActionAsync(new Uia.PerformActionRequest { RuntimeId = id, Action = Uia.ActionType.DoubleClick }); } /// - public void DoubleClick() + public async Task HoverAsync() { - var id = GetId(); - _client.PerformAction(new Uia.PerformActionRequest { RuntimeId = id, Action = Uia.ActionType.DoubleClick }); + var id = await ResolveElementAsync(); + await _client.PerformActionAsync(new Uia.PerformActionRequest { RuntimeId = id, Action = Uia.ActionType.MoveTo }); } + #endregion + + #region Properties + /// - public void Hover() + public async Task NameAsync() { - var id = GetId(); - _client.PerformAction(new Uia.PerformActionRequest { RuntimeId = id, Action = Uia.ActionType.MoveTo }); + var id = await ResolveElementAsync(); + var resp = await _client.GetPropertyAsync(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "Name" }); + return resp.Value; } /// - public string Name() + public async Task ClassNameAsync() { - var id = GetId(); - return _client.GetProperty(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "Name" }).Value; + var id = await ResolveElementAsync(); + var resp = await _client.GetPropertyAsync(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "ClassName" }); + return resp.Value; } /// - public string ClassName() + public async Task AutomationIdAsync() { - var id = GetId(); - return _client.GetProperty(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "ClassName" }).Value; + var id = await ResolveElementAsync(); + var resp = await _client.GetPropertyAsync(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "AutomationId" }); + return resp.Value; } /// - public string AutomationId() + public async Task GetRectangleAsync() { - var id = GetId(); - return _client.GetProperty(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "AutomationId" }).Value; + var id = await ResolveElementAsync(); + var resp = await _client.GetPropertyAsync(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "BoundingRectangle" }); + + if (!resp.Success) + throw new Exception($"Failed to get rectangle: {resp.Message}"); + + return ParseRectangle(resp.Value); } /// - public void WaitForElementIsClickable() + public async Task GetRuntimeIdAsync() { - var stopWatch = new System.Diagnostics.Stopwatch(); - stopWatch.Start(); - while (stopWatch.Elapsed.TotalSeconds < UsabilityTimeLimits.ApplicationLoadLimit) - { - try - { - var id = ResolveElement(); - if (!string.IsNullOrEmpty(id)) return; - } - catch {} - Thread.Sleep(500); - } - throw new TimeoutException("Element not clickable"); + return await ResolveElementAsync(); } + #endregion + + #region Waiting & Existence + /// - public void WaitForElementExist() + public async Task WaitForElementIsClickableAsync() { - var stopWatch = new System.Diagnostics.Stopwatch(); - stopWatch.Start(); - while (stopWatch.Elapsed.TotalSeconds < UsabilityTimeLimits.ApplicationLoadLimit) - { - try - { - var id = ResolveElement(); - if (!string.IsNullOrEmpty(id)) return; - } - catch {} - Thread.Sleep(500); - } - throw new TimeoutException("Element not found"); + await WaitForElementAsync("Element not clickable within timeout"); + } + + /// + public async Task WaitForElementExistAsync() + { + await WaitForElementAsync("Element not found within timeout"); } /// - public bool IsElementExist() + public async Task IsElementExistAsync() { try { - var id = ResolveElement(); + var id = await ResolveElementAsync(); return !string.IsNullOrEmpty(id); } - catch + catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) + { + return false; + } + catch (InvalidOperationException) { + // Empty RuntimeId — element was not found return false; } + // Auth, network, and server errors propagate } /// - public bool IsElementClicable() + public async Task IsElementClickableAsync() { - return IsElementExist(); + return await IsElementExistAsync(); } /// - public bool WaitElementExistStatusForTime(bool status, int time) + public async Task WaitElementExistStatusForTimeAsync(bool status, int time) { var start = DateTime.Now; - while((DateTime.Now - start).TotalSeconds < time) + while ((DateTime.Now - start).TotalSeconds < time) { - bool exists = IsElementExist(); + bool exists = await IsElementExistAsync(); if (exists == status) return status; - Thread.Sleep(100); + await Task.Delay(100); } return !status; } /// - public bool WaitElementClickableStatusForTime(bool status, int time = UsabilityTimeLimits.ApplicationLoadLimit) + public async Task WaitElementClickableStatusForTimeAsync(bool status, int time = UsabilityTimeLimits.ApplicationLoadLimit) { - return WaitElementExistStatusForTime(status, time); + return await WaitElementExistStatusForTimeAsync(status, time); } - /// - public Rectangle GetRectangle() + #endregion + + #region Private Helpers + + /// + /// Waits for the element to become available. + /// Only swallows NotFound and empty-RuntimeId; other errors propagate immediately. + /// + private async Task WaitForElementAsync(string timeoutMessage) { - var id = GetId(); - var resp = _client.GetProperty(new Uia.GetPropertyRequest { RuntimeId = id, PropertyName = "BoundingRectangle" }); - - if (!resp.Success) - throw new Exception($"Failed to get rectangle: {resp.Message}"); + var stopWatch = new System.Diagnostics.Stopwatch(); + stopWatch.Start(); + while (stopWatch.Elapsed.TotalSeconds < UsabilityTimeLimits.ApplicationLoadLimit) + { + try + { + var id = await ResolveElementAsync(); + if (!string.IsNullOrEmpty(id)) return; + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) + { + // Element not yet available — retry + } + catch (InvalidOperationException) + { + // Empty RuntimeId — element not yet available — retry + } + // Auth, network, or server errors propagate immediately + await Task.Delay(500); + } + throw new TimeoutException(timeoutMessage); + } - // Parse "X,Y,Width,Height" - var parts = resp.Value.Split(new[] { ',', ';', ':' }, StringSplitOptions.RemoveEmptyEntries); - if (parts.Length >= 4 && - double.TryParse(parts[0], out double x) && + /// + /// Parses a "X,Y,Width,Height" string into a . + /// + private static Rectangle ParseRectangle(string value) + { + var parts = value.Split(new[] { ',', ';', ':' }, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length >= 4 && + double.TryParse(parts[0], out double x) && double.TryParse(parts[1], out double y) && double.TryParse(parts[2], out double w) && double.TryParse(parts[3], out double h)) { return new Rectangle((int)x, (int)y, (int)w, (int)h); } - throw new Exception($"Failed to parse rectangle from '{resp.Value}'"); + throw new FormatException($"Failed to parse rectangle from '{value}'. Expected format: 'X,Y,Width,Height'."); } - /// - public string GetRuntimeId() - { - return GetId(); - } + #endregion } } diff --git a/UiAutomationGRPC.Library/Helpers/Logger.cs b/UiAutomationGRPC.Library/Helpers/Logger.cs index 7aa154e..0b47735 100644 --- a/UiAutomationGRPC.Library/Helpers/Logger.cs +++ b/UiAutomationGRPC.Library/Helpers/Logger.cs @@ -12,24 +12,33 @@ public static class Logger /// /// The UI element. /// The log message. - public static void WriteLog(IAutomationElement element, string log) + public static async Task WriteLogAsync(IAutomationElement element, string log) { Console.WriteLine("Step: " + DataHelper.GetCurrentMethodName()); - if (element.Name() != null) + var name = await element.NameAsync(); + if (name != null) { - Console.WriteLine("{0} {1}", log, element.Name()); - } - else if (element.AutomationId() != null) - { - Console.WriteLine("{0} {1}", log, element.AutomationId()); - } - else if (element.ClassName() != null) - { - Console.WriteLine("{0} {1}", log, element.ClassName()); + Console.WriteLine("{0} {1}", log, name); } else { - Console.WriteLine("{0}" + log + " No any name"); + var automationId = await element.AutomationIdAsync(); + if (automationId != null) + { + Console.WriteLine("{0} {1}", log, automationId); + } + else + { + var className = await element.ClassNameAsync(); + if (className != null) + { + Console.WriteLine("{0} {1}", log, className); + } + else + { + Console.WriteLine("{0}" + log + " No any name"); + } + } } } diff --git a/UiAutomationGRPC.Library/README.md b/UiAutomationGRPC.Library/README.md index 1f073b7..3ca022b 100644 --- a/UiAutomationGRPC.Library/README.md +++ b/UiAutomationGRPC.Library/README.md @@ -123,26 +123,64 @@ await driver.ClearCacheAsync(processId: 12345); ## Security Configuration -The `UiAutomationDriver` constructor supports three connection modes: +The `UiAutomationDriver` supports three secure connection modes and optional token authentication. + +### Connection Modes ```csharp -// 1. Default: HTTPS (requires server with Security.Enabled = true) +// 1. HTTPS with OS-trusted certificate (production default) await using var driver = new UiAutomationDriver("https://127.0.0.1:50051"); -// 2. Insecure mode: HTTP without encryption (development only) -await using var driver = new UiAutomationDriver("http://127.0.0.1:50051", insecureMode: true); +// 2. HTTPS with self-signed certificate (no OS install needed) +await using var driver = new UiAutomationDriver( + "https://127.0.0.1:50051", + certificatePath: @"C:\certs\server.pfx", + certificatePassword: "cert-password"); + +// 3. HTTP without encryption (development only) +await using var driver = new UiAutomationDriver( + "http://127.0.0.1:50051", + insecureMode: true); +``` + +### Token Authentication + +Token auth can be combined with any HTTPS mode: + +```csharp +await using var driver = new UiAutomationDriver( + "https://127.0.0.1:50051", + authToken: "your-secret-token", + certificatePath: @"C:\certs\server.pfx"); +``` + +### Diagnostics Logging + +Pass an `ILogger` to capture security warnings and certificate diagnostics: -// 3. HTTPS + Token authentication (production) -await using var driver = new UiAutomationDriver("https://127.0.0.1:50051", authToken: "your-secret-token"); +```csharp +await using var driver = new UiAutomationDriver( + "https://127.0.0.1:50051", + certificatePath: @"C:\certs\server.pfx", + logger: loggerFactory.CreateLogger()); ``` +### Constructor Parameters + | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `address` | string | `"https://127.0.0.1:50051"` | Server address (http:// or https://) | -| `authToken` | string? | `null` | Bearer token sent with every gRPC call | -| `insecureMode` | bool | `false` | Use HTTP instead of HTTPS | - -> ⚠️ **Warning**: Insecure mode disables TLS encryption. Use only for local development. See [Server README](../UiAutomationGRPC.Server/README.md#security) for server-side security configuration. +| `address` | `string` | `"https://127.0.0.1:50051"` | Server address (`http://` or `https://`) | +| `authToken` | `string?` | `null` | Bearer token sent with every gRPC call | +| `insecureMode` | `bool` | `false` | Use HTTP instead of HTTPS | +| `certificatePath` | `string?` | `null` | Path to PFX/PEM cert for trusting self-signed server certs | +| `certificatePassword` | `string?` | `null` | Password for the certificate file | +| `logger` | `ILogger?` | `null` | Logger for security warnings and cert diagnostics | + +> [!WARNING] +> Insecure mode disables TLS encryption. Use only for local development. See [Server README](../UiAutomationGRPC.Server/README.md#security) for server-side security configuration. + +> [!NOTE] +> When `certificatePath` is provided, the driver validates the server certificate by **thumbprint pinning** — only the exact cert you specify is trusted. OS-trusted certificates are always accepted regardless of this setting. ## API Reference diff --git a/UiAutomationGRPC.Library/UiAutomationDriver.cs b/UiAutomationGRPC.Library/UiAutomationDriver.cs index d2c96c4..36e6dbf 100644 --- a/UiAutomationGRPC.Library/UiAutomationDriver.cs +++ b/UiAutomationGRPC.Library/UiAutomationDriver.cs @@ -1,6 +1,9 @@ using Grpc.Net.Client; using Grpc.Core; using Grpc.Core.Interceptors; +using Microsoft.Extensions.Logging; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; using UiAutomation; namespace UiAutomationGRPC.Library; @@ -15,6 +18,7 @@ public sealed class UiAutomationDriver : IDisposable, IAsyncDisposable private bool _disposed; private readonly bool _insecureMode; private readonly string? _authToken; + private readonly ILogger? _logger; /// /// Internal gRPC client. @@ -27,20 +31,26 @@ public sealed class UiAutomationDriver : IDisposable, IAsyncDisposable /// The address of the gRPC server. Defaults to secure HTTPS. /// Optional authentication token for secure connections. /// Set to true to use HTTP (insecure) connection. - public UiAutomationDriver(string address = "https://127.0.0.1:50051", string? authToken = null, bool insecureMode = false) + /// Optional path to a PFX/PEM certificate file for trusting self-signed server certificates without OS-level installation. + /// Optional password for the certificate file. + /// Optional logger for diagnostics. When null, warnings are silently omitted. + public UiAutomationDriver( + string address = "https://127.0.0.1:50051", + string? authToken = null, + bool insecureMode = false, + string? certificatePath = null, + string? certificatePassword = null, + ILogger? logger = null) { _insecureMode = insecureMode; _authToken = authToken; + _logger = logger; - // Show warning for insecure mode if (_insecureMode) { - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine("╔════════════════════════════════════════════════════════════════╗"); - Console.WriteLine("║ ⚠️ WARNING: INSECURE MODE - CONNECTION IS NOT ENCRYPTED ⚠️ ║"); - Console.WriteLine("║ This mode should only be used for development/testing. ║"); - Console.WriteLine("╚════════════════════════════════════════════════════════════════╝"); - Console.ResetColor(); + _logger?.LogWarning( + "INSECURE MODE: gRPC connection is NOT encrypted. " + + "This mode should only be used for development/testing."); // For insecure connections, ensure address uses http if (address.StartsWith("https://")) @@ -58,17 +68,21 @@ public UiAutomationDriver(string address = "https://127.0.0.1:50051", string? au } // Configure channel options - var channelOptions = new GrpcChannelOptions(); + var handler = new SocketsHttpHandler + { + EnableMultipleHttp2Connections = true + }; - if (_insecureMode) + if (!_insecureMode && !string.IsNullOrEmpty(certificatePath)) { - // Allow HTTP/2 without TLS for development - channelOptions.HttpHandler = new SocketsHttpHandler - { - EnableMultipleHttp2Connections = true - }; + ConfigureCertificateTrust(handler, certificatePath, certificatePassword); } + var channelOptions = new GrpcChannelOptions + { + HttpHandler = handler + }; + _channel = GrpcChannel.ForAddress(address, channelOptions); // If auth token provided, create call invoker with auth interceptor @@ -87,6 +101,53 @@ public UiAutomationDriver(string address = "https://127.0.0.1:50051", string? au } } + /// + /// Configures the HTTP handler to trust a specific certificate for self-signed cert scenarios. + /// Only the provided certificate is trusted — all other untrusted certs are rejected. + /// + private void ConfigureCertificateTrust(SocketsHttpHandler handler, string certificatePath, string? certificatePassword) + { + if (!File.Exists(certificatePath)) + { + throw new FileNotFoundException( + $"Certificate file not found: '{certificatePath}'. " + + "Provide a valid path to the server's PFX or PEM certificate.", + certificatePath); + } + + var trustedCert = new X509Certificate2(certificatePath, certificatePassword ?? ""); + _logger?.LogInformation("Loaded trusted certificate: Subject={Subject}, Thumbprint={Thumbprint}", + trustedCert.Subject, trustedCert.Thumbprint); + + handler.SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = (_, serverCert, _, sslPolicyErrors) => + { + // If the cert is already trusted at OS level, accept it + if (sslPolicyErrors == SslPolicyErrors.None) + return true; + + // Otherwise, validate that the server cert matches our pinned cert + if (serverCert is null) + return false; + + bool thumbprintMatch = string.Equals( + serverCert.GetCertHashString(), + trustedCert.Thumbprint, + StringComparison.OrdinalIgnoreCase); + + if (!thumbprintMatch) + { + _logger?.LogWarning( + "Certificate thumbprint mismatch. Expected={Expected}, Received={Received}", + trustedCert.Thumbprint, serverCert.GetCertHashString()); + } + + return thumbprintMatch; + } + }; + } + #region App Management /// @@ -367,8 +428,8 @@ public async ValueTask DisposeAsync() if (_disposed) return; _disposed = true; + await _channel.ShutdownAsync(); _channel.Dispose(); - await Task.CompletedTask; } #endregion diff --git a/UiAutomationGRPC.Library/UiAutomationGRPC.Library.csproj b/UiAutomationGRPC.Library/UiAutomationGRPC.Library.csproj index ee76fbf..d296877 100644 --- a/UiAutomationGRPC.Library/UiAutomationGRPC.Library.csproj +++ b/UiAutomationGRPC.Library/UiAutomationGRPC.Library.csproj @@ -26,6 +26,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive