Files
genshin-assistant/electron/services/inputHelper.ts
T
2026-07-09 08:44:50 +02:00

317 lines
12 KiB
TypeScript

import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type {
AutomationGuard,
ClickResult,
FocusGenshinResult,
GdiCaptureResult,
HelperOperationResponse,
KeyPressResult,
WindowBounds,
RuntimeInfo,
ScrollResult,
} from "../../src/types/global.js";
import { INPUT_HELPER_SCRIPT } from "./inputHelperPowerShellFallback.js";
class InputHelperClient {
private child: ChildProcessWithoutNullStreams | null = null;
private pending = new Map<string, { resolve: (value: HelperOperationResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private buffer = "";
private nextId = 1;
private starting: Promise<void> | null = null;
private disposed = false;
constructor(private readonly options: { scriptUserDataPath: string; exePath?: string | null }) {}
private async ensureStarted() {
if (this.child) return;
if (this.disposed) throw new Error("Input helper disposed");
if (!this.starting) {
this.starting = this.start().finally(() => {
this.starting = null;
});
}
await this.starting;
}
private async start() {
// Prefer the compiled C# sidecar (ADR-008). If it is missing or fails to
// start, fall back to the embedded PowerShell helper so the app keeps working
// on machines where the native exe was never built.
if (this.options.exePath) {
try {
await this.startWith(spawn(this.options.exePath, [], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }));
return;
} catch {
this.teardownChild();
}
}
await this.startWith(await this.spawnPowershell());
}
private async spawnPowershell() {
const scriptPath = path.join(this.options.scriptUserDataPath, "input-helper.ps1");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
return spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
}
private async startWith(child: ChildProcessWithoutNullStreams) {
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", () => undefined);
child.on("exit", () => {
this.child = null;
this.buffer = "";
for (const entry of this.pending.values()) {
clearTimeout(entry.timer);
entry.reject(new Error("Input helper exited"));
}
this.pending.clear();
});
this.child = child;
// The C# sidecar answers ping immediately; the PowerShell fallback compiles
// Win32 interop on the first request, so give it extra time.
await this.send("ping", {}, 20000);
}
private teardownChild() {
const child = this.child;
this.child = null;
this.buffer = "";
try {
child?.kill();
} catch {
// Child was never spawned or already gone.
}
}
private handleStdout(chunk: string) {
this.buffer += chunk;
let newlineIndex = this.buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
newlineIndex = this.buffer.indexOf("\n");
if (!line.startsWith("{")) continue;
try {
const message = JSON.parse(line) as HelperOperationResponse;
const entry = this.pending.get(String(message.id));
if (!entry) continue;
this.pending.delete(String(message.id));
clearTimeout(entry.timer);
if (message.ok) entry.resolve(message);
else entry.reject(new Error(message.error || "Input helper command failed"));
} catch {
// Ignore non-JSON noise on stdout.
}
}
}
private send(op: string, params: Record<string, unknown>, timeoutMs: number) {
return new Promise<HelperOperationResponse>((resolve, reject) => {
const child = this.child;
if (!child?.stdin.writable) {
reject(new Error("Input helper is not running"));
return;
}
const id = String(this.nextId++);
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Input helper timed out on ${op}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`);
});
}
request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return this.ensureStarted().then(() => this.send(op, params, timeoutMs));
}
dispose() {
this.disposed = true;
this.child?.kill();
this.child = null;
}
}
export interface InputHelperService {
getRuntimeInfo(): Promise<RuntimeInfo>;
focusGenshinWindow(): Promise<FocusGenshinResult>;
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
getGenshinWindowBounds(): Promise<WindowBounds | null>;
clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
keyPress(key: string): Promise<KeyPressResult>;
getAutomationGuard(): Promise<AutomationGuard>;
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
dispose(): void;
}
export function createInputHelperService(options: { userDataPath: string; exePath?: string | null }): InputHelperService {
const inputHelper = new InputHelperClient({ scriptUserDataPath: options.userDataPath, exePath: options.exePath ?? null });
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return inputHelper.request(op, params, timeoutMs);
}
async function getRuntimeInfo() {
const result = await request("runtime", {}, 4000);
return {
ok: true,
isElevated: Boolean(result.isElevated),
platform: process.platform,
genshinFound: Boolean(result.genshinFound),
genshinHwnd: typeof result.genshinHwnd === "number" ? result.genshinHwnd : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
foregroundHwnd: typeof result.foregroundHwnd === "number" ? result.foregroundHwnd : undefined,
helperPid: typeof result.helperPid === "number" ? result.helperPid : undefined,
};
}
async function focusGenshinWindow() {
const result = await request("focus", {}, 6000);
return {
focused: Boolean(result.focused),
alreadyForeground: Boolean(result.alreadyForeground),
genshinFound: Boolean(result.genshinFound),
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
setForegroundResult: typeof result.setForegroundResult === "boolean" ? result.setForegroundResult : undefined,
};
}
async function focusGenshinForScanStart() {
let result = await focusGenshinWindow();
if (result.focused) return result;
await new Promise((resolve) => setTimeout(resolve, 700));
result = await focusGenshinWindow();
return result;
}
async function getGenshinWindowBounds() {
const result = await request("bounds", {}, 4000);
if (!result.found) return null;
return {
x: Number(result.left),
y: Number(result.top),
width: Number(result.width),
height: Number(result.height),
};
}
async function clickScreen(x: number, y: number) {
const result = (await request("click", { x: Math.round(x), y: Math.round(y) }, 8000)) as HelperOperationResponse;
return {
ok: true,
x: Math.round(x),
y: Math.round(y),
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
escapePressed: Boolean(result.escapePressed),
enterPressed: Boolean(result.enterPressed),
f9Pressed: Boolean(result.f9Pressed),
moved: Boolean(result.moved),
clicked: Boolean(result.clicked),
inputBlocked: Boolean(result.inputBlocked),
focused: Boolean(result.focused),
alreadyForeground: Boolean(result.alreadyForeground),
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
};
}
async function scrollScreen(notches: number, anchorX?: number, anchorY?: number) {
const safeNotches = Math.max(-60, Math.min(60, Math.round(notches)));
const params: Record<string, unknown> = { notches: safeNotches };
if (typeof anchorX === "number" && typeof anchorY === "number") {
params.x = Math.round(anchorX);
params.y = Math.round(anchorY);
}
const result = (await request("scroll", params, 8000 + Math.abs(safeNotches) * 80)) as HelperOperationResponse;
return {
ok: true,
notchesSent: Number(result.notchesSent ?? 0),
inputBlocked: Boolean(result.inputBlocked),
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
};
}
async function 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 {
ok: true,
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
escapePressed: Boolean(result.escapePressed),
enterPressed: Boolean(result.enterPressed),
f9Pressed: Boolean(result.f9Pressed),
};
}
async function capturePrimaryScreenViaGdi() {
const result = await request("capture", {}, 15000);
// The C# sidecar returns PNG bytes inline (no temp file). The PowerShell
// fallback writes a temp PNG and returns its path.
let base64: string;
if (typeof result.imageBase64 === "string" && result.imageBase64) {
base64 = result.imageBase64;
} else {
const capturePath = String(result.path);
const buffer = await fs.readFile(capturePath);
await fs.unlink(capturePath).catch(() => undefined);
base64 = buffer.toString("base64");
}
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
? captureTargetRaw
: "primary-screen";
return {
dataUrl: `data:image/png;base64,${base64}`,
width: Number(result.width),
height: Number(result.height),
originX: Number(result.originX),
originY: Number(result.originY),
captureTarget,
};
}
return {
getRuntimeInfo,
focusGenshinWindow,
focusGenshinForScanStart,
getGenshinWindowBounds,
clickScreen,
scrollScreen,
keyPress,
getAutomationGuard,
capturePrimaryScreenViaGdi,
dispose: () => inputHelper.dispose(),
};
}