Prepare scanner branch for merge

This commit is contained in:
AzuTear
2026-07-09 08:44:50 +02:00
parent f791d1464c
commit 8b73c01e46
69 changed files with 6700 additions and 3773 deletions
+103 -163
View File
@@ -1,5 +1,4 @@
import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
import fs from "node:fs/promises";
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";
@@ -7,18 +6,19 @@ 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 { 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 type { AppSnapshot } from "../src/types/domain.js";
import type {
CaptureOptions,
CaptureResult,
GoodDatabase,
GoodImportFileResult,
OcrResult,
AppRuntimeInfo,
SaveResultWithPath,
ScannerCommand,
ScannerLearningRulePayload,
ScannerStatusPayload,
@@ -39,7 +39,7 @@ import {
profileDetailRect,
} from "../src/lib/layoutProfile.js";
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
import { detectLockState, lockIconCropRect } from "../src/lib/lockDetection.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
@@ -54,12 +54,11 @@ 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-07-ik32-fastsubstats-active-timing";
const APP_RUNTIME_SIGNATURE = "2026-07-08-direct-gdi-reviewfix";
let mainWindow: BrowserWindow | null = null;
let overlayWindow: BrowserWindow | null = null;
let registeredHotkeys: Record<string, boolean> = {};
let devControlServer: Server | null = null;
const captureSourceNameCache = new Map<string, string>();
let scannerDevStatus: ScannerStatusPayload = {
running: false,
reviewStatus: "",
@@ -83,6 +82,8 @@ let artifactStoreRepository: ArtifactStoreRepositoryPort | null = null;
let reviewSamplesRepository: ReviewSamplesRepositoryPort | null = null;
let scannerLearningRepository: ScannerLearningRepositoryPort | null = null;
let inputHelperService: InputHelperService | null = null;
let appWindowManager: AppWindowManager | null = null;
let goodFileService: GoodFileService | null = null;
function getInputHelperService() {
if (!inputHelperService) {
@@ -97,6 +98,7 @@ function resolveInputHelperExePath(): string | null {
const candidates = [
process.env.INPUT_HELPER_EXE,
path.join(process.resourcesPath, "input-helper", "InputHelper.exe"),
path.join(process.cwd(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
path.join(app.getAppPath(), "native", "input-helper", "bin", "publish", "InputHelper.exe"),
].filter((candidate): candidate is string => Boolean(candidate));
@@ -163,7 +165,7 @@ function getScannerLearningRepository() {
async function writeScannerLearningRules(rules: ScannerLearningRulePayload) {
const safeRules = rules && typeof rules === "object" ? rules : {};
try {
return await getScannerLearningRepository().save(safeRules as { textReplacements?: Record<string, string> });
return await getScannerLearningRepository().save(safeRules);
} catch {
return { ok: true, path: scannerLearningPath(), rules: { textReplacements: {} }, total: 0 };
}
@@ -184,8 +186,11 @@ function getSnapshotRepository() {
return context.snapshotRepository;
}
function exportPath(fileName: string) {
return path.join(app.getPath("userData"), "exports", fileName);
function getGoodFileService() {
if (!goodFileService) {
throw new Error("GOOD file service is not initialized.");
}
return goodFileService;
}
async function loadSnapshotFromDisk() {
@@ -264,6 +269,7 @@ async function getGenshinWindowBounds() {
// 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();
@@ -304,8 +310,7 @@ async function showOverlayWindow() {
}
async function hideOverlayWindow() {
overlayWindow?.close();
return { ok: true };
return getAppWindowManager().hideOverlayWindow();
}
async function listCaptureSources() {
@@ -324,12 +329,15 @@ async function listCaptureSources() {
fetchWindowIcons: true,
});
return sources.map((source) => ({
id: source.id,
name: source.name,
isGenshinCandidate: isLikelyGenshinSourceName(source.name),
thumbnailDataUrl: source.thumbnail.resize({ width: 420 }).toDataURL(),
}));
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) {
@@ -394,69 +402,45 @@ async function capturePrimaryScreenViaGdi() {
async function captureSourceFromGdi(sourceId: string, sourceName: string, options: CaptureOptions = {}) {
const gdi = await capturePrimaryScreenViaGdi();
const sourceImage = nativeImage.createFromDataURL(gdi.dataUrl);
const sourceImage = nativeImageFromGdiCapture(gdi);
return await buildCaptureResult(sourceImage, sourceId, sourceName, gdi.captureTarget, options);
}
function createMainWindow() {
Menu.setApplicationMenu(null);
function nativeImageFromGdiCapture(gdi: Awaited<ReturnType<InputHelperService["capturePrimaryScreenViaGdi"]>>) {
return nativeImage.createFromDataURL(gdi.dataUrl);
}
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,
},
});
function shouldUseDirectGdiHotPath(options: CaptureOptions = {}) {
return Boolean(
options.ocrMode === "artifact" ||
options.skipOcrUnlessArtifactDetail ||
options.skipOcr ||
options.omitCrops,
);
}
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 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() {
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 };
return getAppWindowManager().focusMainWindow();
}
function sendScannerCommand(command: ScannerCommand | "probe-click") {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send("scanner:command", command);
getAppWindowManager().sendScannerCommand(command);
}
function registerScannerHotkeys() {
@@ -474,7 +458,7 @@ function startDevControlServer() {
devControlServer = createDevControlServer({
registeredHotkeys,
appBuild: appRuntimeInfo(),
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
hasMainWindow: () => getAppWindowManager().hasMainWindow(),
sendScannerCommand,
clickScreen: clickScreenCommand,
scannerStatus: () => ({ ...scannerDevStatus, appBuild: appRuntimeInfo(), ocrWarmup: getOcrWarmupStatus() }),
@@ -490,43 +474,7 @@ function startDevControlServer() {
}
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;
});
getAppWindowManager().createOverlayWindow();
}
// Inventory Kamera keeps a pool of native Tesseract engines and scans artifact
@@ -818,8 +766,8 @@ async function runOcrOnCropsWithTimeout(crops: OcrCropPayload[], engine: OcrWork
function cleanOcrText(cropId: string, text: string) {
const normalized = text
.replace(/[“”]/g, '"')
.replace(/[’]/g, "'")
.replace(/[\u201c\u201d]/g, '"')
.replace(/[\u2019]/g, "'")
.replace(/\r/g, "")
.split("\n")
.map((line) => line.replace(/\s+/g, " ").trim())
@@ -1126,6 +1074,7 @@ async function getAllSources() {
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;
}
@@ -1172,7 +1121,7 @@ function imageCropFingerprint(sourceImage: NativeImage, rect: Electron.Rectangle
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, safeRect.width * scale), quality: "best" });
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(
@@ -1201,10 +1150,9 @@ function createCrops(
.filter((template) => {
if (fastArtifactProfile && (
template.id === "artifact-set-effects" ||
template.id === "artifact-slot" ||
template.id === "artifact-main-stat-value"
)) return false;
if (template.id === "artifact-footer" && (options.omitEquippedOcr || fastArtifactProfile)) 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);
});
@@ -1314,16 +1262,35 @@ async function buildCaptureResult(
const crops = omitCrops
? []
: createCrops(sourceImage, size, detailRect, inventoryRect, options, bitmap, { sanctified, skipOcr: shouldSkipOcr });
const locked = options.omitLockState
const lockSignal = options.omitLockState
? undefined
: (() => {
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
const lockImage = sourceImage.crop(lockRect);
const lockSize = lockImage.getSize();
return lockSize.width > 0 && lockSize.height > 0
? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height })
const ratio = lockSize.width > 0 && lockSize.height > 0
? (() => {
try {
return lockSignalRatio(pngBufferToBitmap(lockImage.toPNG()));
} catch {
return lockSignalRatio({ data: lockImage.getBitmap(), 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");
const omitDetailPreview = Boolean(options.omitDetailPreview);
const omitInventoryPreview = Boolean(options.omitInventoryPreview || options.ocrMode === "artifact");
@@ -1383,6 +1350,7 @@ async function buildCaptureResult(
paimonMenu,
inventoryCount: count,
locked,
lockSignal,
sanctified,
layout: {
aspect: aspectRatioLabel(size),
@@ -1407,16 +1375,27 @@ async function buildCaptureResult(
}
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.");
@@ -1425,7 +1404,7 @@ async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false
const isGenshinCandidate = isLikelyGenshinSourceName(source.name);
if (isGenshinCandidate) {
try {
return await captureSourceFromGdi(sourceId, source.name, options ?? {});
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.
@@ -1434,56 +1413,16 @@ async function captureSource(sourceId: string, delayMs = 0, focusGenshin = false
const sourceImage = source.thumbnail;
if (sourceImage.isEmpty()) {
return await captureSourceFromGdi(sourceId, source.name, options ?? {});
return withElapsed(await captureSourceFromGdi(sourceId, source.name, options ?? {}));
}
return await buildCaptureResult(
return withElapsed(await buildCaptureResult(
sourceImage,
sourceId,
source.name,
sourceId.startsWith("screen:") ? "desktop-source" : "genshin-client",
options ?? {},
);
}
async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
const fileNameSafe = `good-export-${new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "").replace(/\s+/g, "-")}.json`;
const filePath = exportPath(fileNameSafe);
try {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, JSON.stringify(payload, null, 2), "utf8");
return { ok: true, path: filePath };
} catch {
return { ok: false, path: filePath };
}
}
async function importGoodFile(): Promise<GoodImportFileResult> {
const dialogOptions = {
title: "GOOD-Datei importieren",
properties: ["openFile"],
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
} satisfies Electron.OpenDialogOptions;
const dialogResult = mainWindow && !mainWindow.isDestroyed()
? await dialog.showOpenDialog(mainWindow, dialogOptions)
: await dialog.showOpenDialog(dialogOptions);
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
return { ok: false, canceled: true, path: "" };
}
const filePath = dialogResult.filePaths[0];
try {
const text = await fs.readFile(filePath, "utf8");
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
} catch (error) {
return {
ok: false,
canceled: false,
path: filePath,
error: error instanceof Error ? error.message : String(error),
};
}
));
}
function initializeAppLifecycle() {
@@ -1494,6 +1433,7 @@ function initializeAppLifecycle() {
reviewSamplesRepository = repositoryContext.reviewSamplesRepository;
scannerLearningRepository = repositoryContext.scannerLearningRepository;
inputHelperService = createInputHelperService({ userDataPath, exePath: resolveInputHelperExePath() });
goodFileService = createGoodFileService(path.join(userDataPath, "exports"));
registerIpcHandlers({
focusMainWindow: () => focusMainWindow(),
@@ -1513,8 +1453,8 @@ function initializeAppLifecycle() {
loadReviewSamples: (limit?: number) => loadReviewSamples(limit),
loadScannerLearningRules: () => loadScannerLearningRules(),
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules),
exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload),
importGoodFile: () => importGoodFile(),
exportGood: (exportPayload: GoodDatabase) => getGoodFileService().exportGood(exportPayload),
importGoodFile: () => getGoodFileService().importGoodFile(getAppWindowManager().getMainWindow()),
listSources: () => listCaptureSources(),
captureSource: (
id: string,
@@ -1535,7 +1475,7 @@ function initializeAppLifecycle() {
});
app.on("activate", () => {
if (!mainWindow || mainWindow.isDestroyed()) {
if (!getAppWindowManager().hasMainWindow()) {
createMainWindow();
}
});