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

280 行
14 KiB
C#
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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;
}
}