PRA-only-demo/contrib/windows-runner-actuate/native/ActuateService.cs

280 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.ComponentModel;
namespace AgentWorker.WindowsRunner;
// actuate 执行服务:把 RPA 桌面驱动(PowerShell 实现)的鼠标/键盘/窗口操作
// 移植进对面 windows-runner 协议,安全门与 observe 完全同构。
// 移植来源:packages/rpa-core/src/driver/windows/windows-driver.ts(对照表见 PORTING-MAP.md)。
//
// 安全边界(与 WindowObservationService 同级):
// - 进程白名单(文件名正则)+ 窗口精确匹配 + OS Session 三重核验后才允许输入;
// - 只操作本用户交互 Session 的一个顶层窗口,不扫描其它桌面;
// - 后续动作前仍重新枚举窗口,不信任上一次请求的 HWND;
// - 副作用证据:动作前后各一张目标窗口 PNG(PrintWindow + SHA-256,默认 secret);
// - 非法 Kind 拒绝执行,不静默降级为观察;
// - 稳定错误码进 stdout,系统路径与控件文字不回显。
internal static partial class ActuateService
{
[GeneratedRegex("^[A-Za-z0-9._-]+\\.exe$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessNamePattern();
private const long MaxWindowPixels = 16_000_000;
private const long MaxPngBytes = 8 * 1024 * 1024;
private sealed record WindowCandidate(nint Handle, string ProcessName, string Title);
// DPI 感知一次性声明:与 RPA 驱动每脚本 SetProcessDpiAwarenessContext(-4) 等价,
// 保证此后 SetCursorPos/GetCursorPos 全程物理像素语义。
internal static ActuateResult Actuate(ActuateRequest request, string evidenceRoot)
{
NativeInput.EnsureDpiAwareness();
ValidatePolicy(request);
var selected = SelectWindow(request.WindowTitle, request.Policy);
if (!NativeWindows.GetWindowRect(selected.Handle, out var nativeRect))
throw new RunnerException("window_unavailable");
var bounds = new WindowRect(nativeRect.Left, nativeRect.Top,
nativeRect.Right - nativeRect.Left, nativeRect.Bottom - nativeRect.Top);
if (bounds.Width < 1 || bounds.Height < 1 ||
(long)bounds.Width * bounds.Height > MaxWindowPixels)
throw new RunnerException("window_size_unsupported");
var startedAt = DateTimeOffset.UtcNow;
var before = CaptureWindowEvidence(selected.Handle, bounds, evidenceRoot, startedAt);
Execute(request.Action, selected);
var finishedAt = DateTimeOffset.UtcNow;
var after = CaptureWindowEvidence(selected.Handle, bounds, evidenceRoot, finishedAt);
NativeWindows.GetWindowThreadProcessId(selected.Handle, out var pid);
var observed = new ObservedWindow(selected.ProcessName, selected.Title,
$"0x{selected.Handle.ToInt64():X}",
selected.Handle == NativeWindows.GetForegroundWindow());
return new ActuateResult(request.Action.Kind, finishedAt.ToString("O", CultureInfo.InvariantCulture),
observed, [before, after],
(int)(finishedAt - startedAt).TotalMilliseconds);
}
// 与 observe 的 ValidatePolicy 同构:两个白名单均非空、进程名仅接受文件名、
// WindowTitle 精确成员、OS Session 与本进程一致。actuate 不放宽任何一项。
private static void ValidatePolicy(ActuateRequest request)
{
var policy = request.Policy;
if (policy is null || policy.AllowedProcesses is null || policy.AllowedWindows is null ||
policy.AllowedProcesses.Length == 0 || policy.AllowedWindows.Length == 0 ||
policy.AllowedProcesses.Any(name => !ProcessNamePattern().IsMatch(name) || name.Contains("..")) ||
policy.AllowedWindows.Any(title => string.IsNullOrWhiteSpace(title) || title.Length > 256))
throw new RunnerException("policy_invalid");
if (string.IsNullOrWhiteSpace(request.WindowTitle) || request.WindowTitle.Length > 256 ||
!policy.AllowedWindows.Contains(request.WindowTitle, StringComparer.Ordinal))
throw new RunnerException("window_not_allowed");
var currentSession = Process.GetCurrentProcess().SessionId;
if (currentSession == 0 || policy.DesktopSessionId != currentSession.ToString(CultureInfo.InvariantCulture))
throw new RunnerException("desktop_session_mismatch");
if (request.Action is null || string.IsNullOrWhiteSpace(request.Action.Kind))
throw new RunnerException("action_invalid");
}
// 与 observe 的 SelectWindow 同构:枚举本 Session 顶层窗口,精确标题 + PID 回查进程名。
// 同名同进程窗口多于一个时报 ambiguous,不任选——副作用落错对象不可接受。
private static WindowCandidate SelectWindow(string title, DesktopPolicy policy)
{
var candidates = new List<WindowCandidate>();
var session = Process.GetCurrentProcess().SessionId;
_ = NativeWindows.EnumWindows((handle, _) =>
{
if (!NativeWindows.IsWindowVisible(handle)) return true;
if (NativeWindows.Title(handle) != title) return true;
NativeWindows.GetWindowThreadProcessId(handle, out var pid);
if (pid == 0) return true;
try
{
using var process = Process.GetProcessById(checked((int)pid));
if (process.SessionId != session) return true;
var name = process.ProcessName + ".exe";
if (!policy.AllowedProcesses.Contains(name, StringComparer.OrdinalIgnoreCase))
return true;
candidates.Add(new WindowCandidate(handle, name, title));
}
catch (ArgumentException) { /* 进程已退出;旧 HWND 不能成为目标。 */ }
catch (InvalidOperationException) { /* 进程在枚举期间退出。 */ }
catch (Win32Exception) { /* 受保护进程不能读取。 */ }
return true;
}, nint.Zero);
if (candidates.Count == 0) throw new RunnerException("window_not_found");
if (candidates.Count != 1) throw new RunnerException("window_ambiguous");
return candidates[0];
}
// 动作闭集分发;RPA 驱动 windows-driver.ts 各方法的逐段移植(见 PORTING-MAP.md)。
private static void Execute(ActuateAction action, WindowCandidate window)
{
switch (action.Kind)
{
case "mouse_move":
RequirePoint(action);
if (!NativeInput.SetCursorPos(action.X!.Value, action.Y!.Value))
throw new RunnerException("input_failed");
break;
case "mouse_click":
RequirePoint(action);
if (!NativeInput.SetCursorPos(action.X!.Value, action.Y!.Value))
throw new RunnerException("input_failed");
NativeInput.Click(action.Button == "right", action.Double == true ? 2 : 1,
Math.Max(0, action.SettleMs ?? 0));
break;
case "mouse_drag":
RequirePoint(action);
if (action.ToX is null || action.ToY is null) throw new RunnerException("action_invalid");
NativeInput.DragSequence(action.Button == "right", action.X!.Value, action.Y!.Value,
action.ToX.Value, action.ToY.Value,
Math.Max(0, action.HoldMs ?? 80), Math.Max(0, action.ReleaseDelayMs ?? 80));
break;
case "mouse_scroll":
if (action.Amount is null || action.Amount == 0) throw new RunnerException("action_invalid");
NativeInput.SendWheel(action.Amount.Value);
break;
case "mouse_move_smooth":
RequirePoint(action);
SmoothMove(action);
break;
case "keyboard_send":
if (string.IsNullOrEmpty(action.Text)) throw new RunnerException("action_invalid");
// 输入必须落在已核验窗口:先抢前台(五招兜底),对应 RPA 驱动
// activateTitle 选项 + 300ms 间隙;窗口已在前台时五招直接通过。
NativeInput.FocusChain(window.Handle);
Thread.Sleep(300);
SendKeyboard(action);
break;
case "window_activate":
NativeInput.FocusChain(window.Handle);
break;
case "window_set_state":
if (action.Amount is null) throw new RunnerException("action_invalid");
_ = NativeInput.ShowWindow(window.Handle, action.Amount.Value);
break;
case "window_set_bounds":
if (action.X is null || action.Y is null ||
action.Width is null || action.Height is null ||
action.Width < 1 || action.Height < 1)
throw new RunnerException("action_invalid");
NativeInput.MoveWindowBounds(window.Handle, action.X.Value, action.Y.Value,
action.Width.Value, action.Height.Value);
break;
case "window_close":
NativeInput.CloseWindow(window.Handle);
break;
default:
throw new RunnerException("action_invalid");
}
}
private static void RequirePoint(ActuateAction action)
{
if (action.X is null || action.Y is null) throw new RunnerException("action_invalid");
}
// keyboard 三模式(RPA 驱动 KeyboardDriver.send 的移植):
// clipboard:原文进剪贴板 + Ctrl+V(不做 SendKeys 转义,适合长文本);
// chars:逐字符 SendWait(每个字符按 SendKeys 规则转义,intervalMs 控速);
// keys:整段 SendWait 原样发送(保留 ^+% 等组合语义,供按键组合类能力使用)。
private static void SendKeyboard(ActuateAction action)
{
switch (action.Mode)
{
case "clipboard":
Clipboard.SetDataObject(action.Text!, true);
Thread.Sleep(120);
SendKeys.SendWait("^v");
break;
case "chars":
var interval = Math.Max(0, action.IntervalMs ?? 30);
foreach (var character in action.Text!)
{
SendKeys.SendWait(EscapeSendKeys(character));
if (interval > 0) Thread.Sleep(interval);
}
break;
default: // "keys"
SendKeys.SendWait(action.Text!);
break;
}
}
// RPA 驱动 sendKeysEscape 的逐字符移植:SendKeys 特殊字符用花括号表示字面量。
private static string EscapeSendKeys(char character) =>
"+^%~(){}[]".Contains(character) ? $"{{{character}}}" : character.ToString();
// mouse.moveSmooth 的移植:从当前光标位置出发,按 RPA 驱动 mousePath 公式插值。
// 公式(缓入缓出 + 垂直抖动)逐行保真;JS 与 C# 的 % 对负数同为保号语义。
private static void SmoothMove(ActuateAction action)
{
var from = NativeInput.CursorPosition();
var toX = action.X!.Value;
var toY = action.Y!.Value;
var dx = toX - from.X;
var dy = toY - from.Y;
var distance = Math.Sqrt((double)dx * dx + (double)dy * dy);
if (distance < 2) return;
var stepCount = Math.Max(6, Math.Min(50, (int)Math.Round(distance / 12)));
var ease = action.Ease != false;
var speed = Math.Clamp(action.Speed ?? 35, 1, 100);
var delay = Math.Clamp((int)Math.Round(1500.0 / speed), 4, 80);
for (var i = 1; i <= stepCount; i++)
{
var t = (double)i / stepCount;
var easeFactor = ease ? (t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2) : t;
var jitter = ease ? Math.Sin(t * Math.PI) * (Math.Sin(i * 12.9898) * 43758.5453 % 1) * 3 : 0;
var x = (int)Math.Round(from.X + dx * easeFactor - (dy / distance) * jitter);
var y = (int)Math.Round(from.Y + dy * easeFactor + (dx / distance) * jitter);
if (!NativeInput.SetCursorPos(x, y)) throw new RunnerException("input_failed");
Thread.Sleep(delay);
}
if (!NativeInput.SetCursorPos(toX, toY)) throw new RunnerException("input_failed");
}
// 证据采集:与 observe 的 CaptureWindow 同构(PrintWindow → PNG → SHA-256)。
// actuate 始终留证据(副作用审计需要);PrintWindow 语义与像素有效性检查逐行保真。
private static EvidenceRef CaptureWindowEvidence(nint handle, WindowRect bounds,
string evidenceRoot, DateTimeOffset capturedAt)
{
var path = Path.Combine(evidenceRoot, $"actuate-{Guid.NewGuid():N}.png");
var stamp = capturedAt.ToString("O", CultureInfo.InvariantCulture);
try
{
using var bitmap = new Bitmap(bounds.Width, bounds.Height);
using var graphics = Graphics.FromImage(bitmap);
var deviceContext = graphics.GetHdc();
bool captured;
try { captured = NativeWindows.PrintWindow(handle, deviceContext, 2); }
finally { graphics.ReleaseHdc(deviceContext); }
if (!captured || IsBlank(bitmap)) throw new RunnerException("capture_unavailable");
bitmap.Save(path, ImageFormat.Png);
var size = new FileInfo(path).Length;
if (size < 1 || size > MaxPngBytes) throw new RunnerException("capture_size_unsupported");
using var stream = File.OpenRead(path);
var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
return new EvidenceRef($"ev_{hash[..24]}", "window_snapshot",
new Uri(path).AbsoluteUri, hash, "secret", stamp);
}
catch
{
if (File.Exists(path)) File.Delete(path);
throw;
}
}
private static bool IsBlank(Bitmap bitmap)
{
var first = bitmap.GetPixel(0, 0).ToArgb();
for (var row = 0; row < 10; row++)
for (var column = 0; column < 10; column++)
if (bitmap.GetPixel(column * (bitmap.Width - 1) / 9,
row * (bitmap.Height - 1) / 9).ToArgb() != first) return false;
return true;
}
}