import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import type { AutomationGuard, ClickResult, FocusGenshinResult, GdiCaptureResult, HelperOperationResponse, WindowBounds, RuntimeInfo, ScrollResult, } from "../../src/types/global.js"; const INPUT_HELPER_SCRIPT = String.raw` $ErrorActionPreference = "Stop" Add-Type -AssemblyName System.Drawing Add-Type -AssemblyName System.Windows.Forms $signature = @" [DllImport("user32.dll")] public static extern bool SetProcessDPIAware(); [DllImport("shcore.dll")] public static extern int SetProcessDpiAwareness(int value); [DllImport("user32.dll")] public static extern bool SetCursorPos(int X, int Y); [DllImport("user32.dll")] public static extern bool GetCursorPos(out POINT lpPoint); [DllImport("user32.dll", SetLastError=true)] public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); [DllImport("user32.dll", SetLastError=true)] public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); [DllImport("user32.dll")] public static extern short GetAsyncKeyState(int vKey); [DllImport("user32.dll", SetLastError=true)] public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); [DllImport("user32.dll", SetLastError=true)] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); [DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")] public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni); [DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")] public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni); [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr hWnd); [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; } [StructLayout(LayoutKind.Sequential)] public struct INPUT { public int type; public MOUSEINPUT mi; } "@ Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native # Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the # same symptom): the older SetProcessDPIAware() only applies a single, # system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin # on one display, this app's window on a differently-scaled second display), # that single scale factor is wrong for whichever monitor didn't set it, # silently shifting every SetCursorPos/click coordinate off-target even # though cursor readback still matches what we asked for (both go through the # same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2. try { [Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null } catch { [Native.InputHelper]::SetProcessDPIAware() | Out-Null } [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 # SizeOf must receive a struct instance: passing the type object throws in # Windows PowerShell 5.1 (RuntimeType cannot be marshalled). $inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT)) $genshinHwnd = [IntPtr]::Zero function Send-MouseInput { param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0) $mouseInput = New-Object Native.InputHelper+INPUT $mouseInput.type = 0 $mouseInput.mi.dx = $dx $mouseInput.mi.dy = $dy if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData } $mouseInput.mi.dwFlags = $flags return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize) } # Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves # with bare SetCursorPos, then clicks via the InputSimulator library's # Mouse.LeftButtonClick(), which sends button-down and button-up as ONE # SendInput call (two INPUT structs in the same array) - back-to-back with no # artificial delay between them, unlike two separate SendInput calls with a # Start-Sleep in between. Returns the number of injected events (2 = ok). function Send-MouseClickBatch { $down = New-Object Native.InputHelper+INPUT $down.type = 0 $down.mi.dwFlags = 0x0002 $up = New-Object Native.InputHelper+INPUT $up.type = 0 $up.mi.dwFlags = 0x0004 return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize) } function Get-CursorPoint { $pt = New-Object Native.InputHelper+POINT [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null return $pt } function Get-ProcessNameFromHwnd { param([IntPtr]$hwnd) if ($hwnd -eq [IntPtr]::Zero) { return "" } $pidValue = [uint32]0 [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null if ($pidValue -eq 0) { return "" } try { return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName } catch { return "" } } function Get-CurrentProcessElevation { $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = New-Object Security.Principal.WindowsPrincipal($identity) return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Get-ForegroundInfo { $hwnd = [Native.InputHelper]::GetForegroundWindow() return @{ foregroundHwnd = $hwnd.ToInt64() foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd } } function Get-CursorState { $pt = New-Object Native.InputHelper+POINT [Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null # Only 0x8000 (key is held down right now). The 0x0001 "pressed since last # call" bit is unreliable and fires for ESC presses that happened long # before the scan (ESC is used constantly to navigate Genshin menus). $esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0 $enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0 $f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0 return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 } } function Get-GenshinClientBounds { $hwnd = Find-GenshinWindow if ($hwnd -eq [IntPtr]::Zero) { return $null } $rect = New-Object Native.InputHelper+RECT if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null } $topLeft = New-Object Native.InputHelper+POINT $topLeft.X = 0 $topLeft.Y = 0 if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null } $width = $rect.Right - $rect.Left $height = $rect.Bottom - $rect.Top if ($width -le 0 -or $height -le 0) { return $null } return @{ Left = $topLeft.X Top = $topLeft.Y Width = $width Height = $height } } function Find-GenshinWindow { if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd } $proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1 if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero } return $script:genshinHwnd } # Plain SetForegroundWindow from this background helper process is silently # refused by Windows' foreground lock. Attach our thread's input queue to the # target (and current foreground) window thread and clear the lock timeout, so # the foreground change is honored - the same technique Inventory Kamera uses. function Force-Foreground { param([IntPtr]$hwnd) $current = [Native.InputHelper]::GetCurrentThreadId() $targetPid = [uint32]0 $target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid) $fgWindow = [Native.InputHelper]::GetForegroundWindow() $foreground = [uint32]0 if ($fgWindow -ne [IntPtr]::Zero) { $fgPid = [uint32]0 $foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid) } $attachedTarget = $false $attachedForeground = $false $oldTimeout = [uint32]0 $timeoutRead = $false try { if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) } if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) } $timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0) [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null # Inject a no-op input (0,0 mouse move) so this process is the last input # source, which Windows requires before it will honor a foreground change. Send-MouseInput -flags 0x0001 | Out-Null [Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null [Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null return [Native.InputHelper]::SetForegroundWindow($hwnd) } finally { if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null } if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null } if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null } } } function Focus-GenshinWindow { $hwnd = Find-GenshinWindow $info = @{ hwnd = $hwnd.ToInt64() focused = $false alreadyForeground = $false foregroundProcess = "" targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd } if ($hwnd -eq [IntPtr]::Zero) { return $info } $info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd) if (-not $info.alreadyForeground) { $info.setForegroundResult = Force-Foreground -hwnd $hwnd Start-Sleep -Milliseconds 140 } $foreground = [Native.InputHelper]::GetForegroundWindow() $info.focused = ($foreground -eq $hwnd) $info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground return $info } while ($true) { $line = [Console]::In.ReadLine() if ($null -eq $line) { break } if ($line.Trim().Length -eq 0) { continue } $response = @{ id = ""; ok = $true } try { $cmd = $line | ConvertFrom-Json $response.id = "$($cmd.id)" switch ("$($cmd.op)") { "ping" { $response.pong = $true } "cursor" { $state = Get-CursorState $response.cursorX = $state.cursorX $response.cursorY = $state.cursorY $response.escapePressed = $state.escapePressed $response.enterPressed = $state.enterPressed $response.f9Pressed = $state.f9Pressed } "runtime" { $response.isElevated = Get-CurrentProcessElevation $hwnd = Find-GenshinWindow $foregroundInfo = Get-ForegroundInfo $response.genshinFound = ($hwnd -ne [IntPtr]::Zero) $response.genshinHwnd = $hwnd.ToInt64() $response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd $response.foregroundProcess = $foregroundInfo.foregroundProcess $response.foregroundHwnd = $foregroundInfo.foregroundHwnd $response.helperPid = $PID } "focus" { $focusInfo = Focus-GenshinWindow $response.focused = $focusInfo.focused $response.alreadyForeground = $focusInfo.alreadyForeground $response.foregroundProcess = $focusInfo.foregroundProcess $response.targetProcess = $focusInfo.targetProcess $response.genshinFound = ($focusInfo.hwnd -ne 0) $response.setForegroundResult = $focusInfo.setForegroundResult } "click" { $focusInfo = Focus-GenshinWindow if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { Start-Sleep -Milliseconds 120 } $targetX = [int]$cmd.x $targetY = [int]$cmd.y # Matches Inventory Kamera's verified-working sequence exactly: bare # SetCursorPos immediately followed by a click, with NO extra move # event and NO artificial delay between moving and clicking - IK's # Navigation.Click(x, y) does SetCursor() then Click() back-to-back, # zero gap. Settling delays only happen after the click, in the scan # loop. Down+up are sent as one SendInput call (see # Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick(). [Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null $point = Get-CursorPoint $onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2)) $clickEventsSent = 0 if ($onTarget) { $clickEventsSent = Send-MouseClickBatch } $state = Get-CursorState $response.cursorX = $state.cursorX $response.cursorY = $state.cursorY $response.escapePressed = $state.escapePressed $response.enterPressed = $state.enterPressed $response.f9Pressed = $state.f9Pressed $response.moved = $onTarget $response.focused = $focusInfo.focused $response.alreadyForeground = $focusInfo.alreadyForeground $response.foregroundProcess = $focusInfo.foregroundProcess $response.targetProcess = $focusInfo.targetProcess $response.isElevated = Get-CurrentProcessElevation # Never report a click unless the cursor is verifiably on the target. # Real acceptance is proven later by the detail-panel fingerprint. $response.clicked = ($onTarget -and $clickEventsSent -ge 2) $response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2) } "scroll" { $focusInfo = Focus-GenshinWindow if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) { Start-Sleep -Milliseconds 120 } if ($null -ne $cmd.x -and $null -ne $cmd.y) { [Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null Start-Sleep -Milliseconds 30 } $point = Get-CursorPoint $response.cursorX = $point.X $response.cursorY = $point.Y $response.focused = $focusInfo.focused $response.foregroundProcess = $focusInfo.foregroundProcess $response.isElevated = Get-CurrentProcessElevation $notches = [int]$cmd.notches $stepDelta = 120 if ($notches -lt 0) { $stepDelta = -120 } $count = [Math]::Abs($notches) if ($count -gt 60) { $count = 60 } $sentTotal = 0 for ($i = 0; $i -lt $count; $i++) { $sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta Start-Sleep -Milliseconds 45 } $response.notchesSent = $sentTotal $response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0)) } "bounds" { $clientBounds = Get-GenshinClientBounds if ($null -eq $clientBounds) { $response.found = $false } else { $response.found = $true $response.left = $clientBounds.Left $response.top = $clientBounds.Top $response.width = $clientBounds.Width $response.height = $clientBounds.Height } } "capture" { $clientBounds = Get-GenshinClientBounds if ($null -eq $clientBounds) { $screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds $clientBounds = @{ Left = $screenBounds.Left Top = $screenBounds.Top Width = $screenBounds.Width Height = $screenBounds.Height } $response.captureTarget = "primary-screen" } else { $response.captureTarget = "genshin-client" } $bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height $graphics = [System.Drawing.Graphics]::FromImage($bitmap) $graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size) $capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png") $bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png) $graphics.Dispose() $bitmap.Dispose() $response.path = $capturePath $response.width = $clientBounds.Width $response.height = $clientBounds.Height $response.originX = $clientBounds.Left $response.originY = $clientBounds.Top } default { $response.ok = $false $response.error = "unknown op" } } } catch { $response.ok = $false $response.error = $_.Exception.Message } Write-Output (ConvertTo-Json $response -Compress) } `; class InputHelperClient { private child: ChildProcessWithoutNullStreams | null = null; private pending = new Map void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); private buffer = ""; private nextId = 1; private starting: Promise | null = null; private disposed = false; constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {} private async ensureStarted() { if (this.child) return; if (this.disposed) throw new Error("Input helper disposed"); if (!this.starting) { this.starting = this.start().finally(() => { this.starting = null; }); } await this.starting; } private async start() { // Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to // start, fall back to the embedded PowerShell helper so the app keeps working // on machines where the native exe was never built. if (this.options.exePath) { try { await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] })); return; } catch { this.teardownChild(); } } await this.startWith(await this.spawnPowershell()); } private async spawnPowershell() { const scriptPath = path.join(this.options.scriptUserDataPath, "input-helper.ps1"); await fs.mkdir(path.dirname(scriptPath), { recursive: true }); await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8"); return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"], }); } private async startWith(child: ChildProcessWithoutNullStreams) { child.stdout.setEncoding("utf8"); child.stdout.on("data", (chunk: string) => this.handleStdout(chunk)); child.stderr.setEncoding("utf8"); child.stderr.on("data", () => undefined); child.on("exit", () => { this.child = null; this.buffer = ""; for (const entry of this.pending.values()) { clearTimeout(entry.timer); entry.reject(new Error("Input helper exited")); } this.pending.clear(); }); this.child = child; // The C# sidecar answers ping immediately; the PowerShell fallback compiles // Win32 interop on the first request, so give it extra time. await this.send("ping", {}, 20000); } private teardownChild() { const child = this.child; this.child = null; this.buffer = ""; try { child?.kill(); } catch { // Child was never spawned or already gone. } } private handleStdout(chunk: string) { this.buffer += chunk; let newlineIndex = this.buffer.indexOf("\n"); while (newlineIndex >= 0) { const line = this.buffer.slice(0, newlineIndex).trim(); this.buffer = this.buffer.slice(newlineIndex + 1); newlineIndex = this.buffer.indexOf("\n"); if (!line.startsWith("{")) continue; try { const message = JSON.parse(line) as HelperOperationResponse; const entry = this.pending.get(String(message.id)); if (!entry) continue; this.pending.delete(String(message.id)); clearTimeout(entry.timer); if (message.ok) entry.resolve(message); else entry.reject(new Error(message.error || "Input helper command failed")); } catch { // Ignore non-JSON noise on stdout. } } } private send(op: string, params: Record, timeoutMs: number) { return new Promise((resolve, reject) => { const child = this.child; if (!child?.stdin.writable) { reject(new Error("Input helper is not running")); return; } const id = String(this.nextId++); const timer = setTimeout(() => { this.pending.delete(id); reject(new Error(`Input helper timed out on ${op}`)); }, timeoutMs); this.pending.set(id, { resolve, reject, timer }); child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`); }); } request(op: string, params: Record = {}, timeoutMs = 8000) { return this.ensureStarted().then(() => this.send(op, params, timeoutMs)); } dispose() { this.disposed = true; this.child?.kill(); this.child = null; } } export interface InputHelperService { getRuntimeInfo(): Promise; focusGenshinWindow(): Promise; focusGenshinForScanStart(): Promise; getGenshinWindowBounds(): Promise; clickScreen(x: number, y: number): Promise; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; getAutomationGuard(): Promise; capturePrimaryScreenViaGdi(): Promise; dispose(): void; } export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService { const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null }); async function request(op: string, params: Record = {}, timeoutMs = 8000) { return inputHelper.request(op, params, timeoutMs); } async function getRuntimeInfo() { const result = await request("runtime", {}, 4000); return { ok: true, isElevated: Boolean(result.isElevated), platform: process.platform, genshinFound: Boolean(result.genshinFound), genshinHwnd: typeof result.genshinHwnd === "number" ? result.genshinHwnd : undefined, targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined, foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined, foregroundHwnd: typeof result.foregroundHwnd === "number" ? result.foregroundHwnd : undefined, helperPid: typeof result.helperPid === "number" ? result.helperPid : undefined, }; } async function focusGenshinWindow() { const result = await request("focus", {}, 6000); return { focused: Boolean(result.focused), alreadyForeground: Boolean(result.alreadyForeground), genshinFound: Boolean(result.genshinFound), foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined, targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined, setForegroundResult: typeof result.setForegroundResult === "boolean" ? result.setForegroundResult : undefined, }; } async function focusGenshinForScanStart() { let result = await focusGenshinWindow(); if (result.focused) return result; await new Promise((resolve) => setTimeout(resolve, 700)); result = await focusGenshinWindow(); return result; } async function getGenshinWindowBounds() { const result = await request("bounds", {}, 4000); if (!result.found) return null; return { x: Number(result.left), y: Number(result.top), width: Number(result.width), height: Number(result.height), }; } async function clickScreen(x: number, y: number) { const result = (await request("click", { x: Math.round(x), y: Math.round(y) }, 8000)) as HelperOperationResponse; return { ok: true, x: Math.round(x), y: Math.round(y), cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined, cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined, escapePressed: Boolean(result.escapePressed), enterPressed: Boolean(result.enterPressed), f9Pressed: Boolean(result.f9Pressed), moved: Boolean(result.moved), clicked: Boolean(result.clicked), inputBlocked: Boolean(result.inputBlocked), focused: Boolean(result.focused), alreadyForeground: Boolean(result.alreadyForeground), foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined, targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined, isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined, }; } async function scrollScreen(notches: number, anchorX?: number, anchorY?: number) { const safeNotches = Math.max(-60, Math.min(60, Math.round(notches))); const params: Record = { notches: safeNotches }; if (typeof anchorX === "number" && typeof anchorY === "number") { params.x = Math.round(anchorX); params.y = Math.round(anchorY); } const result = (await request("scroll", params, 8000 + Math.abs(safeNotches) * 80)) as HelperOperationResponse; return { ok: true, notchesSent: Number(result.notchesSent ?? 0), inputBlocked: Boolean(result.inputBlocked), isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined, }; } async function getAutomationGuard() { const result = await request("cursor", {}, 4000); return { ok: true, cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined, cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined, escapePressed: Boolean(result.escapePressed), enterPressed: Boolean(result.enterPressed), f9Pressed: Boolean(result.f9Pressed), }; } async function capturePrimaryScreenViaGdi() { const result = await request("capture", {}, 15000); // The C# sidecar returns PNG bytes inline (no temp file). The PowerShell // fallback writes a temp PNG and returns its path. let base64: string; if (typeof result.imageBase64 === "string" && result.imageBase64) { base64 = result.imageBase64; } else { const capturePath = String(result.path); const buffer = await fs.readFile(capturePath); await fs.unlink(capturePath).catch(() => undefined); base64 = buffer.toString("base64"); } const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : ""; const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client" ? captureTargetRaw : "primary-screen"; return { dataUrl: `data:image/png;base64,${base64}`, width: Number(result.width), height: Number(result.height), originX: Number(result.originX), originY: Number(result.originY), captureTarget, }; } return { getRuntimeInfo, focusGenshinWindow, focusGenshinForScanStart, getGenshinWindowBounds, clickScreen, scrollScreen, getAutomationGuard, capturePrimaryScreenViaGdi, dispose: () => inputHelper.dispose(), }; }