import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron"; import fs from "node:fs/promises"; 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 = {}; 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; } 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: "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 | 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(); } 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 { 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 }; } } 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 }); 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();