c7138b541d
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>
1118 lines
36 KiB
TypeScript
1118 lines
36 KiB
TypeScript
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";
|
|
import { createWorker } from "tesseract.js";
|
|
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
|
|
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
|
|
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
|
import type { AppSnapshot } from "../src/types/domain.js";
|
|
import type {
|
|
CaptureOptions,
|
|
CaptureResult,
|
|
GoodDatabase,
|
|
SaveResultWithPath,
|
|
ScannerLearningRulePayload,
|
|
ScannerStatusPayload,
|
|
} from "../src/types/global.js";
|
|
import type {
|
|
ArtifactStoreRepositoryPort,
|
|
ReviewSamplesRepositoryPort,
|
|
ScannerLearningRepositoryPort,
|
|
} from "./repositories/index.js";
|
|
|
|
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
|
// crash the GPU/renderer process) when the hosting process runs with a full
|
|
// Administrator token - a well-known Electron-on-Windows-elevation quirk.
|
|
// This app already requires elevation for input automation and never loads
|
|
// untrusted remote content, so the renderer sandbox has little security
|
|
// value here; disabling it avoids "works normally, fails only when run as
|
|
// admin" failures with no visible error. Must run before app is ready.
|
|
app.commandLine.appendSwitch("no-sandbox");
|
|
app.commandLine.appendSwitch("disable-gpu-sandbox");
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
|
|
|
let mainWindow: BrowserWindow | null = null;
|
|
let overlayWindow: BrowserWindow | null = null;
|
|
let registeredHotkeys: Record<string, boolean> = {};
|
|
let devControlServer: Server | null = null;
|
|
let scannerDevStatus: ScannerStatusPayload = {
|
|
running: false,
|
|
reviewStatus: "",
|
|
captureStatus: "",
|
|
selectedSource: null,
|
|
stats: {},
|
|
summary: null,
|
|
snapshotArtifacts: 0,
|
|
snapshotCharacters: 0,
|
|
snapshotRecommendations: 0,
|
|
snapshotBuilds: 0,
|
|
grid: null,
|
|
automationLog: [],
|
|
runtimeInfo: null,
|
|
storedTotal: null,
|
|
learningRuleCount: 0,
|
|
updatedAt: null,
|
|
};
|
|
let repositoryContext: RepositoryContext | null = null;
|
|
let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null;
|
|
let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null;
|
|
let scannerLearningRepository: ScannerLearningRepositoryPort | null = null;
|
|
let inputHelperService: InputHelperService | null = null;
|
|
|
|
function getInputHelperService() {
|
|
if (!inputHelperService) {
|
|
throw new Error("Input-helper service has not been initialized.");
|
|
}
|
|
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.");
|
|
}
|
|
return repositoryContext;
|
|
}
|
|
|
|
function artifactStorePath() {
|
|
return getRepositoryContext().artifactStorePath;
|
|
}
|
|
|
|
function reviewSamplesPath() {
|
|
return getRepositoryContext().reviewSamplesPath;
|
|
}
|
|
|
|
function scannerLearningPath() {
|
|
return getRepositoryContext().scannerLearningPath;
|
|
}
|
|
|
|
function getReviewSamplesRepository() {
|
|
if (!reviewSamplesRepository) {
|
|
throw new Error("Review-sample repository is not initialized.");
|
|
}
|
|
return reviewSamplesRepository;
|
|
}
|
|
|
|
async function loadReviewSamples(limit = 50) {
|
|
try {
|
|
const repository = getReviewSamplesRepository();
|
|
return repository.list(Math.max(1, Math.min(200, Number(limit) || 50)));
|
|
} catch {
|
|
return { ok: true, samples: [], total: 0, path: reviewSamplesPath() };
|
|
}
|
|
}
|
|
|
|
async function loadScannerLearningRules() {
|
|
try {
|
|
return await getScannerLearningRepository().load();
|
|
} catch {
|
|
return { ok: true, path: scannerLearningPath(), rules: { textReplacements: {} } };
|
|
}
|
|
}
|
|
|
|
function getScannerLearningRepository() {
|
|
if (!scannerLearningRepository) {
|
|
throw new Error("Scanner-learning repository is not initialized.");
|
|
}
|
|
return scannerLearningRepository;
|
|
}
|
|
|
|
async function writeScannerLearningRules(rules: ScannerLearningRulePayload) {
|
|
const safeRules = rules && typeof rules === "object" ? rules : {};
|
|
try {
|
|
return await getScannerLearningRepository().save(safeRules as { textReplacements?: Record<string, string> });
|
|
} catch {
|
|
return { ok: true, path: scannerLearningPath(), rules: { textReplacements: {} }, total: 0 };
|
|
}
|
|
}
|
|
|
|
function getArtifactStoreRepository() {
|
|
if (!artifactStoreRepository) {
|
|
throw new Error("Artifact store repository is not initialized.");
|
|
}
|
|
return artifactStoreRepository;
|
|
}
|
|
|
|
function getSnapshotRepository() {
|
|
const context = getRepositoryContext();
|
|
if (!context.snapshotRepository) {
|
|
throw new Error("Snapshot repository is not initialized.");
|
|
}
|
|
return context.snapshotRepository;
|
|
}
|
|
|
|
function exportPath(fileName: string) {
|
|
return path.join(app.getPath("userData"), "exports", fileName);
|
|
}
|
|
|
|
async function loadSnapshotFromDisk() {
|
|
try {
|
|
return await getSnapshotRepository().load();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function saveSnapshotToDisk(snapshot: AppSnapshot) {
|
|
try {
|
|
return await getSnapshotRepository().save(snapshot);
|
|
} catch {
|
|
return { ok: false, path: "" };
|
|
}
|
|
}
|
|
|
|
async function publishScannerStatus(status: ScannerStatusPayload) {
|
|
scannerDevStatus = { ...status, updatedAt: new Date().toISOString() };
|
|
return { ok: true };
|
|
}
|
|
|
|
async function readRuntimeInfo() {
|
|
try {
|
|
const result = await getInputHelperService().getRuntimeInfo();
|
|
return {
|
|
ok: true,
|
|
isElevated: result.isElevated,
|
|
platform: result.platform,
|
|
hotkeys: registeredHotkeys,
|
|
genshinFound: result.genshinFound,
|
|
genshinHwnd: result.genshinHwnd ?? undefined,
|
|
targetProcess: result.targetProcess,
|
|
foregroundProcess: result.foregroundProcess,
|
|
foregroundHwnd: result.foregroundHwnd ?? undefined,
|
|
helperPid: result.helperPid,
|
|
};
|
|
} catch {
|
|
return { ok: false, isElevated: false, platform: process.platform, hotkeys: registeredHotkeys };
|
|
}
|
|
}
|
|
|
|
async function focusGenshinWindow() {
|
|
try {
|
|
const result = await getInputHelperService().focusGenshinWindow();
|
|
return result;
|
|
} catch {
|
|
return { focused: false, alreadyForeground: false, genshinFound: false };
|
|
}
|
|
}
|
|
|
|
async function focusGenshinForScanStart() {
|
|
try {
|
|
return await getInputHelperService().focusGenshinForScanStart();
|
|
} catch {
|
|
return { focused: false, alreadyForeground: false, genshinFound: false };
|
|
}
|
|
}
|
|
|
|
async function getGenshinWindowBounds() {
|
|
try {
|
|
return await getInputHelperService().getGenshinWindowBounds();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Our own dashboard window defaulted to Electron's normal placement, which
|
|
// (on this exact reported bug) ended up sitting directly on top of Genshin's
|
|
// fullscreen window on the same monitor - so every simulated click that
|
|
// looked correct (focused, on-target, injected) was actually landing on our
|
|
// own window, not the game, since mouse hit-testing goes by which window is
|
|
// topmost at that screen pixel, not by which window has keyboard focus. If a
|
|
// second display exists and isn't the one Genshin occupies, move the
|
|
// dashboard there so it can never cover the grid we're about to click.
|
|
async function moveMainWindowOffGenshin() {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
const genshinBounds = await getGenshinWindowBounds();
|
|
const displays = screen.getAllDisplays();
|
|
if (displays.length < 2) return;
|
|
|
|
const genshinCenter = genshinBounds
|
|
? { x: genshinBounds.x + genshinBounds.width / 2, y: genshinBounds.y + genshinBounds.height / 2 }
|
|
: null;
|
|
const genshinDisplay = genshinCenter
|
|
? screen.getDisplayNearestPoint(genshinCenter)
|
|
: screen.getPrimaryDisplay();
|
|
|
|
const otherDisplay = displays.find((d) => d.id !== genshinDisplay.id);
|
|
if (!otherDisplay) return;
|
|
|
|
const currentBounds = mainWindow.getBounds();
|
|
const alreadyOnOtherDisplay = screen.getDisplayMatching(currentBounds).id === otherDisplay.id;
|
|
if (alreadyOnOtherDisplay) return;
|
|
|
|
const area = otherDisplay.workArea;
|
|
const width = Math.min(currentBounds.width, area.width - 40);
|
|
const height = Math.min(currentBounds.height, area.height - 40);
|
|
mainWindow.setBounds({
|
|
x: Math.round(area.x + (area.width - width) / 2),
|
|
y: Math.round(area.y + (area.height - height) / 2),
|
|
width: Math.round(width),
|
|
height: Math.round(height),
|
|
});
|
|
}
|
|
|
|
async function runMockScan() {
|
|
return null;
|
|
}
|
|
|
|
async function showOverlayWindow() {
|
|
createOverlayWindow();
|
|
return { ok: true };
|
|
}
|
|
|
|
async function hideOverlayWindow() {
|
|
overlayWindow?.close();
|
|
return { ok: true };
|
|
}
|
|
|
|
async function listCaptureSources() {
|
|
const displays = screen.getAllDisplays();
|
|
const maxSize = displays.reduce(
|
|
(size, display) => ({
|
|
width: Math.max(size.width, display.size.width),
|
|
height: Math.max(size.height, display.size.height),
|
|
}),
|
|
{ width: 1920, height: 1080 },
|
|
);
|
|
|
|
const sources = await desktopCapturer.getSources({
|
|
types: ["window", "screen"],
|
|
thumbnailSize: maxSize,
|
|
fetchWindowIcons: true,
|
|
});
|
|
|
|
return sources.map((source) => ({
|
|
id: source.id,
|
|
name: source.name,
|
|
isGenshinCandidate: isLikelyGenshinSourceName(source.name),
|
|
thumbnailDataUrl: source.thumbnail.resize({ width: 420 }).toDataURL(),
|
|
}));
|
|
}
|
|
|
|
async function clickScreenCommand(x: number, y: number) {
|
|
return getInputHelperService().clickScreen(Math.round(x), Math.round(y));
|
|
}
|
|
|
|
async function scrollScreenCommand(notches: number, anchorX?: number, anchorY?: number) {
|
|
return getInputHelperService().scrollScreen(notches, anchorX, anchorY);
|
|
}
|
|
|
|
async function getAutomationGuardCommand() {
|
|
const result = await getInputHelperService().getAutomationGuard();
|
|
return {
|
|
...result,
|
|
ok: true,
|
|
// Preserve historical automation metadata shape expected by callers.
|
|
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : false,
|
|
};
|
|
}
|
|
|
|
function isLikelyGenshinSourceName(sourceName: string) {
|
|
const lowered = sourceName.toLowerCase();
|
|
return (
|
|
lowered.includes("genshin")
|
|
|| lowered.includes("genshinimpact")
|
|
|| lowered.includes("yuanshen")
|
|
|| sourceName.includes("\u539f\u795e")
|
|
);
|
|
}
|
|
|
|
async function capturePrimaryScreenViaGdi() {
|
|
return getInputHelperService().capturePrimaryScreenViaGdi();
|
|
}
|
|
|
|
async function captureSourceFromGdi(sourceId: string, sourceName: string, options: CaptureOptions = {}) {
|
|
const gdi = await capturePrimaryScreenViaGdi();
|
|
const sourceImage = nativeImage.createFromDataURL(gdi.dataUrl);
|
|
return await buildCaptureResult(sourceImage, sourceId, sourceName, gdi.captureTarget, options);
|
|
}
|
|
|
|
function createMainWindow() {
|
|
Menu.setApplicationMenu(null);
|
|
|
|
mainWindow = new BrowserWindow({
|
|
width: 1320,
|
|
height: 860,
|
|
minWidth: 1120,
|
|
minHeight: 720,
|
|
backgroundColor: "#090711",
|
|
title: "Genshin Artifact Assistant",
|
|
show: false,
|
|
autoHideMenuBar: true,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.cjs"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
|
|
mainWindow.setMenuBarVisibility(false);
|
|
mainWindow.on("closed", () => {
|
|
mainWindow = null;
|
|
});
|
|
mainWindow.once("ready-to-show", () => {
|
|
void moveMainWindowOffGenshin();
|
|
focusMainWindow();
|
|
});
|
|
mainWindow.webContents.once("did-finish-load", () => {
|
|
setTimeout(() => focusMainWindow(), 350);
|
|
});
|
|
|
|
if (isDev) {
|
|
mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL!);
|
|
} else {
|
|
mainWindow.loadFile(path.join(__dirname, "../dist/index.html"));
|
|
}
|
|
}
|
|
|
|
function focusMainWindow() {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return { ok: false };
|
|
|
|
if (mainWindow.isMinimized()) mainWindow.restore();
|
|
mainWindow.show();
|
|
// Genshin often keeps foreground focus after a scan click. Toggling
|
|
// always-on-top for one tick nudges Windows to surface the dashboard again
|
|
// without leaving it pinned above other apps.
|
|
mainWindow.setAlwaysOnTop(true, "screen-saver");
|
|
mainWindow.focus();
|
|
setTimeout(() => {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
mainWindow.setAlwaysOnTop(false);
|
|
mainWindow.focus();
|
|
}, 250);
|
|
return { ok: true };
|
|
}
|
|
|
|
function sendScannerCommand(command: "start-auto" | "stop" | "probe-click") {
|
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
|
mainWindow.webContents.send("scanner:command", command);
|
|
}
|
|
|
|
function registerScannerHotkeys() {
|
|
globalShortcut.unregisterAll();
|
|
registeredHotkeys = {
|
|
"Ctrl+Shift+S": globalShortcut.register("CommandOrControl+Shift+S", () => sendScannerCommand("start-auto")),
|
|
"Ctrl+Shift+X": globalShortcut.register("CommandOrControl+Shift+X", () => sendScannerCommand("stop")),
|
|
F8: globalShortcut.register("F8", () => sendScannerCommand("start-auto")),
|
|
F9: globalShortcut.register("F9", () => sendScannerCommand("stop")),
|
|
};
|
|
}
|
|
|
|
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
|
res.writeHead(statusCode, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store",
|
|
});
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function startDevControlServer() {
|
|
if (!isDev || devControlServer) return;
|
|
|
|
devControlServer = http.createServer((req, res) => {
|
|
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
|
writeDevJson(res, 403, { ok: false, error: "local only" });
|
|
return;
|
|
}
|
|
|
|
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
if (url.pathname === "/health") {
|
|
writeDevJson(res, 200, { ok: true, hotkeys: registeredHotkeys, hasWindow: Boolean(mainWindow && !mainWindow.isDestroyed()) });
|
|
return;
|
|
}
|
|
if (url.pathname === "/scanner/start") {
|
|
sendScannerCommand("start-auto");
|
|
writeDevJson(res, 200, { ok: true, command: "start-auto" });
|
|
return;
|
|
}
|
|
if (url.pathname === "/scanner/stop") {
|
|
sendScannerCommand("stop");
|
|
writeDevJson(res, 200, { ok: true, command: "stop" });
|
|
return;
|
|
}
|
|
if (url.pathname === "/scanner/probe") {
|
|
sendScannerCommand("probe-click");
|
|
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
|
return;
|
|
}
|
|
if (url.pathname === "/automation/click") {
|
|
const x = Number(url.searchParams.get("x"));
|
|
const y = Number(url.searchParams.get("y"));
|
|
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
|
return;
|
|
}
|
|
getInputHelperService()
|
|
.clickScreen(Math.round(x), Math.round(y))
|
|
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
return;
|
|
}
|
|
if (url.pathname === "/scanner/status") {
|
|
writeDevJson(res, 200, { ok: true, status: scannerDevStatus });
|
|
return;
|
|
}
|
|
if (url.pathname === "/review/samples") {
|
|
loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
|
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
|
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
|
return;
|
|
}
|
|
|
|
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
|
});
|
|
|
|
devControlServer.listen(17317, "127.0.0.1");
|
|
}
|
|
|
|
function createOverlayWindow() {
|
|
if (overlayWindow) {
|
|
overlayWindow.show();
|
|
return;
|
|
}
|
|
|
|
const display = screen.getPrimaryDisplay();
|
|
overlayWindow = new BrowserWindow({
|
|
x: display.workArea.x,
|
|
y: display.workArea.y,
|
|
width: display.workArea.width,
|
|
height: display.workArea.height,
|
|
transparent: true,
|
|
frame: false,
|
|
alwaysOnTop: true,
|
|
skipTaskbar: true,
|
|
resizable: false,
|
|
focusable: false,
|
|
webPreferences: {
|
|
preload: path.join(__dirname, "preload.cjs"),
|
|
contextIsolation: true,
|
|
nodeIntegration: false,
|
|
},
|
|
});
|
|
|
|
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
|
|
|
|
if (isDev) {
|
|
overlayWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL!}?overlay=1`);
|
|
} else {
|
|
overlayWindow.loadFile(path.join(__dirname, "../dist/index.html"), {
|
|
query: { overlay: "1" },
|
|
});
|
|
}
|
|
|
|
overlayWindow.on("closed", () => {
|
|
overlayWindow = null;
|
|
});
|
|
}
|
|
|
|
function dataUrlToBuffer(dataUrl: string) {
|
|
const base64 = dataUrl.replace(/^data:image\/png;base64,/, "");
|
|
return Buffer.from(base64, "base64");
|
|
}
|
|
|
|
// One shared OCR worker. Creating a Tesseract worker per capture added ~1s
|
|
// to every artifact during batch scans.
|
|
let ocrWorkerPromise: ReturnType<typeof createWorker> | null = null;
|
|
|
|
function getOcrWorker() {
|
|
if (!ocrWorkerPromise) {
|
|
ocrWorkerPromise = createWorker("eng");
|
|
}
|
|
return ocrWorkerPromise;
|
|
}
|
|
|
|
async function resetOcrWorker() {
|
|
const broken = ocrWorkerPromise;
|
|
ocrWorkerPromise = null;
|
|
if (broken) {
|
|
try {
|
|
const worker = await broken;
|
|
await worker.terminate();
|
|
} catch {
|
|
// Worker never initialized; nothing to clean up.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runOcrOnCrops(crops: Array<{ id: string; label: string; dataUrl: string }>) {
|
|
try {
|
|
const worker = await getOcrWorker();
|
|
const results = [];
|
|
for (const crop of crops) {
|
|
const recognized = await worker.recognize(dataUrlToBuffer(crop.dataUrl));
|
|
results.push({
|
|
id: crop.id,
|
|
label: crop.label,
|
|
text: cleanOcrText(crop.id, recognized.data.text),
|
|
confidence: Math.round(recognized.data.confidence),
|
|
});
|
|
}
|
|
return results;
|
|
} catch (error) {
|
|
await resetOcrWorker();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function runOcrOnCropsWithTimeout(crops: Array<{ id: string; label: string; dataUrl: string }>, timeoutMs = 6500) {
|
|
let timeout: NodeJS.Timeout | undefined;
|
|
try {
|
|
return await Promise.race([
|
|
runOcrOnCrops(crops).then((ocr) => ({ ocr, timedOut: false })),
|
|
new Promise<{ ocr: Awaited<ReturnType<typeof runOcrOnCrops>>; timedOut: boolean }>((resolve) => {
|
|
timeout = setTimeout(() => resolve({ ocr: [], timedOut: true }), timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timeout) clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function cleanOcrText(cropId: string, text: string) {
|
|
const normalized = text
|
|
.replace(/[“â€]/g, '"')
|
|
.replace(/[’]/g, "'")
|
|
.replace(/\r/g, "")
|
|
.split("\n")
|
|
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
.filter(Boolean);
|
|
|
|
if (cropId === "artifact-footer") {
|
|
const equipped = normalized.find((line) => /equipped/i.test(line));
|
|
if (!equipped) return "";
|
|
|
|
const match = /equipped\s*:?\s*([A-Za-z][A-Za-z'\-\s]{1,32})/i.exec(equipped);
|
|
return match ? `Equipped: ${match[1].replace(/[^A-Za-z'\-\s]/g, "").trim()}` : equipped;
|
|
}
|
|
|
|
if (cropId === "artifact-title") {
|
|
return normalized
|
|
.filter((line) => /[A-Za-z]/.test(line))
|
|
.slice(0, 2)
|
|
.join("\n");
|
|
}
|
|
|
|
if (cropId === "artifact-main-stat") {
|
|
return normalized
|
|
.filter((line) => /[A-Za-z0-9]/.test(line))
|
|
.slice(0, 3)
|
|
.join("\n");
|
|
}
|
|
|
|
if (cropId === "artifact-substats") {
|
|
return normalized
|
|
.filter((line) => /(\+|CRIT|ATK|DEF|HP|Energy|Elemental)/i.test(line))
|
|
.slice(0, 5)
|
|
.join("\n");
|
|
}
|
|
|
|
if (cropId === "inventory-count") {
|
|
return normalized
|
|
.map((line) => line.replace(/[^0-9/]/g, ""))
|
|
.find((line) => /[0-9]/.test(line)) ?? "";
|
|
}
|
|
|
|
return normalized.join("\n");
|
|
}
|
|
|
|
function parseInventoryCount(ocr: Array<{ id: string; text: string; confidence: number }>) {
|
|
const entry = ocr.find((item) => item.id === "inventory-count");
|
|
if (!entry?.text) return { current: 0, total: 0, confidence: 0, source: "missing" as const, text: "" };
|
|
const cleaned = entry.text.replace(/[^0-9/]/g, "");
|
|
const match = /^(\d{1,4})\/(\d{3,4})$/.exec(cleaned);
|
|
if (match) {
|
|
return {
|
|
current: Number(match[1]),
|
|
total: Number(match[2]),
|
|
confidence: Math.max(0, Math.min(100, entry.confidence)),
|
|
source: "ocr" as const,
|
|
text: cleaned,
|
|
};
|
|
}
|
|
|
|
const fallback = cleaned.match(/(\d{1,4})(\d{4})$/);
|
|
if (fallback) {
|
|
return {
|
|
current: Number(fallback[1]),
|
|
total: Number(fallback[2]),
|
|
confidence: Math.max(0, Math.min(84, entry.confidence)),
|
|
source: "ocr" as const,
|
|
text: cleaned,
|
|
};
|
|
}
|
|
|
|
return { current: 0, total: 0, confidence: 0, source: "missing" as const, text: cleaned };
|
|
}
|
|
|
|
function isArtifactTitleOrange(bitmap: Buffer, index: number) {
|
|
const blue = bitmap[index];
|
|
const green = bitmap[index + 1];
|
|
const red = bitmap[index + 2];
|
|
|
|
return red >= 135 && green >= 70 && green <= 155 && blue <= 95 && red > green + 35 && green > blue + 20;
|
|
}
|
|
|
|
function isEquippedFooterYellow(bitmap: Buffer, index: number) {
|
|
const blue = bitmap[index];
|
|
const green = bitmap[index + 1];
|
|
const red = bitmap[index + 2];
|
|
|
|
return red >= 220 && green >= 185 && blue >= 125 && red > blue + 35 && green > blue + 20;
|
|
}
|
|
|
|
function isSetTitleGreen(bitmap: Buffer, index: number) {
|
|
const blue = bitmap[index];
|
|
const green = bitmap[index + 1];
|
|
const red = bitmap[index + 2];
|
|
|
|
return green >= 110 && red <= 170 && blue <= 160 && green > red + 12 && green > blue + 10;
|
|
}
|
|
|
|
function isArtifactTextColor(bitmap: Buffer, index: number) {
|
|
const blue = bitmap[index];
|
|
const green = bitmap[index + 1];
|
|
const red = bitmap[index + 2];
|
|
|
|
return green >= 180 && red >= 140 && blue <= 95 && green > blue + 35 && red > blue + 10;
|
|
}
|
|
|
|
function waitDelay(ms: number) {
|
|
return new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, Math.floor(ms))));
|
|
}
|
|
|
|
type CropTemplate = { id: string; label: string; rect: Electron.Rectangle };
|
|
|
|
function getCaptureSourceListOptions() {
|
|
const displays = screen.getAllDisplays();
|
|
const maxSize = displays.reduce(
|
|
(size, display) => ({
|
|
width: Math.max(size.width, display.size.width),
|
|
height: Math.max(size.height, display.size.height),
|
|
}),
|
|
{ width: 1920, height: 1080 },
|
|
);
|
|
|
|
return { maxSize, fetchWindowIcons: true };
|
|
}
|
|
|
|
async function getAllSources() {
|
|
const { maxSize, fetchWindowIcons } = getCaptureSourceListOptions();
|
|
return desktopCapturer.getSources({ types: ["window", "screen"], thumbnailSize: maxSize, fetchWindowIcons });
|
|
}
|
|
|
|
async function findCaptureSourceById(sourceId: string) {
|
|
const sources = await getAllSources();
|
|
return sources.find((source) => source.id === sourceId) ?? null;
|
|
}
|
|
|
|
function clampCaptureRect(rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
|
|
const clamped = {
|
|
x: Math.max(0, Math.min(imageSize.width - 1, rect.x)),
|
|
y: Math.max(0, Math.min(imageSize.height - 1, rect.y)),
|
|
};
|
|
const maxWidth = Math.max(1, imageSize.width - clamped.x);
|
|
const maxHeight = Math.max(1, imageSize.height - clamped.y);
|
|
return {
|
|
...clamped,
|
|
width: Math.max(1, Math.min(maxWidth, rect.width)),
|
|
height: Math.max(1, Math.min(maxHeight, rect.height)),
|
|
};
|
|
}
|
|
|
|
function imageCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
|
|
const safeRect = clampCaptureRect(rect, imageSize);
|
|
return sourceImage.crop(safeRect).toDataURL();
|
|
}
|
|
|
|
function createCrops(
|
|
sourceImage: NativeImage,
|
|
imageSize: { width: number; height: number },
|
|
detailRect: Electron.Rectangle,
|
|
inventoryRect: Electron.Rectangle,
|
|
) {
|
|
const templates: CropTemplate[] = [
|
|
{
|
|
id: "artifact-title",
|
|
label: "Artifact title",
|
|
rect: {
|
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
y: Math.round(detailRect.y + detailRect.height * 0.05),
|
|
width: Math.round(detailRect.width * 0.82),
|
|
height: Math.round(detailRect.height * 0.16),
|
|
},
|
|
},
|
|
{
|
|
id: "artifact-main-stat",
|
|
label: "Main stat",
|
|
rect: {
|
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
y: Math.round(detailRect.y + detailRect.height * 0.20),
|
|
width: Math.round(detailRect.width * 0.82),
|
|
height: Math.round(detailRect.height * 0.18),
|
|
},
|
|
},
|
|
{
|
|
id: "artifact-substats",
|
|
label: "Substats",
|
|
rect: {
|
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
y: Math.round(detailRect.y + detailRect.height * 0.41),
|
|
width: Math.round(detailRect.width * 0.82),
|
|
height: Math.round(detailRect.height * 0.25),
|
|
},
|
|
},
|
|
{
|
|
id: "artifact-footer",
|
|
label: "Footer",
|
|
rect: {
|
|
x: Math.round(detailRect.x + detailRect.width * 0.055),
|
|
y: Math.round(detailRect.y + detailRect.height * 0.78),
|
|
width: Math.round(detailRect.width * 0.82),
|
|
height: Math.round(detailRect.height * 0.16),
|
|
},
|
|
},
|
|
];
|
|
|
|
if (inventoryRect.width > 120 && inventoryRect.height > 80) {
|
|
templates.push({
|
|
id: "inventory-count",
|
|
label: "Inventory count",
|
|
rect: {
|
|
x: Math.round(inventoryRect.x + inventoryRect.width * 0.62),
|
|
y: Math.round(inventoryRect.y + inventoryRect.height * 0.02),
|
|
width: Math.round(inventoryRect.width * 0.34),
|
|
height: Math.round(inventoryRect.height * 0.09),
|
|
},
|
|
});
|
|
}
|
|
|
|
return templates
|
|
.map((template) => ({
|
|
...template,
|
|
rect: clampCaptureRect(template.rect, imageSize),
|
|
dataUrl: imageCropDataUrl(sourceImage, template.rect, imageSize),
|
|
}))
|
|
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0)
|
|
.map((crop) => ({
|
|
...crop,
|
|
rect: {
|
|
x: crop.rect.x,
|
|
y: crop.rect.y,
|
|
width: crop.rect.width,
|
|
height: crop.rect.height,
|
|
},
|
|
}));
|
|
}
|
|
|
|
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
|
const { width, height } = imageSize;
|
|
const sampleStrideX = width > 2200 ? 4 : 3;
|
|
const sampleStrideY = height > 1400 ? 4 : 3;
|
|
const candidates: Array<{ x: number; y: number }> = [];
|
|
|
|
const search = {
|
|
x0: Math.floor(width * 0.40),
|
|
x1: Math.floor(width * 0.98),
|
|
y0: Math.floor(height * 0.04),
|
|
y1: Math.floor(height * 0.83),
|
|
};
|
|
|
|
for (let y = search.y0; y < search.y1; y += sampleStrideY) {
|
|
const rowOffset = y * width * 4;
|
|
for (let x = search.x0; x < search.x1; x += sampleStrideX) {
|
|
const index = rowOffset + x * 4;
|
|
if (isArtifactTitleOrange(bitmap, index) || isSetTitleGreen(bitmap, index) || isArtifactTextColor(bitmap, index)) {
|
|
candidates.push({ x, y });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (candidates.length >= 180) {
|
|
const xValues = candidates.map((item) => item.x);
|
|
const yValues = candidates.map((item) => item.y);
|
|
const xMin = Math.min(...xValues);
|
|
const xMax = Math.max(...xValues);
|
|
const yMin = Math.min(...yValues);
|
|
const yMax = Math.max(...yValues);
|
|
const spanX = Math.max(1, xMax - xMin);
|
|
const spanY = Math.max(1, yMax - yMin);
|
|
const widthGuess = Math.max(Math.round(width * 0.30), Math.min(Math.round(width * 0.52), Math.round(spanX * 3.6)));
|
|
const left = Math.max(Math.round(width * 0.42), Math.round((xMin + xMax) / 2 - widthGuess * 0.52));
|
|
const top = Math.max(0, Math.min(height - 1, Math.round(yMin - spanY * 0.4)));
|
|
const heightGuess = Math.max(Math.round(height * 0.58), Math.min(Math.round(height * 0.74), Math.round(spanY * 4.1)));
|
|
return clampCaptureRect({ x: left, y: top, width: widthGuess, height: heightGuess }, imageSize);
|
|
}
|
|
|
|
if (width > 0 && height > 0) {
|
|
return clampCaptureRect(
|
|
{
|
|
x: Math.round(width * 0.50),
|
|
y: Math.round(height * 0.08),
|
|
width: Math.round(width * 0.46),
|
|
height: Math.round(height * 0.74),
|
|
},
|
|
imageSize,
|
|
);
|
|
}
|
|
|
|
return { x: 0, y: 0, width, height };
|
|
}
|
|
|
|
function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
|
|
const { width, height } = imageSize;
|
|
const preferredWidth = Math.max(140, Math.round(width * 0.48));
|
|
const x = Math.round(width * 0.03);
|
|
const y = Math.round(detailRect.y + detailRect.height * 0.09);
|
|
const availableWidth = Math.max(100, detailRect.x - Math.round(width * 0.04));
|
|
const panelWidth = Math.max(100, Math.min(preferredWidth, availableWidth));
|
|
const safeWidth = panelWidth > width * 0.85 ? Math.round(width * 0.55) : panelWidth;
|
|
return clampCaptureRect(
|
|
{
|
|
x,
|
|
y,
|
|
width: Math.min(safeWidth, Math.max(width - x - Math.round(width * 0.02), 100)),
|
|
height: Math.max(140, Math.round(height * 0.70)),
|
|
},
|
|
imageSize,
|
|
);
|
|
}
|
|
|
|
function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
|
|
const inventoryRect = inferInventoryRect(imageSize, detailRect);
|
|
const cols = 5;
|
|
if (inventoryRect.width < 160 || inventoryRect.height < 140) {
|
|
return {
|
|
centers: [],
|
|
rows: 0,
|
|
cols: 0,
|
|
confidence: 0,
|
|
source: "missing" as const,
|
|
};
|
|
}
|
|
|
|
const cellWidth = Math.max(56, Math.round(inventoryRect.width / cols));
|
|
const stepX = Math.round(cellWidth * 0.96);
|
|
const stepY = Math.round(cellWidth * 1.03);
|
|
const visibleRows = Math.max(2, Math.min(6, Math.round(inventoryRect.height / Math.max(stepY, 1))));
|
|
|
|
const startX = inventoryRect.x + Math.max(6, Math.round(stepX * 0.45));
|
|
const startY = inventoryRect.y + Math.max(6, Math.round(stepY * 0.45));
|
|
const centers = [];
|
|
for (let row = 0; row < visibleRows; row++) {
|
|
for (let col = 0; col < cols; col++) {
|
|
const x = startX + col * stepX;
|
|
const y = startY + row * stepY;
|
|
if (x < imageSize.width && y < imageSize.height) {
|
|
centers.push({ x, y, row, col });
|
|
}
|
|
}
|
|
}
|
|
|
|
const trimmed = centers.filter((center) => center.x > 0 && center.y > 0);
|
|
return {
|
|
centers: trimmed,
|
|
rows: visibleRows,
|
|
cols,
|
|
confidence: trimmed.length >= cols * 2 ? 76 : trimmed.length >= cols ? 58 : 36,
|
|
source: "detected" as const,
|
|
};
|
|
}
|
|
|
|
async function buildCaptureResult(
|
|
sourceImage: NativeImage,
|
|
sourceId: string,
|
|
sourceName: string,
|
|
captureTarget: CaptureResult["captureTarget"],
|
|
options: CaptureOptions = {},
|
|
) {
|
|
const size = sourceImage.getSize();
|
|
if (!size.width || !size.height) {
|
|
throw new Error("Capture produced an empty image.");
|
|
}
|
|
|
|
const bitmap = sourceImage.getBitmap();
|
|
const detailRect = inferDetailRect(bitmap, size);
|
|
const inventoryRect = inferInventoryRect(size, detailRect);
|
|
const crops = createCrops(sourceImage, size, detailRect, inventoryRect);
|
|
const croppedPayload = crops.map((crop) => ({
|
|
id: crop.id,
|
|
label: crop.label,
|
|
dataUrl: crop.dataUrl,
|
|
}));
|
|
const recognized = options.skipOcr ? { ocr: [], timedOut: false } : await runOcrOnCropsWithTimeout(croppedPayload);
|
|
|
|
const count = parseInventoryCount(recognized.ocr);
|
|
return {
|
|
id: sourceId,
|
|
name: sourceName,
|
|
width: size.width,
|
|
height: size.height,
|
|
dataUrl: sourceImage.toDataURL(),
|
|
capturedAt: new Date().toISOString(),
|
|
captureTarget,
|
|
detailDataUrl: imageCropDataUrl(sourceImage, detailRect, size),
|
|
inventoryDataUrl: imageCropDataUrl(sourceImage, inventoryRect, size),
|
|
ocr: recognized.ocr,
|
|
ocrTimedOut: recognized.timedOut,
|
|
ocrSkipped: Boolean(options.skipOcr),
|
|
crops: crops.map((crop) => ({
|
|
id: crop.id,
|
|
label: crop.label,
|
|
rect: {
|
|
x: crop.rect.x,
|
|
y: crop.rect.y,
|
|
width: crop.rect.width,
|
|
height: crop.rect.height,
|
|
},
|
|
dataUrl: crop.dataUrl,
|
|
})),
|
|
inventoryGrid: inferInventoryGrid(size, detailRect),
|
|
inventoryCount: count,
|
|
};
|
|
}
|
|
|
|
async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions): Promise<CaptureResult> {
|
|
if (!Number.isFinite(delayMs) || delayMs < 0) {
|
|
delayMs = 0;
|
|
}
|
|
|
|
await waitDelay(Math.floor(delayMs));
|
|
|
|
if (focusGenshin) {
|
|
await focusGenshinForScanStart();
|
|
}
|
|
|
|
const source = await findCaptureSourceById(sourceId);
|
|
if (!source) {
|
|
throw new Error("Capture source not found.");
|
|
}
|
|
|
|
const isGenshinCandidate = isLikelyGenshinSourceName(source.name);
|
|
if (isGenshinCandidate) {
|
|
try {
|
|
return await captureSourceFromGdi(sourceId, source.name, options ?? {});
|
|
} catch {
|
|
// Fall back to desktop thumbnail capture for robustness in low-permission
|
|
// or transient capture failures. OCR will still produce a best-effort result.
|
|
}
|
|
}
|
|
|
|
const sourceImage = source.thumbnail;
|
|
if (sourceImage.isEmpty()) {
|
|
return await captureSourceFromGdi(sourceId, source.name, options ?? {});
|
|
}
|
|
|
|
return await buildCaptureResult(
|
|
sourceImage,
|
|
sourceId,
|
|
source.name,
|
|
sourceId.startsWith("screen:") ? "desktop-source" : "genshin-client",
|
|
options ?? {},
|
|
);
|
|
}
|
|
|
|
async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
|
|
const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`;
|
|
const filePath = exportPath(fileNameSafe);
|
|
try {
|
|
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
return { ok: true, path: filePath };
|
|
} catch {
|
|
return { ok: false, path: filePath };
|
|
}
|
|
}
|
|
|
|
function initializeAppLifecycle() {
|
|
app.whenReady().then(() => {
|
|
const userDataPath = app.getPath("userData");
|
|
repositoryContext = createRepositoryContext(userDataPath);
|
|
artifactStoreRepository = repositoryContext.artifactStoreRepository;
|
|
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
|
|
scannerLearningRepository = repositoryContext.scannerLearningRepository;
|
|
inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
|
|
|
|
registerIpcHandlers({
|
|
focusMainWindow: () => focusMainWindow(),
|
|
moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(),
|
|
focusGenshinForScanStart: () => focusGenshinForScanStart(),
|
|
publishScannerStatus: (status: ScannerStatusPayload) => publishScannerStatus(status),
|
|
readRuntimeInfo: () => readRuntimeInfo(),
|
|
loadSnapshotFromDisk: () => loadSnapshotFromDisk(),
|
|
saveSnapshotToDisk: (snapshot: AppSnapshot) => saveSnapshotToDisk(snapshot),
|
|
runMockScan: () => runMockScan(),
|
|
showOverlayWindow: () => showOverlayWindow(),
|
|
hideOverlayWindow: () => hideOverlayWindow(),
|
|
getArtifactStoreRepository: () => getArtifactStoreRepository(),
|
|
getReviewSamplesRepository: () => getReviewSamplesRepository(),
|
|
artifactStorePath: () => artifactStorePath(),
|
|
reviewSamplesPath: () => reviewSamplesPath(),
|
|
loadReviewSamples: (limit?: number) => loadReviewSamples(limit),
|
|
loadScannerLearningRules: () => loadScannerLearningRules(),
|
|
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules),
|
|
exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload),
|
|
listSources: () => listCaptureSources(),
|
|
captureSource: (
|
|
id: string,
|
|
delayMs?: number,
|
|
focus?: boolean,
|
|
captureOptions?: CaptureOptions,
|
|
) => captureSource(id, delayMs, focus, captureOptions),
|
|
clickScreen: (x: number, y: number) => clickScreenCommand(x, y),
|
|
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => scrollScreenCommand(notches, anchorX, anchorY),
|
|
getAutomationGuard: () => getAutomationGuardCommand(),
|
|
});
|
|
|
|
createMainWindow();
|
|
registerScannerHotkeys();
|
|
startDevControlServer();
|
|
});
|
|
|
|
app.on("activate", () => {
|
|
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
createMainWindow();
|
|
}
|
|
});
|
|
|
|
app.on("window-all-closed", () => {
|
|
if (process.platform !== "darwin") {
|
|
app.quit();
|
|
}
|
|
});
|
|
|
|
app.on("will-quit", async () => {
|
|
globalShortcut.unregisterAll();
|
|
if (devControlServer) {
|
|
devControlServer.close();
|
|
devControlServer = null;
|
|
}
|
|
await resetOcrWorker();
|
|
inputHelperService?.dispose();
|
|
});
|
|
}
|
|
|
|
initializeAppLifecycle();
|
|
|