feat(native): C# input/capture sidecar replacing the PowerShell helper
Implements ADR-008. native/input-helper is a self-contained .NET 9 console exe speaking the identical JSON-over-stdin/stdout protocol as the old PowerShell helper (ping/cursor/runtime/focus/click/scroll/bounds/capture), so the InputHelperService interface is unchanged. - Win32 interop compiled once (native exe), not per call. - PerMonitorV2 DPI via manifest so click/capture coordinates stay correct on mixed-DPI multi-monitor setups. - capture returns base64 PNG bytes inline (imageBase64) instead of writing a temp file per frame; the client handles both base64 and the PowerShell path. - InputHelperClient prefers the exe and falls back to the embedded PowerShell helper when the exe is absent, so the app still runs without the .NET build. - main.ts resolves the exe (INPUT_HELPER_EXE env -> packaged resources/input-helper -> native/input-helper/bin/publish). electron-builder ships it via extraResources. - npm run helper:build; README documents the build + fallback. Verified end-to-end through the compiled client: sidecar spawns, runtime info and a base64 primary-screen capture return correctly. Build stays green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -34,6 +34,20 @@ npm run dev
|
||||
|
||||
Use the Electron app window for scanner work. The browser preview does not expose the local capture bridge.
|
||||
|
||||
### Input/Capture helper (C# sidecar)
|
||||
|
||||
Input automation and screen capture run through a compiled C# sidecar
|
||||
(`native/input-helper`, see ADR-008). Build it once:
|
||||
|
||||
```powershell
|
||||
npm run helper:build # requires the .NET SDK; produces a self-contained exe
|
||||
```
|
||||
|
||||
The app auto-detects the exe (`INPUT_HELPER_EXE` env override → packaged
|
||||
`resources/input-helper` → `native/input-helper/bin/publish`). If the exe is not
|
||||
present it falls back to the embedded PowerShell helper, so the app still runs
|
||||
without the .NET build - just slower and with the old per-frame temp-file capture.
|
||||
|
||||
### Automatischer Scan: als Administrator starten
|
||||
|
||||
Genshin läuft erhöht (Administrator). Windows (UIPI) verwirft dann alle simulierten Maus-Eingaben aus einer nicht-erhöhten App - SendInput meldet dabei trotzdem Erfolg. Für den automatischen Scan muss die App deshalb ebenfalls erhöht laufen:
|
||||
|
||||
+21
-1
@@ -1,5 +1,6 @@
|
||||
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import http, { type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -70,6 +71,25 @@ function getInputHelperService() {
|
||||
return inputHelperService;
|
||||
}
|
||||
|
||||
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
|
||||
// the service uses the embedded PowerShell helper when the exe was never built.
|
||||
function resolveInputHelperExePath(): string | null {
|
||||
const candidates = [
|
||||
process.env.INPUT_HELPER_EXE,
|
||||
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
|
||||
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
|
||||
].filter((candidate): candidate is string => Boolean(candidate));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
if (existsSync(candidate)) return candidate;
|
||||
} catch {
|
||||
// Unreadable path; try the next candidate.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRepositoryContext() {
|
||||
if (!repositoryContext) {
|
||||
throw new Error("Repository context has not been initialized.");
|
||||
@@ -1032,7 +1052,7 @@ function initializeAppLifecycle() {
|
||||
artifactStoreRepository = repositoryContext.artifactStoreRepository;
|
||||
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
|
||||
scannerLearningRepository = repositoryContext.scannerLearningRepository;
|
||||
inputHelperService = createInputHelperService({ userDataPath });
|
||||
inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
|
||||
|
||||
registerIpcHandlers({
|
||||
focusMainWindow: () => focusMainWindow(),
|
||||
|
||||
@@ -382,7 +382,7 @@ class InputHelperClient {
|
||||
private starting: Promise<void> | null = null;
|
||||
private disposed = false;
|
||||
|
||||
constructor(private readonly scriptUserDataPath: string) {}
|
||||
constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}
|
||||
|
||||
private async ensureStarted() {
|
||||
if (this.child) return;
|
||||
@@ -396,14 +396,31 @@ class InputHelperClient {
|
||||
}
|
||||
|
||||
private async start() {
|
||||
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1");
|
||||
// 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");
|
||||
|
||||
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
|
||||
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");
|
||||
@@ -419,10 +436,22 @@ class InputHelperClient {
|
||||
});
|
||||
this.child = child;
|
||||
|
||||
// First request compiles the Win32 interop; give it extra time.
|
||||
// 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");
|
||||
@@ -485,8 +514,8 @@ export interface InputHelperService {
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export function createInputHelperService(options: { userDataPath: string }): InputHelperService {
|
||||
const inputHelper = new InputHelperClient(options.userDataPath);
|
||||
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);
|
||||
@@ -590,16 +619,24 @@ export function createInputHelperService(options: { userDataPath: string }): Inp
|
||||
|
||||
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,${buffer.toString("base64")}`,
|
||||
dataUrl: `data:image/png;base64,${base64}`,
|
||||
width: Number(result.width),
|
||||
height: Number(result.height),
|
||||
originX: Number(result.originX),
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# .NET build outputs — the self-contained exe is built via `npm run helper:build`,
|
||||
# not committed (it is ~100 MB).
|
||||
bin/
|
||||
obj/
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net9.0-windows</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>InputHelper</AssemblyName>
|
||||
<RootNamespace>GenshinAssistant.InputHelper</RootNamespace>
|
||||
<!-- Screen.PrimaryScreen (Forms) + Bitmap/Graphics.CopyFromScreen (Drawing). -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<!-- Single self-contained exe: no .NET install needed on the user's machine. -->
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>true</PublishSingleFile>
|
||||
<IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract>
|
||||
<InvariantGlobalization>true</InvariantGlobalization>
|
||||
<!-- Per-monitor DPI v2 so SendInput/capture coordinates match a mixed-DPI
|
||||
multi-monitor setup (same reason as the old PowerShell helper). -->
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,466 @@
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Principal;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Windows.Forms;
|
||||
|
||||
// Long-lived input/capture sidecar for the Genshin Artifact Assistant.
|
||||
// Drop-in replacement for the old PowerShell helper (see ADR-008): identical
|
||||
// JSON-over-stdin/stdout protocol - one JSON request per line, one JSON response
|
||||
// per line - so the Electron-side InputHelperService is unchanged. Win32 interop
|
||||
// is compiled once (this is a native exe), and capture returns base64 PNG bytes
|
||||
// directly instead of writing a temp file per frame.
|
||||
|
||||
namespace GenshinAssistant.InputHelper;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static IntPtr _genshinHwnd = IntPtr.Zero;
|
||||
|
||||
private static int Main()
|
||||
{
|
||||
// Manifest already declares PerMonitorV2; this is a belt-and-suspenders
|
||||
// call for hosts that ignore the manifest.
|
||||
try { Native.SetProcessDpiAwarenessContext(Native.DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); }
|
||||
catch { try { Native.SetProcessDpiAwareness(2); } catch { /* oldest fallback */ Native.SetProcessDPIAware(); } }
|
||||
|
||||
Console.OutputEncoding = Encoding.UTF8;
|
||||
var stdout = Console.Out;
|
||||
|
||||
string? line;
|
||||
while ((line = Console.In.ReadLine()) != null)
|
||||
{
|
||||
if (line.Trim().Length == 0) continue;
|
||||
|
||||
var response = new Dictionary<string, object?> { ["id"] = "", ["ok"] = true };
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(line);
|
||||
var root = doc.RootElement;
|
||||
response["id"] = GetString(root, "id");
|
||||
var op = GetString(root, "op");
|
||||
Handle(op, root, response);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response["ok"] = false;
|
||||
response["error"] = ex.Message;
|
||||
}
|
||||
|
||||
stdout.WriteLine(JsonSerializer.Serialize(response));
|
||||
stdout.Flush();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void Handle(string op, JsonElement root, Dictionary<string, object?> response)
|
||||
{
|
||||
switch (op)
|
||||
{
|
||||
case "ping":
|
||||
response["pong"] = true;
|
||||
break;
|
||||
|
||||
case "cursor":
|
||||
{
|
||||
var state = GetCursorState();
|
||||
response["cursorX"] = state.X;
|
||||
response["cursorY"] = state.Y;
|
||||
response["escapePressed"] = state.Escape;
|
||||
response["enterPressed"] = state.Enter;
|
||||
response["f9Pressed"] = state.F9;
|
||||
break;
|
||||
}
|
||||
|
||||
case "runtime":
|
||||
{
|
||||
var hwnd = FindGenshinWindow();
|
||||
var fgHwnd = Native.GetForegroundWindow();
|
||||
response["isElevated"] = IsElevated();
|
||||
response["genshinFound"] = hwnd != IntPtr.Zero;
|
||||
response["genshinHwnd"] = hwnd.ToInt64();
|
||||
response["targetProcess"] = ProcessNameFromHwnd(hwnd);
|
||||
response["foregroundProcess"] = ProcessNameFromHwnd(fgHwnd);
|
||||
response["foregroundHwnd"] = fgHwnd.ToInt64();
|
||||
response["helperPid"] = Environment.ProcessId;
|
||||
break;
|
||||
}
|
||||
|
||||
case "focus":
|
||||
{
|
||||
var info = FocusGenshinWindow();
|
||||
response["focused"] = info.Focused;
|
||||
response["alreadyForeground"] = info.AlreadyForeground;
|
||||
response["foregroundProcess"] = info.ForegroundProcess;
|
||||
response["targetProcess"] = info.TargetProcess;
|
||||
response["genshinFound"] = info.Hwnd != IntPtr.Zero;
|
||||
response["setForegroundResult"] = info.SetForegroundResult;
|
||||
break;
|
||||
}
|
||||
|
||||
case "click":
|
||||
{
|
||||
var info = FocusGenshinWindow();
|
||||
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||
|
||||
var targetX = GetInt(root, "x");
|
||||
var targetY = GetInt(root, "y");
|
||||
// Bare SetCursorPos then a batched down+up click, matching the
|
||||
// verified Inventory Kamera sequence: no extra move event, no
|
||||
// gap between move and click.
|
||||
Native.SetCursorPos(targetX, targetY);
|
||||
Native.GetCursorPos(out var pt);
|
||||
var onTarget = Math.Abs(targetX - pt.X) <= 2 && Math.Abs(targetY - pt.Y) <= 2;
|
||||
var clickEventsSent = onTarget ? SendMouseClickBatch() : 0u;
|
||||
|
||||
var state = GetCursorState();
|
||||
response["cursorX"] = state.X;
|
||||
response["cursorY"] = state.Y;
|
||||
response["escapePressed"] = state.Escape;
|
||||
response["enterPressed"] = state.Enter;
|
||||
response["f9Pressed"] = state.F9;
|
||||
response["moved"] = onTarget;
|
||||
response["focused"] = info.Focused;
|
||||
response["alreadyForeground"] = info.AlreadyForeground;
|
||||
response["foregroundProcess"] = info.ForegroundProcess;
|
||||
response["targetProcess"] = info.TargetProcess;
|
||||
response["isElevated"] = IsElevated();
|
||||
// Only report a click when the cursor is verifiably on target and
|
||||
// SendInput injected both events; real acceptance is proven later
|
||||
// by the detail-panel fingerprint.
|
||||
response["clicked"] = onTarget && clickEventsSent >= 2;
|
||||
response["inputBlocked"] = onTarget && clickEventsSent < 2;
|
||||
break;
|
||||
}
|
||||
|
||||
case "scroll":
|
||||
{
|
||||
var info = FocusGenshinWindow();
|
||||
if (info.Focused && !info.AlreadyForeground) Thread.Sleep(120);
|
||||
|
||||
if (TryGetInt(root, "x", out var ax) && TryGetInt(root, "y", out var ay))
|
||||
{
|
||||
Native.SetCursorPos(ax, ay);
|
||||
Thread.Sleep(30);
|
||||
}
|
||||
|
||||
Native.GetCursorPos(out var pt);
|
||||
response["cursorX"] = pt.X;
|
||||
response["cursorY"] = pt.Y;
|
||||
response["focused"] = info.Focused;
|
||||
response["foregroundProcess"] = info.ForegroundProcess;
|
||||
response["isElevated"] = IsElevated();
|
||||
|
||||
var notches = GetInt(root, "notches");
|
||||
var stepDelta = notches < 0 ? -120 : 120;
|
||||
var count = Math.Min(60, Math.Abs(notches));
|
||||
uint sentTotal = 0;
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
sentTotal += SendMouseWheel(stepDelta);
|
||||
Thread.Sleep(45);
|
||||
}
|
||||
response["notchesSent"] = sentTotal;
|
||||
response["inputBlocked"] = count > 0 && sentTotal == 0;
|
||||
break;
|
||||
}
|
||||
|
||||
case "bounds":
|
||||
{
|
||||
var bounds = GetGenshinClientBounds();
|
||||
if (bounds == null)
|
||||
{
|
||||
response["found"] = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
response["found"] = true;
|
||||
response["left"] = bounds.Value.Left;
|
||||
response["top"] = bounds.Value.Top;
|
||||
response["width"] = bounds.Value.Width;
|
||||
response["height"] = bounds.Value.Height;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "capture":
|
||||
{
|
||||
var bounds = GetGenshinClientBounds();
|
||||
string captureTarget;
|
||||
Rect area;
|
||||
if (bounds == null)
|
||||
{
|
||||
var screen = Screen.PrimaryScreen!.Bounds;
|
||||
area = new Rect { Left = screen.Left, Top = screen.Top, Width = screen.Width, Height = screen.Height };
|
||||
captureTarget = "primary-screen";
|
||||
}
|
||||
else
|
||||
{
|
||||
area = bounds.Value;
|
||||
captureTarget = "genshin-client";
|
||||
}
|
||||
|
||||
using var bitmap = new Bitmap(area.Width, area.Height, PixelFormat.Format32bppArgb);
|
||||
using (var graphics = Graphics.FromImage(bitmap))
|
||||
{
|
||||
graphics.CopyFromScreen(area.Left, area.Top, 0, 0, bitmap.Size, CopyPixelOperation.SourceCopy);
|
||||
}
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
bitmap.Save(stream, ImageFormat.Png);
|
||||
response["imageBase64"] = Convert.ToBase64String(stream.ToArray());
|
||||
response["width"] = area.Width;
|
||||
response["height"] = area.Height;
|
||||
response["originX"] = area.Left;
|
||||
response["originY"] = area.Top;
|
||||
response["captureTarget"] = captureTarget;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
response["ok"] = false;
|
||||
response["error"] = "unknown op";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly struct Rect
|
||||
{
|
||||
public int Left { get; init; }
|
||||
public int Top { get; init; }
|
||||
public int Width { get; init; }
|
||||
public int Height { get; init; }
|
||||
}
|
||||
|
||||
private struct CursorState
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
public bool Escape;
|
||||
public bool Enter;
|
||||
public bool F9;
|
||||
}
|
||||
|
||||
private struct FocusInfo
|
||||
{
|
||||
public IntPtr Hwnd;
|
||||
public bool Focused;
|
||||
public bool AlreadyForeground;
|
||||
public string ForegroundProcess;
|
||||
public string TargetProcess;
|
||||
public bool SetForegroundResult;
|
||||
}
|
||||
|
||||
private static CursorState GetCursorState()
|
||||
{
|
||||
Native.GetCursorPos(out var pt);
|
||||
// Only 0x8000 (held right now). The 0x0001 "pressed since last call" bit
|
||||
// is unreliable and fires for ESC presses used to navigate Genshin menus.
|
||||
var esc = (Native.GetAsyncKeyState(0x1B) & 0x8000) != 0;
|
||||
var enter = (Native.GetAsyncKeyState(0x0D) & 0x8000) != 0;
|
||||
var f9 = (Native.GetAsyncKeyState(0x78) & 0x8000) != 0;
|
||||
return new CursorState { X = pt.X, Y = pt.Y, Escape = esc, Enter = enter, F9 = f9 };
|
||||
}
|
||||
|
||||
private static FocusInfo FocusGenshinWindow()
|
||||
{
|
||||
var hwnd = FindGenshinWindow();
|
||||
var info = new FocusInfo
|
||||
{
|
||||
Hwnd = hwnd,
|
||||
ForegroundProcess = "",
|
||||
TargetProcess = ProcessNameFromHwnd(hwnd),
|
||||
};
|
||||
if (hwnd == IntPtr.Zero) return info;
|
||||
|
||||
info.AlreadyForeground = Native.GetForegroundWindow() == hwnd;
|
||||
if (!info.AlreadyForeground)
|
||||
{
|
||||
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
|
||||
// No ALT tap: this app and Genshin run at the same (elevated)
|
||||
// integrity level, so SetForegroundWindow succeeds on its own. An ALT
|
||||
// tap would toggle menu-mnemonic mode and swallow the next inputs.
|
||||
info.SetForegroundResult = Native.SetForegroundWindow(hwnd);
|
||||
Thread.Sleep(140);
|
||||
}
|
||||
|
||||
var foreground = Native.GetForegroundWindow();
|
||||
info.Focused = foreground == hwnd;
|
||||
info.ForegroundProcess = ProcessNameFromHwnd(foreground);
|
||||
return info;
|
||||
}
|
||||
|
||||
private static IntPtr FindGenshinWindow()
|
||||
{
|
||||
if (_genshinHwnd != IntPtr.Zero && Native.IsWindow(_genshinHwnd)) return _genshinHwnd;
|
||||
|
||||
foreach (var proc in Process.GetProcesses())
|
||||
{
|
||||
try
|
||||
{
|
||||
var name = proc.ProcessName;
|
||||
if ((name.Contains("GenshinImpact", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("YuanShen", StringComparison.OrdinalIgnoreCase)
|
||||
|| name.Contains("Genshin", StringComparison.OrdinalIgnoreCase))
|
||||
&& proc.MainWindowHandle != IntPtr.Zero)
|
||||
{
|
||||
_genshinHwnd = proc.MainWindowHandle;
|
||||
return _genshinHwnd;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process exited between enumeration and inspection; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
_genshinHwnd = IntPtr.Zero;
|
||||
return _genshinHwnd;
|
||||
}
|
||||
|
||||
private static Rect? GetGenshinClientBounds()
|
||||
{
|
||||
var hwnd = FindGenshinWindow();
|
||||
if (hwnd == IntPtr.Zero) return null;
|
||||
if (!Native.GetClientRect(hwnd, out var rect)) return null;
|
||||
|
||||
var topLeft = new Native.POINT { X = 0, Y = 0 };
|
||||
if (!Native.ClientToScreen(hwnd, ref topLeft)) return null;
|
||||
|
||||
var width = rect.Right - rect.Left;
|
||||
var height = rect.Bottom - rect.Top;
|
||||
if (width <= 0 || height <= 0) return null;
|
||||
|
||||
return new Rect { Left = topLeft.X, Top = topLeft.Y, Width = width, Height = height };
|
||||
}
|
||||
|
||||
private static string ProcessNameFromHwnd(IntPtr hwnd)
|
||||
{
|
||||
if (hwnd == IntPtr.Zero) return "";
|
||||
Native.GetWindowThreadProcessId(hwnd, out var pid);
|
||||
if (pid == 0) return "";
|
||||
try { return Process.GetProcessById((int)pid).ProcessName; }
|
||||
catch { return ""; }
|
||||
}
|
||||
|
||||
private static bool IsElevated()
|
||||
{
|
||||
using var identity = WindowsIdentity.GetCurrent();
|
||||
var principal = new WindowsPrincipal(identity);
|
||||
return principal.IsInRole(WindowsBuiltInRole.Administrator);
|
||||
}
|
||||
|
||||
private static uint SendMouseClickBatch()
|
||||
{
|
||||
var inputs = new Native.INPUT[2];
|
||||
inputs[0].type = 0; // INPUT_MOUSE
|
||||
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_LEFTDOWN;
|
||||
inputs[1].type = 0;
|
||||
inputs[1].mi.dwFlags = Native.MOUSEEVENTF_LEFTUP;
|
||||
return Native.SendInput(2, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||
}
|
||||
|
||||
private static uint SendMouseWheel(int wheelData)
|
||||
{
|
||||
var inputs = new Native.INPUT[1];
|
||||
inputs[0].type = 0;
|
||||
inputs[0].mi.mouseData = unchecked((uint)wheelData);
|
||||
inputs[0].mi.dwFlags = Native.MOUSEEVENTF_WHEEL;
|
||||
return Native.SendInput(1, inputs, Marshal.SizeOf<Native.INPUT>());
|
||||
}
|
||||
|
||||
private static string GetString(JsonElement root, string name)
|
||||
=> root.TryGetProperty(name, out var value) ? value.ToString() : "";
|
||||
|
||||
private static int GetInt(JsonElement root, string name)
|
||||
=> TryGetInt(root, name, out var value) ? value : 0;
|
||||
|
||||
private static bool TryGetInt(JsonElement root, string name, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!root.TryGetProperty(name, out var element)) return false;
|
||||
if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) return true;
|
||||
if (element.ValueKind == JsonValueKind.String && int.TryParse(element.GetString(), out value)) return true;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Native
|
||||
{
|
||||
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
||||
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
||||
public const uint MOUSEEVENTF_WHEEL = 0x0800;
|
||||
public static readonly IntPtr DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = new(-4);
|
||||
|
||||
[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; }
|
||||
|
||||
// Binary-compatible with the Win32 INPUT for mouse-only use on x64:
|
||||
// type(4) + 4 pad + MOUSEINPUT(32) = 40 bytes = sizeof(INPUT).
|
||||
[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;
|
||||
}
|
||||
|
||||
[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 SetProcessDpiAwarenessContext(IntPtr 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 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);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="GenshinAssistant.InputHelper" type="win32" />
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<!-- PerMonitorV2: coordinates stay correct across mixed-DPI monitors. -->
|
||||
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
|
||||
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">true/pm</dpiAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -15,6 +15,7 @@
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"eval": "vitest run src/eval/ocrEval.test.ts",
|
||||
"helper:build": "dotnet publish native/input-helper/InputHelper.csproj -c Release -o native/input-helper/bin/publish",
|
||||
"data:genshin": "node scripts/generate-genshin-data.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -48,6 +49,15 @@
|
||||
"dist-electron/**/*",
|
||||
"package.json"
|
||||
],
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "native/input-helper/bin/publish",
|
||||
"to": "input-helper",
|
||||
"filter": [
|
||||
"**/*"
|
||||
]
|
||||
}
|
||||
],
|
||||
"win": {
|
||||
"target": "nsis",
|
||||
"requestedExecutionLevel": "requireAdministrator"
|
||||
|
||||
Reference in New Issue
Block a user