Files
genshin-assistant/electron/main.ts

1029 lines
33 KiB
TypeScript

import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
import fs from "node:fs/promises";
import { existsSync } from "node:fs";
import 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 { createDevControlServer } from "./devControlServer.js";
import type { AppSnapshot } from "../src/types/domain.js";
import type {
CaptureOptions,
CaptureResult,
GoodDatabase,
GoodImportFileResult,
SaveResultWithPath,
ScannerCommand,
ScannerLearningRulePayload,
ScannerStatusPayload,
} from "../src/types/global.js";
import type {
ArtifactStoreRepositoryPort,
ReviewSamplesRepositoryPort,
ScannerLearningRepositoryPort,
} from "./repositories/index.js";
import {
aspectRatioLabel,
detailCropRects,
inventoryCountCropRect,
inventoryGrid as layoutInventoryGrid,
inventoryRect as layoutInventoryRect,
isSixteenNine,
layoutSupportWarning,
profileDetailRect,
} from "../src/lib/layoutProfile.js";
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
import { detectLockState, lockIconCropRect } from "../src/lib/lockDetection.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: ScannerCommand | "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 startDevControlServer() {
if (!isDev || devControlServer) return;
devControlServer = createDevControlServer({
registeredHotkeys,
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
sendScannerCommand,
clickScreen: clickScreenCommand,
scannerStatus: () => scannerDevStatus,
loadReviewSamples,
listCaptureSources,
captureSource,
});
}
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();
}
// Preprocessed copy of a crop for OCR (ADR-009): upscale for more pixels, then
// grayscale + Otsu-binarize with inversion (artifact text is the bright
// foreground). The original crop is kept separately for the diagnostics UI.
function preprocessedCropDataUrl(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
const safeRect = clampCaptureRect(rect, imageSize);
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, safeRect.width * 2), quality: "best" });
const size = upscaled.getSize();
if (!size.width || !size.height) return upscaled.toDataURL();
const binarized = binarizeForOcr({ data: upscaled.getBitmap(), width: size.width, height: size.height });
return nativeImage
.createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height })
.toDataURL();
}
function createCrops(
sourceImage: NativeImage,
imageSize: { width: number; height: number },
detailRect: Electron.Rectangle,
inventoryRect: Electron.Rectangle,
) {
const templates: CropTemplate[] = detailCropRects(detailRect, imageSize);
if (inventoryRect.width > 120 && inventoryRect.height > 80) {
templates.push({
id: "inventory-count",
label: "Inventory count",
rect: inventoryCountCropRect(inventoryRect, imageSize),
});
}
return templates
.map((template) => {
const rect = clampCaptureRect(template.rect, imageSize);
return {
id: template.id,
label: template.label,
rect,
dataUrl: imageCropDataUrl(sourceImage, rect, imageSize),
ocrDataUrl: preprocessedCropDataUrl(sourceImage, rect, imageSize),
};
})
.filter((crop) => crop.rect.width > 0 && crop.rect.height > 0);
}
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
if (isSixteenNine(imageSize)) {
return profileDetailRect(imageSize);
}
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);
}
// Colour detection found nothing usable; fall back to the resolution-anchored
// profile rect (single source of truth in layoutProfile).
return profileDetailRect(imageSize);
}
function inferInventoryRect(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
return layoutInventoryRect(imageSize, detailRect);
}
function inferInventoryGrid(imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
return layoutInventoryGrid(imageSize, detailRect);
}
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 lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
const lockImage = sourceImage.crop(lockRect);
const lockSize = lockImage.getSize();
const locked = lockSize.width > 0 && lockSize.height > 0
? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height })
: undefined;
const croppedPayload = crops.map((crop) => ({
id: crop.id,
label: crop.label,
// OCR reads the preprocessed (upscaled + binarized) crop; the original is
// kept below for the diagnostics UI.
dataUrl: crop.ocrDataUrl ?? 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,
locked,
layout: {
aspect: aspectRatioLabel(size),
isSixteenNine: isSixteenNine(size),
warning: layoutSupportWarning(size),
},
};
}
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 };
}
}
async function importGoodFile(): Promise<GoodImportFileResult> {
const dialogOptions = {
title: "GOOD-Datei importieren",
properties: ["openFile"],
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
} satisfies Electron.OpenDialogOptions;
const dialogResult = mainWindow && !mainWindow.isDestroyed()
? await dialog.showOpenDialog(mainWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
return { ok: false, canceled: true, path: "" };
}
const filePath = dialogResult.filePaths[0];
try {
const text = await fs.readFile(filePath, "utf8");
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
} catch (error) {
return {
ok: false,
canceled: false,
path: filePath,
error: error instanceof Error ? error.message : String(error),
};
}
}
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),
importGoodFile: () => importGoodFile(),
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();