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 void; reject: (error: Error) => void; timer: NodeJS.Timeout }>(); private buffer = ""; private nextId = 1; private starting: Promise | 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, timeoutMs: number) { return new Promise((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 = {}, 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; focusGenshinWindow(): Promise; focusGenshinForScanStart(): Promise; getGenshinWindowBounds(): Promise; clickScreen(x: number, y: number): Promise; scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise; keyPress(key: string): Promise; getAutomationGuard(): Promise; capturePrimaryScreenViaGdi(): Promise; 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 = {}, 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 = { 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 { ...(typeof result.imageBase64 === "string" && result.imageBase64 ? { imageBase64: base64 } : { 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(), }; }