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 = {}; 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 }); } 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 | 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>; 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((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 { 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 { 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 { 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();