Files
genshin-assistant/electron/main.ts
T

1930 lines
67 KiB
TypeScript

import { app, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
import { existsSync } from "node:fs";
import type { Server } from "node:http";
import { cpus } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { createWorker, PSM } from "tesseract.js";
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
import {
createNativeScannerProcessingService,
nativeScannerProcessStats,
type NativeCaptureJobPayload,
} from "./services/nativeScannerProcessingService.js";
import { pngBufferToBitmap } from "./services/pngBitmap.js";
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
import { createDevControlServer } from "./devControlServer.js";
import { createAppWindowManager, type AppWindowManager } from "./appWindowManager.js";
import { createGoodFileService, type GoodFileService } from "./services/goodFileService.js";
import { resolveNativeScannerRunDirectory } from "./services/nativeScannerRunDirectory.js";
import { readNativeScannerRunTiming, recordNativeScannerRunTiming } from "./services/nativeScannerRunTiming.js";
import { isLikelyGenshinSourceName } from "./services/captureSourceClassifier.js";
import { ikInventoryListsPathCandidates, inputHelperPathCandidates } from "./runtimePaths.js";
import type { IkArtifactCatalog } from "../src/lib/ikArtifactMatcher.js";
import type { AppSnapshot } from "../src/types/domain.js";
import type {
CaptureOptions,
CaptureResult,
GoodDatabase,
NativeScannerCatalogStatus,
NativeScannerDataStatus,
NativeScannerDeleteResultStatus,
NativeScannerImageLoadStatus,
NativeScannerPreflightStatus,
NativeScannerProcessOptions,
NativeScannerProcessStatus,
NativeScannerProcessingProgressStatus,
NativeScannerPromotionStatus,
NativeScannerReviewArtifactInput,
NativeScannerReviewStatus,
NativeScannerResultsLoadOptions,
NativeScannerResultsLoadStatus,
NativeScannerRunStatus,
OcrResult,
AppRuntimeInfo,
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 { DEFAULT_LOCK_THRESHOLD, isLocked, lockIconCropRect, lockSignalRatio } 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);
const APP_RUNTIME_STARTED_AT = new Date().toISOString();
const APP_RUNTIME_SIGNATURE = "2026-07-10-packaged-runtime-acceptance";
let registeredHotkeys: Record<string, boolean> = {};
let devControlServer: Server | null = null;
const captureSourceNameCache = new Map<string, string>();
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 nativeProcessingProgress: NativeScannerProcessingProgressStatus = {
running: false,
runDir: "",
total: 0,
processed: 0,
parsed: 0,
review: 0,
stored: 0,
errors: 0,
elapsedMs: 0,
};
let repositoryContext: RepositoryContext | null = null;
let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null;
let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null;
let scannerLearningRepository: ScannerLearningRepositoryPort | null = null;
let nativeScannerProcessingService: ReturnType<typeof createNativeScannerProcessingService> | null = null;
let inputHelperService: InputHelperService | null = null;
let appWindowManager: AppWindowManager | null = null;
let goodFileService: GoodFileService | null = null;
const nativeScannerRequestStartedAtByRunId = new Map<string, string>();
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 = inputHelperPathCandidates({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
currentWorkingDirectory: process.cwd(),
appPath: app.getAppPath(),
overridePath: process.env.INPUT_HELPER_EXE,
});
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);
} 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 getGoodFileService() {
if (!goodFileService) {
throw new Error("GOOD file service is not initialized.");
}
return goodFileService;
}
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 };
}
function nativeScannerDataDir() {
const candidates = ikInventoryListsPathCandidates({
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
currentWorkingDirectory: process.cwd(),
appPath: app.getAppPath(),
overridePath: process.env.IK_INVENTORYLISTS_DIR,
});
return candidates.find((candidate) => existsSync(path.join(candidate, "version.txt"))) ?? candidates[0];
}
function nativeScannerOutputRoot() {
return path.join(app.getPath("userData"), "native-scans");
}
function nativeScannerStats(status: NativeScannerRunStatus): Record<string, number> {
return {
clicked: Number(status.clicked ?? 0),
attempted: Number(status.captured ?? 0),
verified: Number(status.captured ?? 0),
parsed: 0,
stored: 0,
review: 0,
duplicates: 0,
misses: 0,
pages: Number(status.pages ?? 0),
elapsedMs: Number(status.activeMs ?? 0),
activeScanMs: Number(status.activeMs ?? 0),
queued: Number(status.queued ?? 0),
captured: Number(status.captured ?? 0),
};
}
function publishNativeScannerStatus(status: NativeScannerRunStatus, extra: Partial<ScannerStatusPayload> = {}) {
const summary = {
mode: "Native IK Scanner",
status: status.status,
...nativeScannerStats(status),
targetCount: status.target,
gridLabel: status.message,
runId: status.runId,
outputRoot: status.outputRoot,
runDir: status.runDir,
manifestPath: status.manifestPath,
jobsPath: status.jobsPath,
lastArtifactPath: status.lastArtifactPath,
};
scannerDevStatus = {
...scannerDevStatus,
running: Boolean(status.running),
reviewStatus: status.message || "Native scanner ready.",
captureStatus: status.status,
stats: nativeScannerStats(status),
summary,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
status.message || `native scanner ${status.status}`,
],
nativeScanner: status,
...extra,
updatedAt: new Date().toISOString(),
};
return scannerDevStatus;
}
async function nativeScannerDataStatus(): Promise<NativeScannerDataStatus> {
return getInputHelperService().nativeScannerDataStatus(nativeScannerDataDir());
}
async function nativeScannerCatalog(): Promise<NativeScannerCatalogStatus> {
return getInputHelperService().nativeScannerCatalog(nativeScannerDataDir());
}
async function loadNativeIkArtifactCatalog(): Promise<IkArtifactCatalog | null> {
const catalog = await nativeScannerCatalog();
return catalog.data.valid ? { artifacts: catalog.artifacts } : null;
}
async function nativeScannerPreflight(options: { category?: string } = {}): Promise<NativeScannerPreflightStatus> {
const preflight = await getInputHelperService().nativeScannerPreflight(nativeScannerDataDir(), options.category ?? "artifacts");
scannerDevStatus = {
...scannerDevStatus,
nativeScannerPreflight: preflight,
lookupStatus: preflight.data,
updatedAt: new Date().toISOString(),
};
return preflight;
}
async function nativeScannerStart(options: { limit?: number; category?: string } = {}): Promise<NativeScannerRunStatus> {
const requestStartedAt = new Date().toISOString();
const rawStatus = await getInputHelperService().nativeScannerStart({
dataDir: nativeScannerDataDir(),
outputRoot: nativeScannerOutputRoot(),
limit: options.limit ?? 100,
category: options.category ?? "artifacts",
});
const runId = rawStatus.runId?.trim();
if (runId && !nativeScannerRequestStartedAtByRunId.has(runId)) {
nativeScannerRequestStartedAtByRunId.set(runId, requestStartedAt);
}
const status = await attachNativeScannerTiming(rawStatus);
publishNativeScannerStatus(status, { lookupStatus: await nativeScannerDataStatus().catch(() => undefined) });
return status;
}
async function nativeScannerStop(): Promise<NativeScannerRunStatus> {
const status = await attachNativeScannerTiming(await getInputHelperService().nativeScannerStop());
publishNativeScannerStatus(status);
return status;
}
async function nativeScannerStatus(): Promise<NativeScannerRunStatus> {
const status = await attachNativeScannerTiming(await getInputHelperService().nativeScannerStatus());
publishNativeScannerStatus(status);
return status;
}
async function attachNativeScannerTiming(status: NativeScannerRunStatus): Promise<NativeScannerRunStatus> {
const runDir = status.runDir?.trim();
const runId = status.runId?.trim();
if (!runDir) return status;
const timing = await recordNativeScannerRunTiming(runDir, {
...(runId && nativeScannerRequestStartedAtByRunId.has(runId)
? { requestStartedAt: nativeScannerRequestStartedAtByRunId.get(runId) }
: {}),
...(usableRunTimingTimestamp(status.startedAt) ? { scannerStartedAt: status.startedAt } : {}),
...(usableRunTimingTimestamp(status.captureStartedAt) ? { captureStartedAt: status.captureStartedAt } : {}),
...(usableRunTimingTimestamp(status.captureCompletedAt) ? { captureCompletedAt: status.captureCompletedAt } : {}),
}).catch(() => null);
if (!status.running && runId) nativeScannerRequestStartedAtByRunId.delete(runId);
return timing ? { ...status, timing } : status;
}
function usableRunTimingTimestamp(value: unknown): value is string {
return typeof value === "string"
&& value.length > 0
&& !value.startsWith("0001-")
&& Number.isFinite(Date.parse(value));
}
function resolveNativeProcessRunDir(runDir?: string) {
const native = scannerDevStatus.nativeScanner as NativeScannerRunStatus | undefined;
return resolveNativeScannerRunDirectory({
outputRoot: nativeScannerOutputRoot(),
requestedRunDir: runDir,
activeRunDir: native?.runDir,
});
}
async function buildNativeCardCaptureResult(
imagePath: string,
job: NativeCaptureJobPayload,
): Promise<CaptureResult> {
const sourceImage = nativeImage.createFromPath(imagePath);
const size = sourceImage.getSize();
if (!size.width || !size.height) throw new Error(`Native card crop is empty: ${imagePath}`);
const detailRect = { x: 0, y: 0, width: size.width, height: size.height };
const inventoryRect = { x: 0, y: 0, width: 1, height: 1 };
const crops = createCrops(
sourceImage,
size,
detailRect,
inventoryRect,
{
ocrMode: "artifact",
ocrProfile: "full",
omitFullFrame: true,
omitInventoryPreview: true,
omitCropImages: true,
omitLockState: true,
},
undefined,
{ skipOcr: false },
);
const croppedPayload = crops
.filter((crop) => crop.ocrEnabled !== false)
.map((crop) => crop.ocrImage
? {
id: crop.id,
label: crop.label,
image: crop.ocrImage,
}
: null)
.filter((crop): crop is OcrCropPayload => Boolean(crop));
const recognized = await runOcrOnCropsWithTimeout(croppedPayload, "current", 6500);
return {
id: `native-card-${job.sequence}`,
name: path.basename(imagePath),
width: size.width,
height: size.height,
dataUrl: "",
capturedAt: new Date().toISOString(),
captureTarget: "genshin-client",
detailFingerprint: `${path.basename(imagePath)}:${size.width}x${size.height}`,
ocr: recognized.ocr,
ocrTimedOut: recognized.timedOut,
ocrSkipped: false,
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,
})),
artifactDetail: {
present: true,
confidence: 70,
orangeHits: 0,
greenHits: 0,
textHits: 0,
},
layout: {
aspect: aspectRatioLabel(size),
isSixteenNine: false,
warning: "Native card-crop processing uses the detail card as its own coordinate space.",
},
timings: {
prepareMs: 0,
ocrMs: 0,
totalMs: 0,
ocrProfile: "full",
ocrEngine: "current",
ocrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
cropCount: croppedPayload.length,
ocrSkipped: false,
},
};
}
function getNativeScannerService() {
if (nativeScannerProcessingService) return nativeScannerProcessingService;
nativeScannerProcessingService = createNativeScannerProcessingService({
resolveRunDir: resolveNativeProcessRunDir,
buildCaptureResult: buildNativeCardCaptureResult,
loadArtifacts: () => getArtifactStoreRepository().loadAll(),
saveArtifacts: (records) => getArtifactStoreRepository().saveMany(records),
removeArtifacts: (ids) => getArtifactStoreRepository().removeByIds(ids),
loadIkArtifactCatalog: loadNativeIkArtifactCatalog,
saveReviewSample: (sample) => getReviewSamplesRepository().append(sample),
recordRunTiming: recordNativeScannerRunTiming,
loadRunTiming: readNativeScannerRunTiming,
onProgress: (progress) => {
nativeProcessingProgress = { ...progress };
},
});
return nativeScannerProcessingService;
}
async function nativeScannerProcessingStatus() {
return { ...nativeProcessingProgress };
}
async function nativeScannerProcessRun(options: NativeScannerProcessOptions = {}): Promise<NativeScannerProcessStatus> {
const service = getNativeScannerService();
nativeProcessingProgress = {
running: true,
runDir: options.runDir ?? "",
total: Math.max(0, Math.round(options.expectedTotal ?? 0)),
processed: 0,
parsed: 0,
review: 0,
stored: 0,
errors: 0,
elapsedMs: 0,
};
try {
const status = await service.processRun(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: `Native post-processing: ${status.parsed}/${status.processed} parsed, ${status.review} review, ${status.stored} stored.`,
stats: {
...(scannerDevStatus.stats ?? {}),
...nativeScannerProcessStats(status),
},
nativeScannerProcessing: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native process: ${status.parsed}/${status.processed} parsed, report ${status.reportPath}`,
],
updatedAt: new Date().toISOString(),
};
return status;
} catch (error) {
nativeProcessingProgress = {
...nativeProcessingProgress,
running: false,
error: error instanceof Error ? error.message : String(error),
};
throw error;
}
}
async function nativeScannerLoadResults(options: NativeScannerResultsLoadOptions = {}): Promise<NativeScannerResultsLoadStatus> {
const service = getNativeScannerService();
const loaded = await service.loadResults(options);
scannerDevStatus = {
...scannerDevStatus,
nativeScannerResults: loaded,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native results: ${loaded.results.length}/${loaded.total} loaded from ${loaded.path || "scan-results.json"}`,
],
updatedAt: new Date().toISOString(),
};
return loaded;
}
async function nativeScannerPromoteResults(options: { runDir?: string; resultIds: string[] }): Promise<NativeScannerPromotionStatus> {
const service = getNativeScannerService();
const status = await service.promoteResults(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: status.ok
? `Native promotion: ${status.promoted} promoted, ${status.alreadyStored} already stored.`
: `Native promotion blocked: ${status.error ?? "unknown error"}`,
nativeScannerPromotion: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native promotion: ${status.promoted}/${status.requested}, log ${status.logPath || "unavailable"}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerReviewResult(options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}): Promise<NativeScannerReviewStatus> {
const service = getNativeScannerService();
const status = await service.reviewResult(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: status.ok
? `Native review ${status.action}: ${status.resultId}`
: `Native review blocked: ${status.error ?? "unknown error"}`,
nativeScannerReview: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native review ${status.action}: ${status.ok ? "ok" : status.error}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerDeleteResult(options: {
runDir?: string;
resultId: string;
removeLinkedStoreRecord?: boolean;
}): Promise<NativeScannerDeleteResultStatus> {
const service = getNativeScannerService();
const status = await service.deleteResult(options);
scannerDevStatus = {
...scannerDevStatus,
reviewStatus: status.ok
? `Local native result removed: ${status.resultId}${status.deletedStoreRecord ? " and linked store record" : ""}.`
: `Local native result removal blocked: ${status.error ?? "unknown error"}`,
nativeScannerDeletion: status,
automationLog: [
...(scannerDevStatus.automationLog ?? []).slice(-10),
`native local deletion: ${status.resultId || "unknown"} ${status.ok ? "ok" : status.error ?? "blocked"}`,
],
updatedAt: new Date().toISOString(),
};
return status;
}
async function nativeScannerLoadImage(options: { runDir?: string; imagePath: string }): Promise<NativeScannerImageLoadStatus> {
const service = getNativeScannerService();
return service.loadImage(options);
}
async function readRuntimeInfo() {
try {
const result = await getInputHelperService().getRuntimeInfo();
return {
ok: true,
isElevated: result.isElevated,
platform: result.platform,
appBuild: appRuntimeInfo(),
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, appBuild: appRuntimeInfo(), 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() {
const mainWindow = getAppWindowManager().getMainWindow();
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() {
return getAppWindowManager().hideOverlayWindow();
}
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) => {
captureSourceNameCache.set(source.id, source.name);
return {
id: source.id,
name: source.name,
isGenshinCandidate: isLikelyGenshinSourceName(source.name),
thumbnailDataUrl: source.thumbnail.resize({ width: 420 }).toDataURL(),
};
});
}
async function toScreenPoint(x: number, y: number) {
const point = { x: Math.round(x), y: Math.round(y) };
const bounds = await getGenshinWindowBounds();
if (!bounds) return point;
const looksClientRelative =
point.x >= 0 &&
point.y >= 0 &&
point.x <= bounds.width &&
point.y <= bounds.height &&
(bounds.x !== 0 || bounds.y !== 0);
if (!looksClientRelative) return point;
return {
x: bounds.x + point.x,
y: bounds.y + point.y,
};
}
async function clickScreenCommand(x: number, y: number) {
const point = await toScreenPoint(x, y);
return getInputHelperService().clickScreen(point.x, point.y);
}
async function scrollScreenCommand(notches: number, anchorX?: number, anchorY?: number) {
if (typeof anchorX === "number" && typeof anchorY === "number") {
const point = await toScreenPoint(anchorX, anchorY);
return getInputHelperService().scrollScreen(notches, point.x, point.y);
}
return getInputHelperService().scrollScreen(notches);
}
async function keyPressCommand(key: string) {
return getInputHelperService().keyPress(key);
}
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,
};
}
async function capturePrimaryScreenViaGdi() {
return getInputHelperService().capturePrimaryScreenViaGdi();
}
async function captureSourceFromGdi(sourceId: string, sourceName: string, options: CaptureOptions = {}) {
const gdi = await capturePrimaryScreenViaGdi();
const sourceImage = nativeImageFromGdiCapture(gdi);
return await buildCaptureResult(sourceImage, sourceId, sourceName, gdi.captureTarget, options);
}
function nativeImageFromGdiCapture(gdi: Awaited<ReturnType<InputHelperService["capturePrimaryScreenViaGdi"]>>) {
if (gdi.imageBase64) {
return nativeImage.createFromBuffer(Buffer.from(gdi.imageBase64, "base64"));
}
if (!gdi.dataUrl) {
throw new Error("GDI capture returned no image payload.");
}
return nativeImage.createFromDataURL(gdi.dataUrl);
}
function shouldUseDirectGdiHotPath(options: CaptureOptions = {}) {
return Boolean(
options.ocrMode === "artifact" ||
options.ocrMode === "inventory-count" ||
options.skipOcrUnlessArtifactDetail ||
options.skipOcr ||
options.omitCrops,
);
}
function getAppWindowManager() {
if (!appWindowManager) {
appWindowManager = createAppWindowManager({
preloadPath: path.join(__dirname, "preload.cjs"),
rendererUrl: process.env.VITE_DEV_SERVER_URL,
rendererFilePath: path.join(__dirname, "../../dist/index.html"),
onMainReadyToShow: moveMainWindowOffGenshin,
});
}
return appWindowManager;
}
function createMainWindow() {
getAppWindowManager().createMainWindow();
}
function focusMainWindow() {
return getAppWindowManager().focusMainWindow();
}
function sendScannerCommand(command: ScannerCommand | "probe-click") {
if (command === "stop") {
void nativeScannerStop().catch(() => undefined);
}
getAppWindowManager().sendScannerCommand(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,
appBuild: appRuntimeInfo(),
hasMainWindow: () => getAppWindowManager().hasMainWindow(),
sendScannerCommand,
clickScreen: clickScreenCommand,
scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }),
nativeScannerDataStatus,
nativeScannerCatalog,
nativeScannerPreflight,
nativeScannerStart,
nativeScannerStop,
nativeScannerStatus,
nativeScannerProcessRun,
nativeScannerLoadResults,
nativeScannerLoadImage,
warmOcr: (engine) => warmOcrWorkerPool(engine),
loadReviewSamples,
listCaptureSources,
captureSource,
requestShutdown: (reason) => {
console.log(`[dev-control] shutdown requested: ${reason}`);
app.quit();
},
});
}
function createOverlayWindow() {
getAppWindowManager().createOverlayWindow();
}
// The fast artifact profile has four useful OCR parameter groups, so the
// default pool is four workers unless the machine is smaller or GAA_OCR_WORKERS
// explicitly overrides it.
const OCR_WORKER_POOL_SIZE = resolveOcrWorkerPoolSize();
type OcrWorker = Awaited<ReturnType<typeof createWorker>>;
type OcrWorkerEngine = "current";
type OcrCropPayload = { id: string; label: string; image: Buffer };
type OcrWarmupStatus = {
engine: OcrWorkerEngine;
status: "cold" | "warming" | "ready" | "error";
workerPoolSize: number;
startedAt?: string;
readyAt?: string;
elapsedMs?: number;
error?: string;
};
type OcrWorkerSlot = {
worker: OcrWorker;
parametersKey: string;
};
type OcrWorkerPoolState = {
poolPromise: Promise<OcrWorkerSlot[]> | null;
runQueue: Promise<unknown>;
};
const ocrWorkerPools: Record<OcrWorkerEngine, OcrWorkerPoolState> = {
current: { poolPromise: null, runQueue: Promise.resolve() },
};
const ocrWarmupStatuses: Record<OcrWorkerEngine, OcrWarmupStatus> = {
current: { engine: "current", status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE },
};
function resolveOcrWorkerPoolSize() {
const requested = Number(process.env.GAA_OCR_WORKERS ?? Number.NaN);
if (Number.isFinite(requested) && requested > 0) {
return Math.max(1, Math.min(8, Math.floor(requested)));
}
const logicalCores = cpus().length || 4;
return Math.max(2, Math.min(4, logicalCores - 1));
}
function appRuntimeInfo(): AppRuntimeInfo {
return {
signature: APP_RUNTIME_SIGNATURE,
pid: process.pid,
startedAt: APP_RUNTIME_STARTED_AT,
cwd: process.cwd(),
isDev,
expectedOcrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
};
}
function ocrEngineFromOptions(options: CaptureOptions = {}): OcrWorkerEngine {
void options;
return "current";
}
function getOcrWorkerOptions(engine: OcrWorkerEngine) {
void engine;
return { lang: "eng", options: undefined };
}
function getOcrWorkerPool(engine: OcrWorkerEngine) {
const state = ocrWorkerPools[engine];
if (!state.poolPromise) {
const { lang, options } = getOcrWorkerOptions(engine);
state.poolPromise = Promise.all(
Array.from({ length: OCR_WORKER_POOL_SIZE }, async () => ({
worker: await createWorker(lang, 1, options),
parametersKey: "",
})),
);
}
return state.poolPromise;
}
async function resetOcrWorker(engine?: OcrWorkerEngine) {
const engines: OcrWorkerEngine[] = engine ? [engine] : ["current"];
await Promise.all(engines.map(async (engineId) => {
const state = ocrWorkerPools[engineId];
const broken = state.poolPromise;
state.poolPromise = null;
state.runQueue = Promise.resolve();
ocrWarmupStatuses[engineId] = { engine: engineId, status: "cold", workerPoolSize: OCR_WORKER_POOL_SIZE };
if (broken) {
try {
const pool = await broken;
await Promise.all(pool.map((slot) => slot.worker.terminate().catch(() => undefined)));
} catch {
// Workers never initialized; nothing to clean up.
}
}
}));
}
function ocrParametersForCrop(cropId: string) {
switch (cropId) {
case "artifact-level":
return {
tessedit_pageseg_mode: PSM.SINGLE_WORD,
tessedit_char_whitelist: "0123456789+",
};
case "artifact-main-stat-value":
return {
tessedit_pageseg_mode: PSM.SINGLE_LINE,
tessedit_char_whitelist: "0123456789.,%+",
};
case "inventory-count":
return {
tessedit_pageseg_mode: PSM.SINGLE_LINE,
tessedit_char_whitelist: "0123456789/",
};
case "artifact-name":
case "artifact-slot":
case "artifact-main-stat-label":
case "artifact-footer":
return {
tessedit_pageseg_mode: PSM.SINGLE_LINE,
tessedit_char_whitelist: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .:'%-",
};
case "artifact-main-stat":
return {
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
tessedit_char_whitelist: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,%+",
};
case "artifact-substats":
return {
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
tessedit_char_whitelist: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 .,%+-",
};
default:
return {
tessedit_pageseg_mode: PSM.SINGLE_BLOCK,
tessedit_char_whitelist: "",
};
}
}
function ocrParametersKey(cropId: string) {
const params = ocrParametersForCrop(cropId);
return `${params.tessedit_pageseg_mode}:${params.tessedit_char_whitelist}`;
}
async function applyOcrParameters(slot: OcrWorkerSlot, cropId: string) {
const params = ocrParametersForCrop(cropId);
const key = ocrParametersKey(cropId);
if (key === slot.parametersKey) return;
await slot.worker.setParameters(params);
slot.parametersKey = key;
}
function warmOcrWorkerPool(engine: OcrWorkerEngine = "current") {
const current = ocrWarmupStatuses[engine];
if (current.status === "warming" || current.status === "ready") return Promise.resolve(current);
const started = Date.now();
ocrWarmupStatuses[engine] = {
engine,
status: "warming",
workerPoolSize: OCR_WORKER_POOL_SIZE,
startedAt: new Date(started).toISOString(),
};
return getOcrWorkerPool(engine)
.then(async (pool) => {
await Promise.all(pool.map((slot) => applyOcrParameters(slot, "artifact-name")));
const readyAt = Date.now();
ocrWarmupStatuses[engine] = {
engine,
status: "ready",
workerPoolSize: OCR_WORKER_POOL_SIZE,
startedAt: ocrWarmupStatuses[engine].startedAt,
readyAt: new Date(readyAt).toISOString(),
elapsedMs: readyAt - started,
};
return ocrWarmupStatuses[engine];
})
.catch((error: unknown) => {
ocrWarmupStatuses[engine] = {
engine,
status: "error",
workerPoolSize: OCR_WORKER_POOL_SIZE,
startedAt: ocrWarmupStatuses[engine].startedAt,
elapsedMs: Date.now() - started,
error: error instanceof Error ? error.message : String(error),
};
return ocrWarmupStatuses[engine];
});
}
function getOcrWarmupStatus() {
return {
current: ocrWarmupStatuses.current,
};
}
async function runOcrOnCrops(crops: OcrCropPayload[], engine: OcrWorkerEngine) {
try {
const pool = await getOcrWorkerPool(engine);
const results: OcrResult[] = new Array(crops.length);
let nextCropIndex = 0;
const workers = pool.slice(0, Math.min(pool.length, crops.length || 1));
await Promise.all(workers.map(async (slot) => {
while (nextCropIndex < crops.length) {
const index = nextCropIndex++;
const crop = crops[index];
if (!crop) continue;
results[index] = await recognizeCropWithSlot(slot, crop);
}
}));
return results;
} catch (error) {
await resetOcrWorker(engine);
throw error;
}
}
async function recognizeCropWithSlot(slot: OcrWorkerSlot, crop: OcrCropPayload): Promise<OcrResult> {
const startedAt = Date.now();
await applyOcrParameters(slot, crop.id);
const recognized = await slot.worker.recognize(crop.image);
return {
id: crop.id,
label: crop.label,
text: cleanOcrText(crop.id, recognized.data.text),
confidence: Math.round(recognized.data.confidence),
elapsedMs: Date.now() - startedAt,
};
}
function runQueuedOcrOnCrops(crops: OcrCropPayload[], engine: OcrWorkerEngine) {
const state = ocrWorkerPools[engine];
const run = state.runQueue.catch(() => undefined).then(() => runOcrOnCrops(crops, engine));
state.runQueue = run.catch(() => undefined);
return run;
}
async function runOcrOnCropsWithTimeout(crops: OcrCropPayload[], engine: OcrWorkerEngine, timeoutMs = 6500) {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
runQueuedOcrOnCrops(crops, engine).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(/[\u201c\u201d]/g, '"')
.replace(/[\u2019]/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" || cropId === "artifact-name" || cropId === "artifact-slot" || cropId === "artifact-main-stat-label") {
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-main-stat-value") {
return normalized
.map((line) => line.replace(/[^0-9.,%]/g, ""))
.find((line) => /[0-9]/.test(line)) ?? "";
}
if (cropId === "artifact-level") {
const value = normalized
.map((line) => line.replace(/[^0-9+]/g, ""))
.find((line) => /[0-9]/.test(line)) ?? "";
return value.startsWith("+") || value === "" ? value : `+${value}`;
}
if (cropId === "artifact-substats") {
return normalized
.filter((line) => /(\+|CRIT|ATK|DEF|HP|Energy|Elemental)/i.test(line))
.slice(0, 6)
.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 hasEquippedFooterMarker(bitmap: Buffer, imageSize: { width: number; height: number }, rect: Electron.Rectangle) {
const safeRect = clampCaptureRect(rect, imageSize);
const strideX = Math.max(1, Math.floor(safeRect.width / 48));
const strideY = Math.max(1, Math.floor(safeRect.height / 18));
let hits = 0;
for (let y = safeRect.y; y < safeRect.y + safeRect.height; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = safeRect.x; x < safeRect.x + safeRect.width; x += strideX) {
if (isEquippedFooterYellow(bitmap, rowOffset + x * 4)) {
hits++;
if (hits >= 10) return true;
}
}
}
return false;
}
function isSanctifiedArtifactPurple(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return blue >= 220 && red >= 180 && red <= 245 && green >= 155 && green <= 220 && blue > red + 10;
}
function detectSanctifiedArtifactDetail(bitmap: Buffer, imageSize: { width: number; height: number }, detailRect: Electron.Rectangle) {
const safeRect = clampCaptureRect(detailRect, imageSize);
const x0 = Math.max(safeRect.x, Math.round(safeRect.x + safeRect.width * 0.0));
const x1 = Math.min(imageSize.width - 1, Math.round(safeRect.x + safeRect.width * 0.0606));
const y0 = Math.max(safeRect.y, Math.round(safeRect.y + safeRect.height * 0.3333));
const y1 = Math.min(imageSize.height - 1, Math.round(y0 + safeRect.height * 0.0526));
const strideX = Math.max(1, Math.floor(Math.max(1, x1 - x0) / 12));
const strideY = Math.max(1, Math.floor(Math.max(1, y1 - y0) / 10));
let hits = 0;
for (let y = y0; y <= y1; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = x0; x <= x1; x += strideX) {
if (isSanctifiedArtifactPurple(bitmap, rowOffset + x * 4)) {
hits++;
if (hits >= 3) return true;
}
}
}
return false;
}
function analyzeArtifactDetailPanel(bitmap: Buffer, imageSize: { width: number; height: number }, rect: Electron.Rectangle) {
const safeRect = clampCaptureRect(rect, imageSize);
const strideX = Math.max(1, Math.floor(safeRect.width / 96));
const strideY = Math.max(1, Math.floor(safeRect.height / 160));
let orangeHits = 0;
let greenHits = 0;
let textHits = 0;
let titleOrangeHits = 0;
let upperTextHits = 0;
let lowerGreenHits = 0;
for (let y = safeRect.y; y < safeRect.y + safeRect.height; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = safeRect.x; x < safeRect.x + safeRect.width; x += strideX) {
const index = rowOffset + x * 4;
const relativeY = (y - safeRect.y) / safeRect.height;
const isOrange = isArtifactTitleOrange(bitmap, index);
const isGreen = isSetTitleGreen(bitmap, index);
const isText = isArtifactTextColor(bitmap, index);
if (isOrange) {
orangeHits++;
if (relativeY <= 0.085) titleOrangeHits++;
}
if (isGreen) {
greenHits++;
if (relativeY >= 0.37 && relativeY <= 0.84) lowerGreenHits++;
}
if (isText) {
textHits++;
if (relativeY >= 0.08 && relativeY <= 0.37) upperTextHits++;
}
}
}
const confidence = Math.max(
0,
Math.min(100, Math.round(titleOrangeHits * 1.6 + upperTextHits * 1.2 + lowerGreenHits * 0.7)),
);
return {
present: titleOrangeHits >= 40 && (upperTextHits >= 20 || lowerGreenHits >= 40) && confidence >= 58,
confidence,
orangeHits,
greenHits,
textHits,
titleOrangeHits,
upperTextHits,
lowerGreenHits,
};
}
function isPaimonProfileLight(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return red > 150 && green > 150 && blue > 150;
}
function isPaimonProfileCream(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return red >= 195 && green >= 185 && blue >= 155;
}
function isPaimonMenuTileDark(bitmap: Buffer, index: number) {
const blue = bitmap[index];
const green = bitmap[index + 1];
const red = bitmap[index + 2];
return red >= 45 && red <= 115 && green >= 55 && green <= 125 && blue >= 70 && blue <= 150;
}
function sampleScreenZone(
bitmap: Buffer,
imageSize: { width: number; height: number },
zone: { x0: number; x1: number; y0: number; y1: number },
predicate: (bitmap: Buffer, index: number) => boolean,
) {
const x0 = Math.max(0, Math.floor(imageSize.width * zone.x0));
const x1 = Math.min(imageSize.width, Math.ceil(imageSize.width * zone.x1));
const y0 = Math.max(0, Math.floor(imageSize.height * zone.y0));
const y1 = Math.min(imageSize.height, Math.ceil(imageSize.height * zone.y1));
const strideX = Math.max(1, Math.floor((x1 - x0) / 135));
const strideY = Math.max(1, Math.floor((y1 - y0) / 90));
let hits = 0;
let samples = 0;
for (let y = y0; y < y1; y += strideY) {
const rowOffset = y * imageSize.width * 4;
for (let x = x0; x < x1; x += strideX) {
samples++;
if (predicate(bitmap, rowOffset + x * 4)) hits++;
}
}
return samples > 0 ? (hits / samples) * 100 : 0;
}
function analyzePaimonMenu(bitmap: Buffer, imageSize: { width: number; height: number }) {
const profileZone = { x0: 0.05, x1: 0.40, y0: 0, y1: 0.30 };
const menuTileZone = { x0: 0.06, x1: 0.39, y0: 0.32, y1: 0.98 };
const profileLightPct = sampleScreenZone(bitmap, imageSize, profileZone, isPaimonProfileLight);
const profileCreamPct = sampleScreenZone(bitmap, imageSize, profileZone, isPaimonProfileCream);
const menuTileDarkPct = sampleScreenZone(bitmap, imageSize, menuTileZone, isPaimonMenuTileDark);
const confidence = Math.max(0, Math.min(100, Math.round((profileLightPct - 20) * 1.2 + (profileCreamPct - 12) * 1.4 + (menuTileDarkPct - 25) * 1.1)));
return {
present: profileLightPct >= 35 && profileCreamPct >= 20 && menuTileDarkPct >= 40,
confidence,
profileLightPct: Math.round(profileLightPct * 10) / 10,
profileCreamPct: Math.round(profileCreamPct * 10) / 10,
menuTileDarkPct: Math.round(menuTileDarkPct * 10) / 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();
for (const source of sources) captureSourceNameCache.set(source.id, source.name);
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 imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }) {
const safeRect = clampCaptureRect(rect, imageSize);
const bitmap = sourceImage.crop(safeRect).toBitmap();
let hash = 2166136261;
const stride = Math.max(4, Math.floor(bitmap.length / 4096) * 4);
for (let index = 0; index < bitmap.length; index += stride) {
hash ^= bitmap[index] ?? 0;
hash = Math.imul(hash, 16777619);
hash ^= bitmap[index + 1] ?? 0;
hash = Math.imul(hash, 16777619);
hash ^= bitmap[index + 2] ?? 0;
hash = Math.imul(hash, 16777619);
}
return `${safeRect.width}x${safeRect.height}:${(hash >>> 0).toString(16)}`;
}
// 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.
// OCR receives a PNG buffer directly to avoid DataURL encode/decode churn in
// the auto-scan hot path.
function preprocessedCropPngBuffer(sourceImage: NativeImage, rect: Electron.Rectangle, imageSize: { width: number; height: number }, cropId = "") {
const safeRect = clampCaptureRect(rect, imageSize);
const scale = cropId === "artifact-level" ? 3 : 2;
const upscaled = sourceImage.crop(safeRect).resize({ width: Math.max(1, Math.round(safeRect.width * scale)), quality: "best" });
const size = upscaled.getSize();
if (!size.width || !size.height) return upscaled.toPNG();
const binarized = binarizeForOcr(
{ data: upscaled.toBitmap(), width: size.width, height: size.height },
cropId === "artifact-level" ? { contrast: 80 } : {},
);
return nativeImage
.createFromBitmap(Buffer.from(binarized.data), { width: binarized.width, height: binarized.height })
.toPNG();
}
function createCrops(
sourceImage: NativeImage,
imageSize: { width: number; height: number },
detailRect: Electron.Rectangle,
inventoryRect: Electron.Rectangle,
options: CaptureOptions = {},
bitmap?: Buffer,
cropOptions: { sanctified?: boolean; skipOcr?: boolean } = {},
) {
const isArtifactScanMode = options.ocrMode === "artifact";
const isInventoryCountMode = options.ocrMode === "inventory-count";
const fastArtifactProfile = isArtifactScanMode && options.ocrProfile === "fast";
const omitCropImages = Boolean(options.omitCropImages);
const skipCropOcr = Boolean(cropOptions.skipOcr);
const templates: CropTemplate[] = isInventoryCountMode
? []
: detailCropRects(detailRect, imageSize, { ...cropOptions, fastProfile: fastArtifactProfile })
.filter((template) => {
if (fastArtifactProfile && (
template.id === "artifact-set-effects" ||
template.id === "artifact-main-stat-value"
)) return false;
if (template.id === "artifact-footer" && options.omitEquippedOcr) return false;
if (!isArtifactScanMode || template.id !== "artifact-footer" || !bitmap) return true;
return hasEquippedFooterMarker(bitmap, imageSize, template.rect);
});
if (!isArtifactScanMode && 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);
const ocrEnabled = !skipCropOcr;
return {
id: template.id,
label: template.label,
rect,
dataUrl: omitCropImages ? undefined : imageCropDataUrl(sourceImage, rect, imageSize),
ocrImage: ocrEnabled ? preprocessedCropPngBuffer(sourceImage, rect, imageSize, template.id) : undefined,
ocrEnabled,
};
})
.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 buildStartedAt = Date.now();
const isInventoryCountMode = options.ocrMode === "inventory-count";
const size = sourceImage.getSize();
if (!size.width || !size.height) {
throw new Error("Capture produced an empty image.");
}
const bitmap = sourceImage.toBitmap();
const detailRect = inferDetailRect(bitmap, size);
const artifactDetail = analyzeArtifactDetailPanel(bitmap, size, detailRect);
const paimonMenu = isInventoryCountMode ? undefined : analyzePaimonMenu(bitmap, size);
const sanctified = isInventoryCountMode ? undefined : detectSanctifiedArtifactDetail(bitmap, size, detailRect);
const skipOcrForMissingDetail = Boolean(options.skipOcrUnlessArtifactDetail && !artifactDetail?.present);
const shouldSkipOcr = Boolean(options.skipOcr || skipOcrForMissingDetail);
const inventoryRect = inferInventoryRect(size, detailRect);
const omitCrops = Boolean(options.omitCrops);
const crops = omitCrops
? []
: createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr });
const lockSignal = options.omitLockState || isInventoryCountMode
? undefined
: (() => {
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
const lockImage = sourceImage.crop(lockRect);
const lockSize = lockImage.getSize();
const ratio = lockSize.width > 0 && lockSize.height > 0
? (() => {
try {
return lockSignalRatio(pngBufferToBitmap(lockImage.toPNG()));
} catch {
return lockSignalRatio({ data: lockImage.toBitmap(), width: lockSize.width, height: lockSize.height });
}
})()
: undefined;
return typeof ratio === "number"
? {
ratio,
threshold: DEFAULT_LOCK_THRESHOLD,
rect: {
x: lockRect.x,
y: lockRect.y,
width: lockRect.width,
height: lockRect.height,
},
}
: undefined;
})();
const locked = lockSignal ? isLocked(lockSignal.ratio, lockSignal.threshold) : undefined;
const omitFullFrame = Boolean(options.omitFullFrame || options.ocrMode === "artifact" || isInventoryCountMode);
const omitDetailPreview = Boolean(options.omitDetailPreview || isInventoryCountMode);
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact" || isInventoryCountMode);
const detailFingerprint = isInventoryCountMode ? undefined : imageCropFingerprint(sourceImage, detailRect, size);
const inventoryFingerprint = isInventoryCountMode ? undefined : imageCropFingerprint(sourceImage, inventoryRect, size);
const fastOcrPriority: Record<string, number> = {
"artifact-substats": 0,
"artifact-name": 1,
"artifact-footer": 2,
"artifact-slot": 3,
"artifact-main-stat-label": 4,
"artifact-level": 5,
};
const croppedPayload = crops
.filter((crop) => crop.ocrEnabled !== false)
.map((crop) => {
return crop.ocrImage
? {
id: crop.id,
label: crop.label,
image: crop.ocrImage,
}
: null;
})
.filter((crop): crop is OcrCropPayload => Boolean(crop))
.sort((left, right) => {
if (options.ocrMode !== "artifact" || options.ocrProfile !== "fast") return 0;
return (fastOcrPriority[left.id] ?? 100) - (fastOcrPriority[right.id] ?? 100);
});
const prepareMs = Date.now() - buildStartedAt;
const ocrEngine = ocrEngineFromOptions(options);
const ocrStartedAt = Date.now();
const recognized = shouldSkipOcr
? { ocr: [], timedOut: false }
: await runOcrOnCropsWithTimeout(croppedPayload, ocrEngine);
const ocrMs = Date.now() - ocrStartedAt;
const totalMs = Date.now() - buildStartedAt;
const captureOcrEngine: CaptureOptions["ocrEngine"] = "current";
const count = parseInventoryCount(recognized.ocr);
return {
id: sourceId,
name: sourceName,
width: size.width,
height: size.height,
dataUrl: omitFullFrame ? "" : sourceImage.toDataURL(),
capturedAt: new Date().toISOString(),
captureTarget,
detailDataUrl: omitDetailPreview ? undefined : imageCropDataUrl(sourceImage, detailRect, size),
inventoryDataUrl: omitInventoryPreview ? undefined : imageCropDataUrl(sourceImage, inventoryRect, size),
detailFingerprint,
inventoryFingerprint,
ocr: recognized.ocr,
ocrTimedOut: recognized.timedOut,
ocrSkipped: shouldSkipOcr,
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),
artifactDetail,
paimonMenu,
inventoryCount: count,
locked,
lockSignal,
sanctified,
layout: {
aspect: aspectRatioLabel(size),
isSixteenNine: isSixteenNine(size),
warning: layoutSupportWarning(size),
},
timings: {
totalMs,
prepareMs,
ocrMs: shouldSkipOcr ? 0 : ocrMs,
cropCount: croppedPayload.length,
ocrEngine: captureOcrEngine,
ocrWorkerPoolSize: OCR_WORKER_POOL_SIZE,
ocrProfile: options.ocrProfile ?? "full",
ocrFieldMs: recognized.ocr.reduce<Record<string, number>>((fields, result) => {
if (typeof result.elapsedMs === "number") fields[result.id] = result.elapsedMs;
return fields;
}, {}),
ocrSkipped: shouldSkipOcr,
},
};
}
async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions): Promise<CaptureResult> {
const captureStartedAt = Date.now();
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
const withElapsed = (capture: CaptureResult): CaptureResult => ({
...capture,
elapsedMs: Math.max(0, Date.now() - captureStartedAt),
});
await waitDelay(Math.floor(delayMs));
if (focusGenshin) {
await focusGenshinForScanStart();
}
if (shouldUseDirectGdiHotPath(options ?? {})) {
const cachedName = captureSourceNameCache.get(sourceId) ?? "Genshin GDI Capture";
return withElapsed(await captureSourceFromGdi(sourceId, cachedName, options ?? {}));
}
const source = await findCaptureSourceById(sourceId);
if (!source) {
throw new Error("Capture source not found.");
}
const isGenshinCandidate = isLikelyGenshinSourceName(source.name);
if (isGenshinCandidate) {
try {
return withElapsed(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 withElapsed(await captureSourceFromGdi(sourceId, source.name, options ?? {}));
}
return withElapsed(await buildCaptureResult(
sourceImage,
sourceId,
source.name,
sourceId.startsWith("screen:") ? "desktop-source" : "genshin-client",
options ?? {},
));
}
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() });
goodFileService = createGoodFileService(path.join(userDataPath, "exports"));
registerIpcHandlers({
focusMainWindow: () => focusMainWindow(),
moveMainWindowOffGenshin: async () => moveMainWindowOffGenshin(),
focusGenshinForScanStart: () => focusGenshinForScanStart(),
publishScannerStatus: (status: ScannerStatusPayload) => publishScannerStatus(status),
nativeScannerDataStatus: () => nativeScannerDataStatus(),
nativeScannerCatalog: () => nativeScannerCatalog(),
nativeScannerPreflight: (options?: { category?: string }) => nativeScannerPreflight(options),
nativeScannerStart: (options?: { limit?: number; category?: string }) => nativeScannerStart(options),
nativeScannerStop: () => nativeScannerStop(),
nativeScannerStatus: () => nativeScannerStatus(),
nativeScannerProcessRun: (options?: NativeScannerProcessOptions) => nativeScannerProcessRun(options),
nativeScannerProcessingStatus: () => nativeScannerProcessingStatus(),
nativeScannerLoadResults: (options?: NativeScannerResultsLoadOptions) => nativeScannerLoadResults(options),
nativeScannerPromoteResults: (options: { runDir?: string; resultIds: string[] }) => nativeScannerPromoteResults(options),
nativeScannerReviewResult: (options: {
runDir?: string;
resultId: string;
action: "approve" | "reject";
artifact?: NativeScannerReviewArtifactInput;
note?: string;
}) => nativeScannerReviewResult(options),
nativeScannerDeleteResult: (options: { runDir?: string; resultId: string; removeLinkedStoreRecord?: boolean }) => nativeScannerDeleteResult(options),
nativeScannerLoadImage: (options: { runDir?: string; imagePath: string }) => nativeScannerLoadImage(options),
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) => getGoodFileService().exportGood(exportPayload),
importGoodFile: () => getGoodFileService().importGoodFile(getAppWindowManager().getMainWindow()),
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),
keyPress: (key: string) => keyPressCommand(key),
getAutomationGuard: () => getAutomationGuardCommand(),
});
createMainWindow();
registerScannerHotkeys();
startDevControlServer();
void warmOcrWorkerPool("current");
});
app.on("activate", () => {
if (!getAppWindowManager().hasMainWindow()) {
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();