c8ae0dd7bf
Auto-scan aborted immediately with "Genshin konnte nicht in den Vordergrund geholt werden". Root cause: the focus call runs in the background input/capture helper process, and Windows' foreground lock silently refuses SetForegroundWindow from a process that is neither foreground nor the last input source. When the user clicks "Auto-Scan starten" the Electron window is foreground, so the helper's plain SetForegroundWindow is dropped and focus stays false. Fix (both the C# sidecar and the PowerShell fallback): before SetForegroundWindow, attach our thread's input queue to the target (and current-foreground) window thread with AttachThreadInput and clear SPI_..FOREGROUNDLOCKTIMEOUT, then restore. This is the same technique Inventory Kamera and other reliable automators use; it is what our helper was missing after the old ALT-tap workaround was removed on the wrong assumption that equal integrity level is sufficient (that only covers UIPI input injection, not foreground changes). Sidecar recompiled + republished; electron build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
693 lines
28 KiB
TypeScript
693 lines
28 KiB
TypeScript
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
|
|
[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<string, { resolve: (value: HelperOperationResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
|
|
private buffer = "";
|
|
private nextId = 1;
|
|
private starting: Promise<void> | 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<string, unknown>, timeoutMs: number) {
|
|
return new Promise<HelperOperationResponse>((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<string, unknown> = {}, 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<RuntimeInfo>;
|
|
focusGenshinWindow(): Promise<FocusGenshinResult>;
|
|
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
|
|
getGenshinWindowBounds(): Promise<WindowBounds | null>;
|
|
clickScreen(x: number, y: number): Promise<ClickResult>;
|
|
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
|
getAutomationGuard(): Promise<AutomationGuard>;
|
|
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
|
|
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<string, unknown> = {}, 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<string, unknown> = { 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(),
|
|
};
|
|
}
|