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:
+21
-1
@@ -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(),
|
||||
|
||||
@@ -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