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(),