317 lines
12 KiB
TypeScript
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(),
|
|
};
|
|
}
|