Merge scanner readiness work
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { dialog, type BrowserWindow } from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { GoodDatabase, GoodImportFileResult, SaveResultWithPath } from "../../src/types/global.js";
|
||||
|
||||
export interface GoodFileService {
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: (parentWindow?: BrowserWindow | null) => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
export function createGoodFileService(exportDirectory: string): GoodFileService {
|
||||
function exportPath(fileName: string) {
|
||||
return path.join(exportDirectory, fileName);
|
||||
}
|
||||
|
||||
async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
|
||||
const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`;
|
||||
const filePath = exportPath(fileNameSafe);
|
||||
try {
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
|
||||
return { ok: true, path: filePath };
|
||||
} catch {
|
||||
return { ok: false, path: filePath };
|
||||
}
|
||||
}
|
||||
|
||||
async function importGoodFile(parentWindow?: BrowserWindow | null): Promise<GoodImportFileResult> {
|
||||
const dialogOptions = {
|
||||
title: "GOOD-Datei importieren",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
|
||||
} satisfies Electron.OpenDialogOptions;
|
||||
const dialogResult = parentWindow && !parentWindow.isDestroyed()
|
||||
? await dialog.showOpenDialog(parentWindow, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions);
|
||||
|
||||
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
|
||||
return { ok: false, canceled: true, path: "" };
|
||||
}
|
||||
|
||||
const filePath = dialogResult.filePaths[0];
|
||||
try {
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
canceled: false,
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { exportGood, importGoodFile };
|
||||
}
|
||||
@@ -7,408 +7,13 @@ import type {
|
||||
FocusGenshinResult,
|
||||
GdiCaptureResult,
|
||||
HelperOperationResponse,
|
||||
KeyPressResult,
|
||||
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)
|
||||
}
|
||||
`;
|
||||
import { INPUT_HELPER_SCRIPT } from "./inputHelperPowerShellFallback.js";
|
||||
|
||||
class InputHelperClient {
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
@@ -545,6 +150,7 @@ export interface InputHelperService {
|
||||
getGenshinWindowBounds(): Promise<WindowBounds | null>;
|
||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||
keyPress(key: string): Promise<KeyPressResult>;
|
||||
getAutomationGuard(): Promise<AutomationGuard>;
|
||||
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
|
||||
dispose(): void;
|
||||
@@ -641,6 +247,20 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
};
|
||||
}
|
||||
|
||||
async function keyPress(key: string) {
|
||||
const result = (await request("key", { key }, 8000)) as HelperOperationResponse;
|
||||
return {
|
||||
ok: Boolean(result.ok) && Number(result.eventsSent ?? 0) >= 2,
|
||||
key,
|
||||
focused: Boolean(result.focused),
|
||||
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
|
||||
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
|
||||
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||
inputBlocked: Boolean(result.inputBlocked),
|
||||
eventsSent: Number(result.eventsSent ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAutomationGuard() {
|
||||
const result = await request("cursor", {}, 4000);
|
||||
return {
|
||||
@@ -688,6 +308,7 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
getGenshinWindowBounds,
|
||||
clickScreen,
|
||||
scrollScreen,
|
||||
keyPress,
|
||||
getAutomationGuard,
|
||||
capturePrimaryScreenViaGdi,
|
||||
dispose: () => inputHelper.dispose(),
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
# 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 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 - 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))
|
||||
}
|
||||
"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)
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { inflateSync } from "node:zlib";
|
||||
import type { Bitmap } from "../../src/lib/ocrPreprocess.js";
|
||||
|
||||
interface PngChunk {
|
||||
type: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
function readChunks(buffer: Buffer): PngChunk[] {
|
||||
const signature = buffer.subarray(0, 8);
|
||||
if (!signature.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
throw new Error("Invalid PNG signature.");
|
||||
}
|
||||
const chunks: PngChunk[] = [];
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + length;
|
||||
if (dataEnd + 4 > buffer.length) throw new Error("Invalid PNG chunk length.");
|
||||
chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) });
|
||||
offset = dataEnd + 4;
|
||||
if (type === "IEND") break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function paethPredictor(left: number, up: number, upperLeft: number) {
|
||||
const estimate = left + up - upperLeft;
|
||||
const leftDistance = Math.abs(estimate - left);
|
||||
const upDistance = Math.abs(estimate - up);
|
||||
const upperLeftDistance = Math.abs(estimate - upperLeft);
|
||||
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left;
|
||||
if (upDistance <= upperLeftDistance) return up;
|
||||
return upperLeft;
|
||||
}
|
||||
|
||||
function unfilterScanlines(raw: Buffer, width: number, height: number, bytesPerPixel: number) {
|
||||
const stride = width * bytesPerPixel;
|
||||
const output = Buffer.alloc(stride * height);
|
||||
let rawOffset = 0;
|
||||
for (let row = 0; row < height; row++) {
|
||||
const filter = raw[rawOffset++];
|
||||
const rowOffset = row * stride;
|
||||
const previousRowOffset = rowOffset - stride;
|
||||
for (let col = 0; col < stride; col++) {
|
||||
const value = raw[rawOffset++];
|
||||
const left = col >= bytesPerPixel ? output[rowOffset + col - bytesPerPixel] : 0;
|
||||
const up = row > 0 ? output[previousRowOffset + col] : 0;
|
||||
const upperLeft = row > 0 && col >= bytesPerPixel ? output[previousRowOffset + col - bytesPerPixel] : 0;
|
||||
let restored = value;
|
||||
if (filter === 1) restored = value + left;
|
||||
else if (filter === 2) restored = value + up;
|
||||
else if (filter === 3) restored = value + Math.floor((left + up) / 2);
|
||||
else if (filter === 4) restored = value + paethPredictor(left, up, upperLeft);
|
||||
else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`);
|
||||
output[rowOffset + col] = restored & 0xff;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function pngBufferToBitmap(buffer: Buffer): Bitmap {
|
||||
const chunks = readChunks(buffer);
|
||||
const ihdr = chunks.find((chunk) => chunk.type === "IHDR")?.data;
|
||||
if (!ihdr) throw new Error("PNG missing IHDR.");
|
||||
const width = ihdr.readUInt32BE(0);
|
||||
const height = ihdr.readUInt32BE(4);
|
||||
const bitDepth = ihdr[8];
|
||||
const colorType = ihdr[9];
|
||||
const compression = ihdr[10];
|
||||
const filter = ihdr[11];
|
||||
const interlace = ihdr[12];
|
||||
if (bitDepth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0) {
|
||||
throw new Error("Unsupported PNG format.");
|
||||
}
|
||||
const sourceBytesPerPixel = colorType === 6 ? 4 : colorType === 2 ? 3 : 0;
|
||||
if (!sourceBytesPerPixel) throw new Error(`Unsupported PNG color type: ${colorType}`);
|
||||
const idat = Buffer.concat(chunks.filter((chunk) => chunk.type === "IDAT").map((chunk) => chunk.data));
|
||||
const unfiltered = unfilterScanlines(inflateSync(idat), width, height, sourceBytesPerPixel);
|
||||
if (colorType === 6) return { data: unfiltered, width, height };
|
||||
|
||||
const rgba = Buffer.alloc(width * height * 4);
|
||||
for (let pixel = 0; pixel < width * height; pixel++) {
|
||||
rgba[pixel * 4] = unfiltered[pixel * 3];
|
||||
rgba[pixel * 4 + 1] = unfiltered[pixel * 3 + 1];
|
||||
rgba[pixel * 4 + 2] = unfiltered[pixel * 3 + 2];
|
||||
rgba[pixel * 4 + 3] = 255;
|
||||
}
|
||||
return { data: rgba, width, height };
|
||||
}
|
||||
Reference in New Issue
Block a user