280 lines
14 KiB
C#
280 lines
14 KiB
C#
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;
|
||
}
|
||
}
|