Implement recorder PoC (#6)

This commit is contained in:
minsung
2026-04-07 14:27:46 +09:00
parent e3d2ff6c77
commit d486cbb4d9
14 changed files with 996 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using FlaUI.Core;
using FlaUI.Core.AutomationElements;
using FlaUI.UIA3;
namespace Recordingtest.Recorder;
public static class Program
{
public static int Main(string[] args)
{
var parsed = ParseArgs(args);
if (parsed is null)
{
PrintUsage();
return 2;
}
Console.WriteLine($"[recorder] output={parsed.OutputPath} attach={parsed.Attach}");
try
{
return Run(parsed);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[recorder] error: {ex.Message}");
return 1;
}
}
internal sealed record CliArgs(string OutputPath, string Attach);
internal static CliArgs? ParseArgs(string[] args)
{
string? output = null;
string? attach = null;
for (int i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--output" when i + 1 < args.Length:
output = args[++i];
break;
case "--attach" when i + 1 < args.Length:
attach = args[++i];
break;
}
}
if (string.IsNullOrEmpty(attach)) return null;
if (string.IsNullOrEmpty(output)) output = "scenarios/recorded.yaml";
return new CliArgs(output!, attach!);
}
internal static void PrintUsage()
{
Console.Error.WriteLine("Usage: Recordingtest.Recorder --output scenarios/<name>.yaml --attach <pid|title>");
Console.Error.WriteLine(" --attach is REQUIRED. The recorder never launches the SUT itself.");
}
private static int Run(CliArgs args)
{
var channel = Channel.CreateUnbounded<RawEvent>();
using var hook = new LowLevelHook(channel);
hook.Start();
Application? app = null;
UIA3Automation? automation = null;
AutomationElement? mainWindow = null;
try
{
(app, automation, mainWindow) = TryAttach(args.Attach);
}
catch (Exception ex)
{
Console.Error.WriteLine($"[recorder] attach failed: {ex.Message}");
}
var scenario = new Scenario
{
Name = System.IO.Path.GetFileNameWithoutExtension(args.OutputPath),
Description = "Recorded session",
};
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
Console.WriteLine("[recorder] capturing... press Ctrl+C to stop.");
int eventCount = 0;
int unresolved = 0;
var sw = Stopwatch.StartNew();
try
{
ConsumeAsync(channel.Reader, scenario, mainWindow, automation, cts.Token,
onEvent: () => eventCount++,
onUnresolved: () => unresolved++).GetAwaiter().GetResult();
}
catch (OperationCanceledException)
{
// expected on Ctrl+C
}
sw.Stop();
ScenarioWriter.WriteToFile(scenario, args.OutputPath);
Console.WriteLine($"[recorder] done. events={eventCount} elapsed={sw.Elapsed} unresolved_paths={unresolved}");
automation?.Dispose();
return 0;
}
private static (Application?, UIA3Automation?, AutomationElement?) TryAttach(string attach)
{
// NOTE: We never Launch() the SUT here. Only attach by pid or window title.
Application? app = null;
if (int.TryParse(attach, out var pid))
{
app = Application.Attach(pid);
}
else
{
var procs = Process.GetProcesses();
foreach (var p in procs)
{
try
{
if (!string.IsNullOrEmpty(p.MainWindowTitle) &&
p.MainWindowTitle.Contains(attach, StringComparison.OrdinalIgnoreCase))
{
app = Application.Attach(p.Id);
break;
}
}
catch
{
// ignore inaccessible processes
}
}
}
if (app is null) return (null, null, null);
var automation = new UIA3Automation();
var main = app.GetMainWindow(automation, TimeSpan.FromSeconds(5));
return (app, automation, main);
}
private static async Task ConsumeAsync(
ChannelReader<RawEvent> reader,
Scenario scenario,
AutomationElement? mainWindow,
UIA3Automation? automation,
CancellationToken ct,
Action onEvent,
Action onUnresolved)
{
while (await reader.WaitToReadAsync(ct).ConfigureAwait(false))
{
while (reader.TryRead(out var ev))
{
onEvent();
if (!IsInterestingForStep(ev.Kind)) continue;
IElementSnapshot? snap = null;
if (automation is not null)
{
try
{
snap = ResolveAt(automation, ev.X, ev.Y);
}
catch
{
snap = null;
}
}
if (snap is null)
{
onUnresolved();
continue;
}
var path = ElementPathBuilder.Build(snap);
var (dx, dy) = OffsetNormalizer.Normalize(snap.BoundingRectangle, ev.X, ev.Y);
var step = new ScenarioStep
{
Kind = ev.Kind.StartsWith("key", StringComparison.Ordinal) ? "type" : "click",
Target = new ScenarioTarget
{
UiaPath = path,
Offset = new[] { dx, dy },
},
Value = MaskPolicy.IsMasked(snap) ? MaskPolicy.MaskedValue : null,
};
scenario.Steps.Add(step);
}
}
}
private static bool IsInterestingForStep(string kind) =>
kind == "mouse_down_l" || kind == "mouse_down_r" || kind == "key_down";
private static IElementSnapshot? ResolveAt(UIA3Automation automation, int x, int y)
{
var raw = automation.FromPoint(new System.Drawing.Point(x, y));
if (raw is null) return null;
return new FlaUiSnapshot(raw);
}
}
/// <summary>
/// Adapter wrapping a FlaUI AutomationElement as IElementSnapshot.
/// Resolved on demand from the main loop (never from the hook thread).
/// </summary>
internal sealed class FlaUiSnapshot : IElementSnapshot
{
private readonly AutomationElement _el;
private readonly FlaUiSnapshot? _parentSnap;
public FlaUiSnapshot(AutomationElement el, FlaUiSnapshot? parentSnap = null)
{
_el = el;
_parentSnap = parentSnap;
}
public string ClassName => SafeGet(() => _el.ClassName ?? string.Empty);
public string? AutomationId => SafeGet(() => _el.AutomationId);
public string? Name => SafeGet(() => _el.Name);
public bool IsPassword
{
get
{
try
{
var ct = _el.ControlType;
if (ct == FlaUI.Core.Definitions.ControlType.Edit &&
string.Equals(_el.ClassName, "PasswordBox", StringComparison.Ordinal))
{
return true;
}
}
catch
{
// ignore
}
return false;
}
}
public (double Left, double Top, double Width, double Height) BoundingRectangle
{
get
{
try
{
var r = _el.BoundingRectangle;
return (r.Left, r.Top, r.Width, r.Height);
}
catch
{
return (0, 0, 0, 0);
}
}
}
public IElementSnapshot? Parent
{
get
{
if (_parentSnap is not null) return _parentSnap;
try
{
var p = _el.Parent;
return p is null ? null : new FlaUiSnapshot(p);
}
catch
{
return null;
}
}
}
private static T SafeGet<T>(Func<T> f)
{
try { return f(); } catch { return default!; }
}
}