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:
@@ -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);
|
||||
const capturePath = String(result.path);
|
||||
const buffer = await fs.readFile(capturePath);
|
||||
await fs.unlink(capturePath).catch(() => undefined);
|
||||
// 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),
|
||||
|
||||
Reference in New Issue
Block a user