using System.Drawing;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
using FlaUI.UIA3;
using XFEExtension.NetCore.InputSimulator;
using XFEWorkflow.Abstractions;
namespace XFEWorkflow.Plugins.Desktop;
public sealed class DesktopPlugin : IWorkflowPlugin
{
public static PluginManifest Manifest
{
get
{
using var stream = typeof(DesktopPlugin).Assembly.GetManifestResourceStream("XFEWorkflow.Plugins.Desktop.plugin.json")!;
return JsonSerializer.Deserialize<PluginManifest>(stream, WorkflowJson.Options)!;
}
}
public void Register(IPluginRegistry registry)
{
foreach (var descriptor in Manifest.Capabilities) registry.RegisterAction(descriptor, () => new DesktopAction(descriptor.Id));
}
}
public sealed class DesktopAction(string id) : IWorkflowAction
{
public async ValueTask<JsonObject> ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct)
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
if (id is "xfe.desktop.text" or "xfe.desktop.keys" or "xfe.desktop.sequence")
{
var parameters = WorkflowJson.Copy(input); parameters["operation"] = id["xfe.desktop.".Length..];
return await context.InvokeHostAsync("desktop.input", parameters, ct);
}
using var automation = new UIA3Automation();
ct.ThrowIfCancellationRequested();
if (id == "xfe.desktop.pick")
{
await Task.Delay(Math.Max(0, input.Integer("delaySeconds", 3)) * 1000, ct);
GetCursorPos(out var point);
return Describe(automation.FromPoint(new Point(point.X, point.Y)));
}
var root = automation.GetDesktop();
var processId = input.Integer("processId"); var windowName = input.String("window");
var window = processId > 0
? root.FindAllChildren(c => c.ByProcessId(processId)).FirstOrDefault(x => x.ControlType == ControlType.Window && (windowName.Length == 0 || x.Name == windowName))
: windowName.Length > 0 ? root.FindFirstChild(c => c.ByName(windowName)) : null;
if (window is null) throw new InvalidOperationException("未找到目标窗口");
if (id == "xfe.desktop.activate") { window.AsWindow().Focus(); return Describe(window); }
AutomationElement? element = window;
if (input.String("automationId") is { Length: > 0 } automationId) element = window.FindFirstDescendant(c => c.ByAutomationId(automationId));
else if (input.String("name") is { Length: > 0 } name) element = window.FindFirstDescendant(c => c.ByName(name));
if (element is null) throw new InvalidOperationException("未找到目标控件");
if (id == "xfe.desktop.read")
{
var result = Describe(element);
if (element.Patterns.Value.IsSupported) result["text"] = element.Patterns.Value.Pattern.Value.Value;
else if (element.Patterns.Text.IsSupported) result["text"] = element.Patterns.Text.Pattern.DocumentRange.GetText(-1);
else result["text"] = element.Name;
return result;
}
window.AsWindow().Focus(); element.Focus();
if (id == "xfe.desktop.fill")
{
if (element.Patterns.Value.IsSupported) element.Patterns.Value.Pattern.SetValue(input.String("text"));
else await context.InvokeHostAsync("desktop.input", new() { ["operation"] = "text", ["text"] = input.String("text") }, ct);
return Describe(element);
}
if (id == "xfe.desktop.click")
{
if (element.Patterns.Invoke.IsSupported) element.Patterns.Invoke.Pattern.Invoke();
else
{
var bounds = element.BoundingRectangle;
await context.InvokeHostAsync("desktop.input", new() { ["operation"] = "click", ["x"] = (int)(bounds.Left + bounds.Width / 2), ["y"] = (int)(bounds.Top + bounds.Height / 2) }, ct);
}
return Describe(element);
}
throw new NotSupportedException(id);
}
private static JsonObject Describe(AutomationElement element) => new()
{
["name"] = element.Name, ["automationId"] = element.AutomationId, ["processId"] = element.Properties.ProcessId.ValueOrDefault,
["controlType"] = element.ControlType.ToString(),
["x"] = element.BoundingRectangle.X, ["y"] = element.BoundingRectangle.Y,
["width"] = element.BoundingRectangle.Width, ["height"] = element.BoundingRectangle.Height
};
[DllImport("user32.dll")] private static extern bool GetCursorPos(out NativePoint point);
[StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X; public int Y; }
}
// The desktop host owns the input controller so killing a Runner can still release every held key.
public sealed class DesktopInputService : IDisposable
{
private InputController? controller;
private string owner = "";
private readonly SemaphoreSlim gate = new(1);
private CancellationTokenSource? emergency;
public string Backend { get; set; } = "standard";
public async ValueTask<JsonObject> ExecuteAsync(string runId, JsonObject input, CancellationToken ct)
{
await gate.WaitAsync(ct);
try
{
if (owner != runId)
{
controller?.Dispose(); emergency?.Dispose();
emergency = new(); owner = runId;
controller = new InputController(Backend == "hid" ? new DriverBackend() : new WindowsInputBackend());
}
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, emergency!.Token);
var token = linked.Token;
async Task Step(JsonObject step)
{
token.ThrowIfCancellationRequested();
switch (step.String("operation"))
{
case "text": await controller!.TypeTextAsync(step.String("text"), step.Integer("delay"), token); break;
case "keys":
var keys = step.String("keys").Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(x => Enum.Parse<ScanCode>(x, true)).ToArray();
await controller!.PressCombinationAsync(keys, Math.Max(0, step.Integer("holdTime", 30)), token); break;
case "down": controller!.KeyDown(Enum.Parse<ScanCode>(step.String("key"), true)); break;
case "up": controller!.KeyUp(Enum.Parse<ScanCode>(step.String("key"), true)); break;
case "move": controller!.MouseMove(step.Integer("x"), step.Integer("y")); break;
case "locate": controller!.LocateTo(step.Integer("x"), step.Integer("y")); break;
case "click":
controller!.LocateTo(step.Integer("x"), step.Integer("y"));
var button = ParseButton(step);
if (step.Integer("clicks", 1) == 2) await controller.MouseDoubleClickAsync(button, 80, token);
else await controller.MouseClickAsync(button, 30, token);
break;
case "drag":
await controller!.MouseDragAsync(new Point(step.Integer("fromX"), step.Integer("fromY")), new Point(step.Integer("toX"), step.Integer("toY")), Math.Clamp(step.Integer("durationMilliseconds", 500), 0, 60000), ParseButton(step), token);
break;
case "scroll":
if (step.Boolean("horizontal")) controller!.MouseHorizontalWheelRoll(step.Integer("delta", 120));
else controller!.MouseWheelRoll(step.Integer("delta", 120));
break;
case "delay": await Task.Delay(Math.Clamp(step.Integer("milliseconds"), 0, 3_600_000), token); break;
default: throw new NotSupportedException("未知输入步骤");
}
}
try
{
if (input.String("operation") == "sequence")
foreach (var step in input["steps"]?.AsArray() ?? []) await Step(step!.AsObject());
else await Step(input);
return new() { ["backend"] = controller!.BackendName };
}
finally { controller?.ReleaseAll(); }
}
finally { gate.Release(); }
}
public void EmergencyStop() { emergency?.Cancel(); controller?.ReleaseAll(); }
private static MouseButton ParseButton(JsonObject step) => step.String("button", "left") switch
{ "left" => MouseButton.Left, "right" => MouseButton.Right, "middle" => MouseButton.Middle, _ => throw new ArgumentException("鼠标按键无效") };
public async ValueTask ReleaseAsync(string runId)
{
if (runId != owner) return;
emergency?.Cancel();
await gate.WaitAsync();
try { controller?.Dispose(); controller = null; owner = ""; emergency?.Dispose(); emergency = null; }
finally { gate.Release(); }
}
public void Dispose() { EmergencyStop(); controller?.Dispose(); emergency?.Dispose(); }
}
public sealed class WindowsInputBackend : IInputBackend
{
public string Name => "Windows SendInput";
public bool SupportsUnicodeText => true;
public void SendKey(ScanCode key, bool isKeyUp)
{
var code = (ushort)key;
Send(new() { Type = 1, Data = new() { Keyboard = new() { Scan = (ushort)(code & 0xff), Flags = 8u | (isKeyUp ? 2u : 0u) | ((code & 0xff00) == 0xe000 ? 1u : 0u) } } });
}
public void SendUnicode(char codeUnit, bool isKeyUp) =>
Send(new() { Type = 1, Data = new() { Keyboard = new() { Scan = codeUnit, Flags = 4u | (isKeyUp ? 2u : 0u) } } });
public void MoveMouse(int x, int y, bool absolute, bool virtualDesktop) =>
Send(new() { Data = new() { Mouse = new() { X = x, Y = y, Flags = 1u | (absolute ? 0x8000u : 0u) | (virtualDesktop ? 0x4000u : 0u) } } });
public void SendMouseButton(MouseButton button, bool isKeyUp)
{
var (down, up, data) = button switch
{
MouseButton.Left => (2u, 4u, 0u), MouseButton.Right => (8u, 16u, 0u), MouseButton.Middle => (32u, 64u, 0u),
MouseButton.XButton1 => (128u, 256u, 1u), MouseButton.XButton2 => (128u, 256u, 2u), _ => throw new ArgumentOutOfRangeException(nameof(button))
};
Send(new() { Data = new() { Mouse = new() { Flags = isKeyUp ? up : down, MouseData = data } } });
}
public void ScrollMouse(int delta, bool horizontal) => Send(new() { Data = new() { Mouse = new() { Flags = horizontal ? 0x1000u : 0x800u, MouseData = unchecked((uint)delta) } } });
public void Dispose() { }
private static void Send(Input input)
{
if (SendInput(1, [input], Marshal.SizeOf<Input>()) != 1) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Windows 输入被拒绝;请检查目标进程权限");
}
[DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint count, Input[] inputs, int size);
[StructLayout(LayoutKind.Sequential)] private struct Input { public uint Type; public InputUnion Data; }
[StructLayout(LayoutKind.Explicit)] private struct InputUnion
{ [FieldOffset(0)] public MouseInput Mouse; [FieldOffset(0)] public KeyboardInput Keyboard; }
[StructLayout(LayoutKind.Sequential)] private struct MouseInput { public int X, Y; public uint MouseData, Flags, Time; public nuint ExtraInfo; }
[StructLayout(LayoutKind.Sequential)] private struct KeyboardInput { public ushort VirtualKey, Scan; public uint Flags, Time; public nuint ExtraInfo; }
}
using System.Drawing;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
using FlaUI.Core.AutomationElements;
using FlaUI.Core.Definitions;
using FlaUI.UIA3;
using XFEExtension.NetCore.InputSimulator;
using XFEWorkflow.Abstractions;
namespace XFEWorkflow.Plugins.Desktop;
public sealed class DesktopPlugin : IWorkflowPlugin
{
public static PluginManifest Manifest
{
get
{
using var stream = typeof(DesktopPlugin).Assembly.GetManifestResourceStream("XFEWorkflow.Plugins.Desktop.plugin.json")!;
return JsonSerializer.Deserialize<PluginManifest>(stream, WorkflowJson.Options)!;
}
}
public void Register(IPluginRegistry registry)
{
foreach (var descriptor in Manifest.Capabilities) registry.RegisterAction(descriptor, () => new DesktopAction(descriptor.Id));
}
}
public sealed class DesktopAction(string id) : IWorkflowAction
{
public async ValueTask<JsonObject> ExecuteAsync(JsonObject input, IWorkflowContext context, CancellationToken ct)
{
await context.InvokeHostAsync("desktop.acquire", [], ct);
if (id is "xfe.desktop.text" or "xfe.desktop.keys" or "xfe.desktop.sequence")
{
var parameters = WorkflowJson.Copy(input); parameters["operation"] = id["xfe.desktop.".Length..];
return await context.InvokeHostAsync("desktop.input", parameters, ct);
}
using var automation = new UIA3Automation();
ct.ThrowIfCancellationRequested();
if (id == "xfe.desktop.pick")
{
await Task.Delay(Math.Max(0, input.Integer("delaySeconds", 3)) * 1000, ct);
GetCursorPos(out var point);
return Describe(automation.FromPoint(new Point(point.X, point.Y)));
}
var root = automation.GetDesktop();
var processId = input.Integer("processId"); var windowName = input.String("window");
var window = processId > 0
? root.FindAllChildren(c => c.ByProcessId(processId)).FirstOrDefault(x => x.ControlType == ControlType.Window && (windowName.Length == 0 || x.Name == windowName))
: windowName.Length > 0 ? root.FindFirstChild(c => c.ByName(windowName)) : null;
if (window is null) throw new InvalidOperationException("未找到目标窗口");
if (id == "xfe.desktop.activate") { window.AsWindow().Focus(); return Describe(window); }
AutomationElement? element = window;
if (input.String("automationId") is { Length: > 0 } automationId) element = window.FindFirstDescendant(c => c.ByAutomationId(automationId));
else if (input.String("name") is { Length: > 0 } name) element = window.FindFirstDescendant(c => c.ByName(name));
if (element is null) throw new InvalidOperationException("未找到目标控件");
if (id == "xfe.desktop.read")
{
var result = Describe(element);
if (element.Patterns.Value.IsSupported) result["text"] = element.Patterns.Value.Pattern.Value.Value;
else if (element.Patterns.Text.IsSupported) result["text"] = element.Patterns.Text.Pattern.DocumentRange.GetText(-1);
else result["text"] = element.Name;
return result;
}
window.AsWindow().Focus(); element.Focus();
if (id == "xfe.desktop.fill")
{
if (element.Patterns.Value.IsSupported) element.Patterns.Value.Pattern.SetValue(input.String("text"));
else await context.InvokeHostAsync("desktop.input", new() { ["operation"] = "text", ["text"] = input.String("text") }, ct);
return Describe(element);
}
if (id == "xfe.desktop.click")
{
if (element.Patterns.Invoke.IsSupported) element.Patterns.Invoke.Pattern.Invoke();
else
{
var bounds = element.BoundingRectangle;
await context.InvokeHostAsync("desktop.input", new() { ["operation"] = "click", ["x"] = (int)(bounds.Left + bounds.Width / 2), ["y"] = (int)(bounds.Top + bounds.Height / 2) }, ct);
}
return Describe(element);
}
throw new NotSupportedException(id);
}
private static JsonObject Describe(AutomationElement element) => new()
{
["name"] = element.Name, ["automationId"] = element.AutomationId, ["processId"] = element.Properties.ProcessId.ValueOrDefault,
["controlType"] = element.ControlType.ToString(),
["x"] = element.BoundingRectangle.X, ["y"] = element.BoundingRectangle.Y,
["width"] = element.BoundingRectangle.Width, ["height"] = element.BoundingRectangle.Height
};
[DllImport("user32.dll")] private static extern bool GetCursorPos(out NativePoint point);
[StructLayout(LayoutKind.Sequential)] private struct NativePoint { public int X; public int Y; }
}
// The desktop host owns the input controller so killing a Runner can still release every held key.
public sealed class DesktopInputService : IDisposable
{
private InputController? controller;
private string owner = "";
private readonly SemaphoreSlim gate = new(1);
private CancellationTokenSource? emergency;
public string Backend { get; set; } = "standard";
public async ValueTask<JsonObject> ExecuteAsync(string runId, JsonObject input, CancellationToken ct)
{
await gate.WaitAsync(ct);
try
{
if (owner != runId)
{
controller?.Dispose(); emergency?.Dispose();
emergency = new(); owner = runId;
controller = new InputController(Backend == "hid" ? new DriverBackend() : new WindowsInputBackend());
}
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, emergency!.Token);
var token = linked.Token;
async Task Step(JsonObject step)
{
token.ThrowIfCancellationRequested();
switch (step.String("operation"))
{
case "text": await controller!.TypeTextAsync(step.String("text"), step.Integer("delay"), token); break;
case "keys":
var keys = step.String("keys").Split('+', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)
.Select(x => Enum.Parse<ScanCode>(x, true)).ToArray();
await controller!.PressCombinationAsync(keys, Math.Max(0, step.Integer("holdTime", 30)), token); break;
case "down": controller!.KeyDown(Enum.Parse<ScanCode>(step.String("key"), true)); break;
case "up": controller!.KeyUp(Enum.Parse<ScanCode>(step.String("key"), true)); break;
case "move": controller!.MouseMove(step.Integer("x"), step.Integer("y")); break;
case "locate": controller!.LocateTo(step.Integer("x"), step.Integer("y")); break;
case "click":
controller!.LocateTo(step.Integer("x"), step.Integer("y"));
var button = ParseButton(step);
if (step.Integer("clicks", 1) == 2) await controller.MouseDoubleClickAsync(button, 80, token);
else await controller.MouseClickAsync(button, 30, token);
break;
case "drag":
await controller!.MouseDragAsync(new Point(step.Integer("fromX"), step.Integer("fromY")), new Point(step.Integer("toX"), step.Integer("toY")), Math.Clamp(step.Integer("durationMilliseconds", 500), 0, 60000), ParseButton(step), token);
break;
case "scroll":
if (step.Boolean("horizontal")) controller!.MouseHorizontalWheelRoll(step.Integer("delta", 120));
else controller!.MouseWheelRoll(step.Integer("delta", 120));
break;
case "delay": await Task.Delay(Math.Clamp(step.Integer("milliseconds"), 0, 3_600_000), token); break;
default: throw new NotSupportedException("未知输入步骤");
}
}
try
{
if (input.String("operation") == "sequence")
foreach (var step in input["steps"]?.AsArray() ?? []) await Step(step!.AsObject());
else await Step(input);
return new() { ["backend"] = controller!.BackendName };
}
finally { controller?.ReleaseAll(); }
}
finally { gate.Release(); }
}
public void EmergencyStop() { emergency?.Cancel(); controller?.ReleaseAll(); }
private static MouseButton ParseButton(JsonObject step) => step.String("button", "left") switch
{ "left" => MouseButton.Left, "right" => MouseButton.Right, "middle" => MouseButton.Middle, _ => throw new ArgumentException("鼠标按键无效") };
public async ValueTask ReleaseAsync(string runId)
{
if (runId != owner) return;
emergency?.Cancel();
await gate.WaitAsync();
try { controller?.Dispose(); controller = null; owner = ""; emergency?.Dispose(); emergency = null; }
finally { gate.Release(); }
}
public void Dispose() { EmergencyStop(); controller?.Dispose(); emergency?.Dispose(); }
}
public sealed class WindowsInputBackend : IInputBackend
{
public string Name => "Windows SendInput";
public bool SupportsUnicodeText => true;
public void SendKey(ScanCode key, bool isKeyUp)
{
var code = (ushort)key;
Send(new() { Type = 1, Data = new() { Keyboard = new() { Scan = (ushort)(code & 0xff), Flags = 8u | (isKeyUp ? 2u : 0u) | ((code & 0xff00) == 0xe000 ? 1u : 0u) } } });
}
public void SendUnicode(char codeUnit, bool isKeyUp) =>
Send(new() { Type = 1, Data = new() { Keyboard = new() { Scan = codeUnit, Flags = 4u | (isKeyUp ? 2u : 0u) } } });
public void MoveMouse(int x, int y, bool absolute, bool virtualDesktop) =>
Send(new() { Data = new() { Mouse = new() { X = x, Y = y, Flags = 1u | (absolute ? 0x8000u : 0u) | (virtualDesktop ? 0x4000u : 0u) } } });
public void SendMouseButton(MouseButton button, bool isKeyUp)
{
var (down, up, data) = button switch
{
MouseButton.Left => (2u, 4u, 0u), MouseButton.Right => (8u, 16u, 0u), MouseButton.Middle => (32u, 64u, 0u),
MouseButton.XButton1 => (128u, 256u, 1u), MouseButton.XButton2 => (128u, 256u, 2u), _ => throw new ArgumentOutOfRangeException(nameof(button))
};
Send(new() { Data = new() { Mouse = new() { Flags = isKeyUp ? up : down, MouseData = data } } });
}
public void ScrollMouse(int delta, bool horizontal) => Send(new() { Data = new() { Mouse = new() { Flags = horizontal ? 0x1000u : 0x800u, MouseData = unchecked((uint)delta) } } });
public void Dispose() { }
private static void Send(Input input)
{
if (SendInput(1, [input], Marshal.SizeOf<Input>()) != 1) throw new System.ComponentModel.Win32Exception(Marshal.GetLastWin32Error(), "Windows 输入被拒绝;请检查目标进程权限");
}
[DllImport("user32.dll", SetLastError = true)] private static extern uint SendInput(uint count, Input[] inputs, int size);
[StructLayout(LayoutKind.Sequential)] private struct Input { public uint Type; public InputUnion Data; }
[StructLayout(LayoutKind.Explicit)] private struct InputUnion
{ [FieldOffset(0)] public MouseInput Mouse; [FieldOffset(0)] public KeyboardInput Keyboard; }
[StructLayout(LayoutKind.Sequential)] private struct MouseInput { public int X, Y; public uint MouseData, Flags, Time; public nuint ExtraInfo; }
[StructLayout(LayoutKind.Sequential)] private struct KeyboardInput { public ushort VirtualKey, Scan; public uint Flags, Time; public nuint ExtraInfo; }
}