Files
genshin-assistant/electron/services/inputHelperPowerShellFallback.ts
AzuTear 639b0b7f59 feat(scanner): add native artifact pipeline
Add native IK-style capture processing, Artifact Inventory, explicit promotion and single-result review. Confirm the three live OCR corrections in the eval corpus and preserve extraction/value separation.
2026-07-09 23:30:42 +02:00

436 lines
18 KiB
TypeScript

// PowerShell fallback for environments where the compiled C# sidecar is unavailable. Keep the JSON protocol aligned with native/input-helper/Program.cs.
export 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)
}
# Uses bare SetCursorPos, then 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 Send-KeyPressBatch {
param([int]$virtualKey)
$down = New-Object Native.InputHelper+INPUT
$down.type = 1
$down.mi.dx = $virtualKey
$up = New-Object Native.InputHelper+INPUT
$up.type = 1
$up.mi.dx = $virtualKey
# Same union bytes as KEYBDINPUT: dx low word = wVk, dy = dwFlags.
$up.mi.dy = 0x0002
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
}
function Resolve-VirtualKey {
param([string]$key)
switch ($key.ToUpperInvariant()) {
"ESC" { return 27 }
"ESCAPE" { return 27 }
"ENTER" { return 13 }
"B" { return 66 }
"C" { return 67 }
"1" { return 49 }
default { throw "unsupported key: $key" }
}
}
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.
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
# Bare SetCursorPos immediately followed by a click, with NO extra move
# event and NO artificial delay between moving and clicking. Settling
# delays only happen after the click, in the scan loop. Down+up are sent
# as one SendInput call (see Send-MouseClickBatch).
[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))
}
"key" {
$focusInfo = Focus-GenshinWindow
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
Start-Sleep -Milliseconds 120
}
$vk = Resolve-VirtualKey -key "$($cmd.key)"
$sent = Send-KeyPressBatch -virtualKey $vk
$response.key = "$($cmd.key)"
$response.focused = $focusInfo.focused
$response.foregroundProcess = $focusInfo.foregroundProcess
$response.targetProcess = $focusInfo.targetProcess
$response.isElevated = Get-CurrentProcessElevation
$response.eventsSent = $sent
$response.inputBlocked = ($sent -lt 2)
}
"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)
}
`;