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(); 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; } }