b8309af377
Follow-up to the AttachThreadInput change: verified against an isolated repro (a foreground-stealing window + the helper spawned exactly like the app) that AttachThreadInput + clearing the foreground-lock timeout was NOT sufficient on this Windows build - SetForegroundWindow still returned false and Genshin stayed in the background. The missing condition is "the calling process received the last input event". Injecting a benign no-op input (a 0,0 relative mouse move, no cursor movement, no menu-mnemonic side effect) right before SetForegroundWindow satisfies it. With the nudge the repro now returns focused:true / setForegroundResult:true from a background process while another app holds the foreground - the exact auto-scan start scenario. Applied to both the C# sidecar and the PowerShell fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
536 lines
20 KiB
C#
536 lines
20 KiB
C#
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)
|
|
{
|
|
info.SetForegroundResult = ForceForeground(hwnd);
|
|
Thread.Sleep(140);
|
|
}
|
|
|
|
var foreground = Native.GetForegroundWindow();
|
|
info.Focused = foreground == hwnd;
|
|
info.ForegroundProcess = ProcessNameFromHwnd(foreground);
|
|
return info;
|
|
}
|
|
|
|
// Plain SetForegroundWindow from a background process is silently refused by
|
|
// Windows' foreground lock. Inventory Kamera and other reliable automation
|
|
// tools bypass it by attaching the calling thread's input queue to the target
|
|
// (and current-foreground) window thread and clearing the lock timeout, so the
|
|
// foreground change is honored. Without this the auto-scan aborts with
|
|
// "Genshin konnte nicht in den Vordergrund geholt werden".
|
|
private static bool ForceForeground(IntPtr hwnd)
|
|
{
|
|
var current = Native.GetCurrentThreadId();
|
|
var target = Native.GetWindowThreadProcessId(hwnd, out _);
|
|
var foregroundHwnd = Native.GetForegroundWindow();
|
|
var foreground = foregroundHwnd != IntPtr.Zero ? Native.GetWindowThreadProcessId(foregroundHwnd, out _) : 0u;
|
|
|
|
var attachedTarget = false;
|
|
var attachedForeground = false;
|
|
uint oldTimeout = 0;
|
|
var timeoutRead = false;
|
|
try
|
|
{
|
|
if (target != 0 && target != current) attachedTarget = Native.AttachThreadInput(current, target, true);
|
|
if (foreground != 0 && foreground != current && foreground != target)
|
|
attachedForeground = Native.AttachThreadInput(current, foreground, true);
|
|
|
|
timeoutRead = Native.SystemParametersInfo(Native.SPI_GETFOREGROUNDLOCKTIMEOUT, 0, ref oldTimeout, 0);
|
|
Native.SystemParametersInfo(Native.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, IntPtr.Zero, Native.SPIF_SENDCHANGE);
|
|
|
|
// Inject a no-op input (0,0 relative mouse move) so this process counts
|
|
// as the last input source - one of the conditions Windows requires to
|
|
// allow a foreground change. This is what the removed ALT tap did, but
|
|
// without the menu-mnemonic side effect.
|
|
NudgeInput();
|
|
|
|
Native.ShowWindowAsync(hwnd, 9); // SW_RESTORE
|
|
Native.BringWindowToTop(hwnd);
|
|
var ok = Native.SetForegroundWindow(hwnd);
|
|
return ok;
|
|
}
|
|
finally
|
|
{
|
|
if (timeoutRead)
|
|
Native.SystemParametersInfo(Native.SPI_SETFOREGROUNDLOCKTIMEOUT, 0, new IntPtr((long)oldTimeout), Native.SPIF_SENDCHANGE);
|
|
if (attachedForeground) Native.AttachThreadInput(current, foreground, false);
|
|
if (attachedTarget) Native.AttachThreadInput(current, target, false);
|
|
}
|
|
}
|
|
|
|
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 void NudgeInput()
|
|
{
|
|
var move = new Native.INPUT[1];
|
|
move[0].type = 0; // INPUT_MOUSE
|
|
move[0].mi.dwFlags = Native.MOUSEEVENTF_MOVE; // dx=dy=0 -> no cursor movement
|
|
Native.SendInput(1, move, Marshal.SizeOf<Native.INPUT>());
|
|
}
|
|
|
|
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_MOVE = 0x0001;
|
|
public const uint MOUSEEVENTF_LEFTDOWN = 0x0002;
|
|
public const uint MOUSEEVENTF_LEFTUP = 0x0004;
|
|
public const uint MOUSEEVENTF_WHEEL = 0x0800;
|
|
public const uint SPI_GETFOREGROUNDLOCKTIMEOUT = 0x2000;
|
|
public const uint SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001;
|
|
public const uint SPIF_SENDCHANGE = 0x0002;
|
|
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 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)]
|
|
public static extern bool SystemParametersInfo(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
|
|
|
[DllImport("user32.dll", SetLastError = true)]
|
|
public static extern bool SystemParametersInfo(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);
|
|
}
|