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:
AzuTear
2026-07-05 21:33:26 +02:00
parent 92345ef51a
commit c7138b541d
8 changed files with 597 additions and 12 deletions
+21 -1
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import http, { type Server } from "node:http";
import path from "node:path";
import { fileURLToPath } from "node:url";
@@ -70,6 +71,25 @@ function getInputHelperService() {
return inputHelperService;
}
// Locate the compiled C# input/capture sidecar (ADR-008). Falls back to null so
// the service uses the embedded PowerShell helper when the exe was never built.
function resolveInputHelperExePath(): string | null {
const candidates = [
process.env.INPUT_HELPER_EXE,
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
].filter((candidate): candidate is string => Boolean(candidate));
for (const candidate of candidates) {
try {
if (existsSync(candidate)) return candidate;
} catch {
// Unreadable path; try the next candidate.
}
}
return null;
}
function getRepositoryContext() {
if (!repositoryContext) {
throw new Error("Repository context has not been initialized.");
@@ -1032,7 +1052,7 @@ function initializeAppLifecycle() {
artifactStoreRepository = repositoryContext.artifactStoreRepository;
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
scannerLearningRepository = repositoryContext.scannerLearningRepository;
inputHelperService = createInputHelperService({ userDataPath });
inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
registerIpcHandlers({
focusMainWindow: () => focusMainWindow(),
+48 -11
View File
@@ -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),