Prepare scanner branch for merge
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { BrowserWindow, Menu, screen } from "electron";
|
||||
import path from "node:path";
|
||||
|
||||
export interface AppWindowManagerOptions {
|
||||
preloadPath: string;
|
||||
rendererUrl?: string;
|
||||
rendererFilePath: string;
|
||||
onMainReadyToShow?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface AppWindowManager {
|
||||
createMainWindow: () => void;
|
||||
focusMainWindow: () => { ok: boolean };
|
||||
hasMainWindow: () => boolean;
|
||||
getMainWindow: () => BrowserWindow | null;
|
||||
sendScannerCommand: (command: unknown) => void;
|
||||
createOverlayWindow: () => void;
|
||||
hideOverlayWindow: () => { ok: boolean };
|
||||
}
|
||||
|
||||
export function createAppWindowManager({
|
||||
preloadPath,
|
||||
rendererUrl,
|
||||
rendererFilePath,
|
||||
onMainReadyToShow,
|
||||
}: AppWindowManagerOptions): AppWindowManager {
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let overlayWindow: BrowserWindow | null = null;
|
||||
|
||||
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: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.setMenuBarVisibility(false);
|
||||
mainWindow.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
void onMainReadyToShow?.();
|
||||
focusMainWindow();
|
||||
});
|
||||
mainWindow.webContents.once("did-finish-load", () => {
|
||||
setTimeout(() => focusMainWindow(), 350);
|
||||
});
|
||||
|
||||
if (rendererUrl) {
|
||||
mainWindow.loadURL(rendererUrl);
|
||||
} else {
|
||||
mainWindow.loadFile(rendererFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
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: unknown) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send("scanner:command", command);
|
||||
}
|
||||
|
||||
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: preloadPath,
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
overlayWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||
|
||||
if (rendererUrl) {
|
||||
overlayWindow.loadURL(`${rendererUrl}?overlay=1`);
|
||||
} else {
|
||||
overlayWindow.loadFile(rendererFilePath, {
|
||||
query: { overlay: "1" },
|
||||
});
|
||||
}
|
||||
|
||||
overlayWindow.on("closed", () => {
|
||||
overlayWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
function hideOverlayWindow() {
|
||||
overlayWindow?.close();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
createMainWindow,
|
||||
focusMainWindow,
|
||||
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
getMainWindow: () => mainWindow,
|
||||
sendScannerCommand,
|
||||
createOverlayWindow,
|
||||
hideOverlayWindow,
|
||||
};
|
||||
}
|
||||
@@ -113,6 +113,7 @@ async function writeDevCaptureSnapshot(capture: CaptureResult) {
|
||||
: null,
|
||||
inventoryCount: capture.inventoryCount ?? null,
|
||||
locked: capture.locked,
|
||||
lockSignal: capture.lockSignal,
|
||||
crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })),
|
||||
ocr: capture.ocr ?? [],
|
||||
files,
|
||||
@@ -339,7 +340,10 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
captures,
|
||||
};
|
||||
}
|
||||
const summaries = await Promise.all(engines.map((engine) => runEngineBenchmark(engine)));
|
||||
const summaries = [];
|
||||
for (const engine of engines) {
|
||||
summaries.push(await runEngineBenchmark(engine));
|
||||
}
|
||||
writeDevJson(res, 200, {
|
||||
ok: true,
|
||||
summary: summaries.length === 1 ? summaries[0] : { mode: "compare", limit, ocrProfile, engines: summaries },
|
||||
|
||||
+103
-163
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -16,22 +16,71 @@ export class ScannerLearningRepository implements ScannerLearningRepositoryPort
|
||||
return {
|
||||
ok: true,
|
||||
path: this.filePath,
|
||||
rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} },
|
||||
rules: parsed && typeof parsed === "object" ? parsed : emptyRules(),
|
||||
};
|
||||
} catch {
|
||||
return { ok: true, path: this.filePath, rules: { textReplacements: {} } };
|
||||
return { ok: true, path: this.filePath, rules: emptyRules() };
|
||||
}
|
||||
}
|
||||
|
||||
async save(rules: ScannerLearningRules): Promise<ScannerLearningSaveResult> {
|
||||
const current = await this.load();
|
||||
const nextTextReplacements = {
|
||||
...((current.rules as { textReplacements?: Record<string, string> })?.textReplacements ?? {}),
|
||||
...((rules as { textReplacements?: Record<string, string> })?.textReplacements ?? {}),
|
||||
};
|
||||
const payload: ScannerLearningRules = { textReplacements: nextTextReplacements };
|
||||
const payload: ScannerLearningRules = mergeRules(current.rules, rules);
|
||||
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
|
||||
await fs.writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
|
||||
return { ok: true, path: this.filePath, rules: payload, total: Object.keys(nextTextReplacements).length };
|
||||
return { ok: true, path: this.filePath, rules: payload, total: countRules(payload) };
|
||||
}
|
||||
}
|
||||
|
||||
function emptyRules(): ScannerLearningRules {
|
||||
return {
|
||||
textReplacements: {},
|
||||
fieldAliases: {},
|
||||
constrainedFixes: {},
|
||||
cropAdjustments: {},
|
||||
uiProfileAdjustments: {},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRules(current: ScannerLearningRules, incoming: ScannerLearningRules): ScannerLearningRules {
|
||||
return {
|
||||
textReplacements: {
|
||||
...(current.textReplacements ?? {}),
|
||||
...(incoming.textReplacements ?? {}),
|
||||
},
|
||||
fieldAliases: mergeNested(current.fieldAliases, incoming.fieldAliases),
|
||||
constrainedFixes: {
|
||||
...(current.constrainedFixes ?? {}),
|
||||
...(incoming.constrainedFixes ?? {}),
|
||||
},
|
||||
cropAdjustments: {
|
||||
...(current.cropAdjustments ?? {}),
|
||||
...(incoming.cropAdjustments ?? {}),
|
||||
},
|
||||
uiProfileAdjustments: {
|
||||
...(current.uiProfileAdjustments ?? {}),
|
||||
...(incoming.uiProfileAdjustments ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mergeNested(
|
||||
current: Record<string, Record<string, string>> | undefined,
|
||||
incoming: Record<string, Record<string, string>> | undefined,
|
||||
) {
|
||||
const merged: Record<string, Record<string, string>> = {};
|
||||
for (const [field, values] of Object.entries(current ?? {})) merged[field] = { ...(values ?? {}) };
|
||||
for (const [field, values] of Object.entries(incoming ?? {})) merged[field] = { ...(merged[field] ?? {}), ...(values ?? {}) };
|
||||
return merged;
|
||||
}
|
||||
|
||||
function countRules(rules: ScannerLearningRules) {
|
||||
const fieldAliases = Object.values(rules.fieldAliases ?? {}).reduce((sum, aliases) => sum + Object.keys(aliases ?? {}).length, 0);
|
||||
return (
|
||||
Object.keys(rules.textReplacements ?? {}).length
|
||||
+ fieldAliases
|
||||
+ Object.keys(rules.constrainedFixes ?? {}).length
|
||||
+ Object.keys(rules.cropAdjustments ?? {}).length
|
||||
+ Object.keys(rules.uiProfileAdjustments ?? {}).length
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { dialog, type BrowserWindow } from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { GoodDatabase, GoodImportFileResult, SaveResultWithPath } from "../../src/types/global.js";
|
||||
|
||||
export interface GoodFileService {
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: (parentWindow?: BrowserWindow | null) => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
export function createGoodFileService(exportDirectory: string): GoodFileService {
|
||||
function exportPath(fileName: string) {
|
||||
return path.join(exportDirectory, fileName);
|
||||
}
|
||||
|
||||
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(parentWindow?: BrowserWindow | null): Promise<GoodImportFileResult> {
|
||||
const dialogOptions = {
|
||||
title: "GOOD-Datei importieren",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
|
||||
} satisfies Electron.OpenDialogOptions;
|
||||
const dialogResult = parentWindow && !parentWindow.isDestroyed()
|
||||
? await dialog.showOpenDialog(parentWindow, 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { exportGood, importGoodFile };
|
||||
}
|
||||
@@ -13,444 +13,7 @@ import type {
|
||||
ScrollResult,
|
||||
} from "../../src/types/global.js";
|
||||
|
||||
const INPUT_HELPER_SCRIPT = String.raw`
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
$signature = @"
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetProcessDPIAware();
|
||||
[DllImport("shcore.dll")]
|
||||
public static extern int SetProcessDpiAwareness(int value);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vKey);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT { public int X; public int Y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT { public int type; public MOUSEINPUT mi; }
|
||||
"@
|
||||
Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native
|
||||
# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the
|
||||
# same symptom): the older SetProcessDPIAware() only applies a single,
|
||||
# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin
|
||||
# on one display, this app's window on a differently-scaled second display),
|
||||
# that single scale factor is wrong for whichever monitor didn't set it,
|
||||
# silently shifting every SetCursorPos/click coordinate off-target even
|
||||
# though cursor readback still matches what we asked for (both go through the
|
||||
# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2.
|
||||
try {
|
||||
[Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null
|
||||
} catch {
|
||||
[Native.InputHelper]::SetProcessDPIAware() | Out-Null
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# SizeOf must receive a struct instance: passing the type object throws in
|
||||
# Windows PowerShell 5.1 (RuntimeType cannot be marshalled).
|
||||
$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT))
|
||||
$genshinHwnd = [IntPtr]::Zero
|
||||
|
||||
function Send-MouseInput {
|
||||
param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0)
|
||||
$mouseInput = New-Object Native.InputHelper+INPUT
|
||||
$mouseInput.type = 0
|
||||
$mouseInput.mi.dx = $dx
|
||||
$mouseInput.mi.dy = $dy
|
||||
if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData }
|
||||
$mouseInput.mi.dwFlags = $flags
|
||||
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
|
||||
}
|
||||
|
||||
# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves
|
||||
# with bare SetCursorPos, then clicks via the InputSimulator library's
|
||||
# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE
|
||||
# SendInput call (two INPUT structs in the same array) - back-to-back with no
|
||||
# artificial delay between them, unlike two separate SendInput calls with a
|
||||
# Start-Sleep in between. Returns the number of injected events (2 = ok).
|
||||
function Send-MouseClickBatch {
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 0
|
||||
$down.mi.dwFlags = 0x0002
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 0
|
||||
$up.mi.dwFlags = 0x0004
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Send-KeyPressBatch {
|
||||
param([int]$virtualKey)
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 1
|
||||
$down.mi.dx = $virtualKey
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 1
|
||||
$up.mi.dx = $virtualKey
|
||||
# Same union bytes as KEYBDINPUT: dx low word = wVk, dy = dwFlags.
|
||||
$up.mi.dy = 0x0002
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Resolve-VirtualKey {
|
||||
param([string]$key)
|
||||
switch ($key.ToUpperInvariant()) {
|
||||
"ESC" { return 27 }
|
||||
"ESCAPE" { return 27 }
|
||||
"ENTER" { return 13 }
|
||||
"B" { return 66 }
|
||||
"C" { return 67 }
|
||||
"1" { return 49 }
|
||||
default { throw "unsupported key: $key" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorPoint {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
return $pt
|
||||
}
|
||||
|
||||
function Get-ProcessNameFromHwnd {
|
||||
param([IntPtr]$hwnd)
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return "" }
|
||||
$pidValue = [uint32]0
|
||||
[Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null
|
||||
if ($pidValue -eq 0) { return "" }
|
||||
try {
|
||||
return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentProcessElevation {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-ForegroundInfo {
|
||||
$hwnd = [Native.InputHelper]::GetForegroundWindow()
|
||||
return @{
|
||||
foregroundHwnd = $hwnd.ToInt64()
|
||||
foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorState {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
# Only 0x8000 (key is held down right now). The 0x0001 "pressed since last
|
||||
# call" bit is unreliable and fires for ESC presses that happened long
|
||||
# before the scan (ESC is used constantly to navigate Genshin menus).
|
||||
$esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0
|
||||
$enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0
|
||||
$f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0
|
||||
return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 }
|
||||
}
|
||||
|
||||
function Get-GenshinClientBounds {
|
||||
$hwnd = Find-GenshinWindow
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $null }
|
||||
|
||||
$rect = New-Object Native.InputHelper+RECT
|
||||
if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null }
|
||||
|
||||
$topLeft = New-Object Native.InputHelper+POINT
|
||||
$topLeft.X = 0
|
||||
$topLeft.Y = 0
|
||||
if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null }
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) { return $null }
|
||||
|
||||
return @{
|
||||
Left = $topLeft.X
|
||||
Top = $topLeft.Y
|
||||
Width = $width
|
||||
Height = $height
|
||||
}
|
||||
}
|
||||
|
||||
function Find-GenshinWindow {
|
||||
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
|
||||
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
||||
return $script:genshinHwnd
|
||||
}
|
||||
|
||||
# Plain SetForegroundWindow from this background helper process is silently
|
||||
# refused by Windows' foreground lock. Attach our thread's input queue to the
|
||||
# target (and current foreground) window thread and clear the lock timeout, so
|
||||
# the foreground change is honored - the same technique Inventory Kamera uses.
|
||||
function Force-Foreground {
|
||||
param([IntPtr]$hwnd)
|
||||
$current = [Native.InputHelper]::GetCurrentThreadId()
|
||||
$targetPid = [uint32]0
|
||||
$target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid)
|
||||
$fgWindow = [Native.InputHelper]::GetForegroundWindow()
|
||||
$foreground = [uint32]0
|
||||
if ($fgWindow -ne [IntPtr]::Zero) {
|
||||
$fgPid = [uint32]0
|
||||
$foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid)
|
||||
}
|
||||
|
||||
$attachedTarget = $false
|
||||
$attachedForeground = $false
|
||||
$oldTimeout = [uint32]0
|
||||
$timeoutRead = $false
|
||||
try {
|
||||
if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) }
|
||||
if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) }
|
||||
$timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0)
|
||||
[Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null
|
||||
# Inject a no-op input (0,0 mouse move) so this process is the last input
|
||||
# source, which Windows requires before it will honor a foreground change.
|
||||
Send-MouseInput -flags 0x0001 | Out-Null
|
||||
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||
[Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null
|
||||
return [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||
} finally {
|
||||
if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null }
|
||||
if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null }
|
||||
if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
function Focus-GenshinWindow {
|
||||
$hwnd = Find-GenshinWindow
|
||||
$info = @{
|
||||
hwnd = $hwnd.ToInt64()
|
||||
focused = $false
|
||||
alreadyForeground = $false
|
||||
foregroundProcess = ""
|
||||
targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $info }
|
||||
|
||||
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
|
||||
if (-not $info.alreadyForeground) {
|
||||
$info.setForegroundResult = Force-Foreground -hwnd $hwnd
|
||||
Start-Sleep -Milliseconds 140
|
||||
}
|
||||
|
||||
$foreground = [Native.InputHelper]::GetForegroundWindow()
|
||||
$info.focused = ($foreground -eq $hwnd)
|
||||
$info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground
|
||||
return $info
|
||||
}
|
||||
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ($line.Trim().Length -eq 0) { continue }
|
||||
$response = @{ id = ""; ok = $true }
|
||||
try {
|
||||
$cmd = $line | ConvertFrom-Json
|
||||
$response.id = "$($cmd.id)"
|
||||
switch ("$($cmd.op)") {
|
||||
"ping" {
|
||||
$response.pong = $true
|
||||
}
|
||||
"cursor" {
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
}
|
||||
"runtime" {
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$hwnd = Find-GenshinWindow
|
||||
$foregroundInfo = Get-ForegroundInfo
|
||||
$response.genshinFound = ($hwnd -ne [IntPtr]::Zero)
|
||||
$response.genshinHwnd = $hwnd.ToInt64()
|
||||
$response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
$response.foregroundProcess = $foregroundInfo.foregroundProcess
|
||||
$response.foregroundHwnd = $foregroundInfo.foregroundHwnd
|
||||
$response.helperPid = $PID
|
||||
}
|
||||
"focus" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.genshinFound = ($focusInfo.hwnd -ne 0)
|
||||
$response.setForegroundResult = $focusInfo.setForegroundResult
|
||||
}
|
||||
"click" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$targetX = [int]$cmd.x
|
||||
$targetY = [int]$cmd.y
|
||||
# Matches Inventory Kamera's verified-working sequence exactly: bare
|
||||
# SetCursorPos immediately followed by a click, with NO extra move
|
||||
# event and NO artificial delay between moving and clicking - IK's
|
||||
# Navigation.Click(x, y) does SetCursor() then Click() back-to-back,
|
||||
# zero gap. Settling delays only happen after the click, in the scan
|
||||
# loop. Down+up are sent as one SendInput call (see
|
||||
# Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick().
|
||||
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
|
||||
$point = Get-CursorPoint
|
||||
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
|
||||
$clickEventsSent = 0
|
||||
if ($onTarget) {
|
||||
$clickEventsSent = Send-MouseClickBatch
|
||||
}
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
$response.moved = $onTarget
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
# Never report a click unless the cursor is verifiably on the target.
|
||||
# Real acceptance is proven later by the detail-panel fingerprint.
|
||||
$response.clicked = ($onTarget -and $clickEventsSent -ge 2)
|
||||
$response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2)
|
||||
}
|
||||
"scroll" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
if ($null -ne $cmd.x -and $null -ne $cmd.y) {
|
||||
[Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null
|
||||
Start-Sleep -Milliseconds 30
|
||||
}
|
||||
$point = Get-CursorPoint
|
||||
$response.cursorX = $point.X
|
||||
$response.cursorY = $point.Y
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$notches = [int]$cmd.notches
|
||||
$stepDelta = 120
|
||||
if ($notches -lt 0) { $stepDelta = -120 }
|
||||
$count = [Math]::Abs($notches)
|
||||
if ($count -gt 60) { $count = 60 }
|
||||
$sentTotal = 0
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta
|
||||
Start-Sleep -Milliseconds 45
|
||||
}
|
||||
$response.notchesSent = $sentTotal
|
||||
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
|
||||
}
|
||||
"key" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$vk = Resolve-VirtualKey -key "$($cmd.key)"
|
||||
$sent = Send-KeyPressBatch -virtualKey $vk
|
||||
$response.key = "$($cmd.key)"
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$response.eventsSent = $sent
|
||||
$response.inputBlocked = ($sent -lt 2)
|
||||
}
|
||||
"bounds" {
|
||||
$clientBounds = Get-GenshinClientBounds
|
||||
if ($null -eq $clientBounds) {
|
||||
$response.found = $false
|
||||
} else {
|
||||
$response.found = $true
|
||||
$response.left = $clientBounds.Left
|
||||
$response.top = $clientBounds.Top
|
||||
$response.width = $clientBounds.Width
|
||||
$response.height = $clientBounds.Height
|
||||
}
|
||||
}
|
||||
"capture" {
|
||||
$clientBounds = Get-GenshinClientBounds
|
||||
if ($null -eq $clientBounds) {
|
||||
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$clientBounds = @{
|
||||
Left = $screenBounds.Left
|
||||
Top = $screenBounds.Top
|
||||
Width = $screenBounds.Width
|
||||
Height = $screenBounds.Height
|
||||
}
|
||||
$response.captureTarget = "primary-screen"
|
||||
} else {
|
||||
$response.captureTarget = "genshin-client"
|
||||
}
|
||||
$bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size)
|
||||
$capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png")
|
||||
$bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$response.path = $capturePath
|
||||
$response.width = $clientBounds.Width
|
||||
$response.height = $clientBounds.Height
|
||||
$response.originX = $clientBounds.Left
|
||||
$response.originY = $clientBounds.Top
|
||||
}
|
||||
default {
|
||||
$response.ok = $false
|
||||
$response.error = "unknown op"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$response.ok = $false
|
||||
$response.error = $_.Exception.Message
|
||||
}
|
||||
Write-Output (ConvertTo-Json $response -Compress)
|
||||
}
|
||||
`;
|
||||
import { INPUT_HELPER_SCRIPT } from "./inputHelperPowerShellFallback.js";
|
||||
|
||||
class InputHelperClient {
|
||||
private child: ChildProcessWithoutNullStreams | null = null;
|
||||
|
||||
@@ -0,0 +1,441 @@
|
||||
// PowerShell fallback for environments where the compiled C# sidecar is unavailable. Keep the JSON protocol aligned with native/input-helper/Program.cs.
|
||||
|
||||
export const INPUT_HELPER_SCRIPT = String.raw`
|
||||
$ErrorActionPreference = "Stop"
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
$signature = @"
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetProcessDPIAware();
|
||||
[DllImport("shcore.dll")]
|
||||
public static extern int SetProcessDpiAwareness(int value);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetCursorPos(int X, int Y);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetCursorPos(out POINT lpPoint);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern short GetAsyncKeyState(int vKey);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool SetForegroundWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool BringWindowToTop(IntPtr hWnd);
|
||||
[DllImport("user32.dll", SetLastError=true)]
|
||||
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
|
||||
[DllImport("kernel32.dll")]
|
||||
public static extern uint GetCurrentThreadId();
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoGet(uint uiAction, uint uiParam, ref uint pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll", SetLastError=true, EntryPoint="SystemParametersInfoW")]
|
||||
public static extern bool SystemParametersInfoSet(uint uiAction, uint uiParam, IntPtr pvParam, uint fWinIni);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool IsWindow(IntPtr hWnd);
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT { public int X; public int Y; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct MOUSEINPUT { public int dx; public int dy; public uint mouseData; public uint dwFlags; public uint time; public UIntPtr dwExtraInfo; }
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct INPUT { public int type; public MOUSEINPUT mi; }
|
||||
"@
|
||||
Add-Type -MemberDefinition $signature -Name InputHelper -Namespace Native
|
||||
# Per-monitor DPI awareness (matches GenshinArtScanner's proven fix for the
|
||||
# same symptom): the older SetProcessDPIAware() only applies a single,
|
||||
# system-wide scale factor. On a mixed-DPI multi-monitor setup (e.g. Genshin
|
||||
# on one display, this app's window on a differently-scaled second display),
|
||||
# that single scale factor is wrong for whichever monitor didn't set it,
|
||||
# silently shifting every SetCursorPos/click coordinate off-target even
|
||||
# though cursor readback still matches what we asked for (both go through the
|
||||
# same, wrong, virtualization layer). PROCESS_PER_MONITOR_DPI_AWARE = 2.
|
||||
try {
|
||||
[Native.InputHelper]::SetProcessDpiAwareness(2) | Out-Null
|
||||
} catch {
|
||||
[Native.InputHelper]::SetProcessDPIAware() | Out-Null
|
||||
}
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
# SizeOf must receive a struct instance: passing the type object throws in
|
||||
# Windows PowerShell 5.1 (RuntimeType cannot be marshalled).
|
||||
$inputSize = [Runtime.InteropServices.Marshal]::SizeOf((New-Object Native.InputHelper+INPUT))
|
||||
$genshinHwnd = [IntPtr]::Zero
|
||||
|
||||
function Send-MouseInput {
|
||||
param([uint32]$flags, [int]$dx = 0, [int]$dy = 0, [long]$wheelData = 0)
|
||||
$mouseInput = New-Object Native.InputHelper+INPUT
|
||||
$mouseInput.type = 0
|
||||
$mouseInput.mi.dx = $dx
|
||||
$mouseInput.mi.dy = $dy
|
||||
if ($wheelData -lt 0) { $mouseInput.mi.mouseData = [uint32](4294967296 + $wheelData) } else { $mouseInput.mi.mouseData = [uint32]$wheelData }
|
||||
$mouseInput.mi.dwFlags = $flags
|
||||
return [Native.InputHelper]::SendInput(1, [Native.InputHelper+INPUT[]]@($mouseInput), $inputSize)
|
||||
}
|
||||
|
||||
# Matches Inventory Kamera exactly (see docs/DECISIONS.md ADR-008): it moves
|
||||
# with bare SetCursorPos, then clicks via the InputSimulator library's
|
||||
# Mouse.LeftButtonClick(), which sends button-down and button-up as ONE
|
||||
# SendInput call (two INPUT structs in the same array) - back-to-back with no
|
||||
# artificial delay between them, unlike two separate SendInput calls with a
|
||||
# Start-Sleep in between. Returns the number of injected events (2 = ok).
|
||||
function Send-MouseClickBatch {
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 0
|
||||
$down.mi.dwFlags = 0x0002
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 0
|
||||
$up.mi.dwFlags = 0x0004
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Send-KeyPressBatch {
|
||||
param([int]$virtualKey)
|
||||
$down = New-Object Native.InputHelper+INPUT
|
||||
$down.type = 1
|
||||
$down.mi.dx = $virtualKey
|
||||
$up = New-Object Native.InputHelper+INPUT
|
||||
$up.type = 1
|
||||
$up.mi.dx = $virtualKey
|
||||
# Same union bytes as KEYBDINPUT: dx low word = wVk, dy = dwFlags.
|
||||
$up.mi.dy = 0x0002
|
||||
return [Native.InputHelper]::SendInput(2, [Native.InputHelper+INPUT[]]@($down, $up), $inputSize)
|
||||
}
|
||||
|
||||
function Resolve-VirtualKey {
|
||||
param([string]$key)
|
||||
switch ($key.ToUpperInvariant()) {
|
||||
"ESC" { return 27 }
|
||||
"ESCAPE" { return 27 }
|
||||
"ENTER" { return 13 }
|
||||
"B" { return 66 }
|
||||
"C" { return 67 }
|
||||
"1" { return 49 }
|
||||
default { throw "unsupported key: $key" }
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorPoint {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
return $pt
|
||||
}
|
||||
|
||||
function Get-ProcessNameFromHwnd {
|
||||
param([IntPtr]$hwnd)
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return "" }
|
||||
$pidValue = [uint32]0
|
||||
[Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$pidValue) | Out-Null
|
||||
if ($pidValue -eq 0) { return "" }
|
||||
try {
|
||||
return (Get-Process -Id ([int]$pidValue) -ErrorAction Stop).ProcessName
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CurrentProcessElevation {
|
||||
$identity = [Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
$principal = New-Object Security.Principal.WindowsPrincipal($identity)
|
||||
return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
}
|
||||
|
||||
function Get-ForegroundInfo {
|
||||
$hwnd = [Native.InputHelper]::GetForegroundWindow()
|
||||
return @{
|
||||
foregroundHwnd = $hwnd.ToInt64()
|
||||
foregroundProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
}
|
||||
|
||||
function Get-CursorState {
|
||||
$pt = New-Object Native.InputHelper+POINT
|
||||
[Native.InputHelper]::GetCursorPos([ref]$pt) | Out-Null
|
||||
# Only 0x8000 (key is held down right now). The 0x0001 "pressed since last
|
||||
# call" bit is unreliable and fires for ESC presses that happened long
|
||||
# before the scan (ESC is used constantly to navigate Genshin menus).
|
||||
$esc = ([Native.InputHelper]::GetAsyncKeyState(27) -band 0x8000) -ne 0
|
||||
$enter = ([Native.InputHelper]::GetAsyncKeyState(13) -band 0x8000) -ne 0
|
||||
$f9 = ([Native.InputHelper]::GetAsyncKeyState(120) -band 0x8000) -ne 0
|
||||
return @{ cursorX = $pt.X; cursorY = $pt.Y; escapePressed = $esc; enterPressed = $enter; f9Pressed = $f9 }
|
||||
}
|
||||
|
||||
function Get-GenshinClientBounds {
|
||||
$hwnd = Find-GenshinWindow
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $null }
|
||||
|
||||
$rect = New-Object Native.InputHelper+RECT
|
||||
if (-not [Native.InputHelper]::GetClientRect($hwnd, [ref]$rect)) { return $null }
|
||||
|
||||
$topLeft = New-Object Native.InputHelper+POINT
|
||||
$topLeft.X = 0
|
||||
$topLeft.Y = 0
|
||||
if (-not [Native.InputHelper]::ClientToScreen($hwnd, [ref]$topLeft)) { return $null }
|
||||
|
||||
$width = $rect.Right - $rect.Left
|
||||
$height = $rect.Bottom - $rect.Top
|
||||
if ($width -le 0 -or $height -le 0) { return $null }
|
||||
|
||||
return @{
|
||||
Left = $topLeft.X
|
||||
Top = $topLeft.Y
|
||||
Width = $width
|
||||
Height = $height
|
||||
}
|
||||
}
|
||||
|
||||
function Find-GenshinWindow {
|
||||
if ($script:genshinHwnd -ne [IntPtr]::Zero -and [Native.InputHelper]::IsWindow($script:genshinHwnd)) { return $script:genshinHwnd }
|
||||
$proc = Get-Process | Where-Object { $_.ProcessName -match 'GenshinImpact|YuanShen|Genshin' -and $_.MainWindowHandle -ne 0 } | Select-Object -First 1
|
||||
if ($proc) { $script:genshinHwnd = $proc.MainWindowHandle } else { $script:genshinHwnd = [IntPtr]::Zero }
|
||||
return $script:genshinHwnd
|
||||
}
|
||||
|
||||
# Plain SetForegroundWindow from this background helper process is silently
|
||||
# refused by Windows' foreground lock. Attach our thread's input queue to the
|
||||
# target (and current foreground) window thread and clear the lock timeout, so
|
||||
# the foreground change is honored - the same technique Inventory Kamera uses.
|
||||
function Force-Foreground {
|
||||
param([IntPtr]$hwnd)
|
||||
$current = [Native.InputHelper]::GetCurrentThreadId()
|
||||
$targetPid = [uint32]0
|
||||
$target = [Native.InputHelper]::GetWindowThreadProcessId($hwnd, [ref]$targetPid)
|
||||
$fgWindow = [Native.InputHelper]::GetForegroundWindow()
|
||||
$foreground = [uint32]0
|
||||
if ($fgWindow -ne [IntPtr]::Zero) {
|
||||
$fgPid = [uint32]0
|
||||
$foreground = [Native.InputHelper]::GetWindowThreadProcessId($fgWindow, [ref]$fgPid)
|
||||
}
|
||||
|
||||
$attachedTarget = $false
|
||||
$attachedForeground = $false
|
||||
$oldTimeout = [uint32]0
|
||||
$timeoutRead = $false
|
||||
try {
|
||||
if ($target -ne 0 -and $target -ne $current) { $attachedTarget = [Native.InputHelper]::AttachThreadInput($current, $target, $true) }
|
||||
if ($foreground -ne 0 -and $foreground -ne $current -and $foreground -ne $target) { $attachedForeground = [Native.InputHelper]::AttachThreadInput($current, $foreground, $true) }
|
||||
$timeoutRead = [Native.InputHelper]::SystemParametersInfoGet(0x2000, 0, [ref]$oldTimeout, 0)
|
||||
[Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::Zero, 0x0002) | Out-Null
|
||||
# Inject a no-op input (0,0 mouse move) so this process is the last input
|
||||
# source, which Windows requires before it will honor a foreground change.
|
||||
Send-MouseInput -flags 0x0001 | Out-Null
|
||||
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
|
||||
[Native.InputHelper]::BringWindowToTop($hwnd) | Out-Null
|
||||
return [Native.InputHelper]::SetForegroundWindow($hwnd)
|
||||
} finally {
|
||||
if ($timeoutRead) { [Native.InputHelper]::SystemParametersInfoSet(0x2001, 0, [IntPtr]::new([int64]$oldTimeout), 0x0002) | Out-Null }
|
||||
if ($attachedForeground) { [Native.InputHelper]::AttachThreadInput($current, $foreground, $false) | Out-Null }
|
||||
if ($attachedTarget) { [Native.InputHelper]::AttachThreadInput($current, $target, $false) | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
function Focus-GenshinWindow {
|
||||
$hwnd = Find-GenshinWindow
|
||||
$info = @{
|
||||
hwnd = $hwnd.ToInt64()
|
||||
focused = $false
|
||||
alreadyForeground = $false
|
||||
foregroundProcess = ""
|
||||
targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
}
|
||||
if ($hwnd -eq [IntPtr]::Zero) { return $info }
|
||||
|
||||
$info.alreadyForeground = ([Native.InputHelper]::GetForegroundWindow() -eq $hwnd)
|
||||
if (-not $info.alreadyForeground) {
|
||||
$info.setForegroundResult = Force-Foreground -hwnd $hwnd
|
||||
Start-Sleep -Milliseconds 140
|
||||
}
|
||||
|
||||
$foreground = [Native.InputHelper]::GetForegroundWindow()
|
||||
$info.focused = ($foreground -eq $hwnd)
|
||||
$info.foregroundProcess = Get-ProcessNameFromHwnd -hwnd $foreground
|
||||
return $info
|
||||
}
|
||||
|
||||
while ($true) {
|
||||
$line = [Console]::In.ReadLine()
|
||||
if ($null -eq $line) { break }
|
||||
if ($line.Trim().Length -eq 0) { continue }
|
||||
$response = @{ id = ""; ok = $true }
|
||||
try {
|
||||
$cmd = $line | ConvertFrom-Json
|
||||
$response.id = "$($cmd.id)"
|
||||
switch ("$($cmd.op)") {
|
||||
"ping" {
|
||||
$response.pong = $true
|
||||
}
|
||||
"cursor" {
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
}
|
||||
"runtime" {
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$hwnd = Find-GenshinWindow
|
||||
$foregroundInfo = Get-ForegroundInfo
|
||||
$response.genshinFound = ($hwnd -ne [IntPtr]::Zero)
|
||||
$response.genshinHwnd = $hwnd.ToInt64()
|
||||
$response.targetProcess = Get-ProcessNameFromHwnd -hwnd $hwnd
|
||||
$response.foregroundProcess = $foregroundInfo.foregroundProcess
|
||||
$response.foregroundHwnd = $foregroundInfo.foregroundHwnd
|
||||
$response.helperPid = $PID
|
||||
}
|
||||
"focus" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.genshinFound = ($focusInfo.hwnd -ne 0)
|
||||
$response.setForegroundResult = $focusInfo.setForegroundResult
|
||||
}
|
||||
"click" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$targetX = [int]$cmd.x
|
||||
$targetY = [int]$cmd.y
|
||||
# Matches Inventory Kamera's verified-working sequence exactly: bare
|
||||
# SetCursorPos immediately followed by a click, with NO extra move
|
||||
# event and NO artificial delay between moving and clicking - IK's
|
||||
# Navigation.Click(x, y) does SetCursor() then Click() back-to-back,
|
||||
# zero gap. Settling delays only happen after the click, in the scan
|
||||
# loop. Down+up are sent as one SendInput call (see
|
||||
# Send-MouseClickBatch), matching InputSimulator.Mouse.LeftButtonClick().
|
||||
[Native.InputHelper]::SetCursorPos($targetX, $targetY) | Out-Null
|
||||
$point = Get-CursorPoint
|
||||
$onTarget = (([Math]::Abs($targetX - $point.X) -le 2) -and ([Math]::Abs($targetY - $point.Y) -le 2))
|
||||
$clickEventsSent = 0
|
||||
if ($onTarget) {
|
||||
$clickEventsSent = Send-MouseClickBatch
|
||||
}
|
||||
$state = Get-CursorState
|
||||
$response.cursorX = $state.cursorX
|
||||
$response.cursorY = $state.cursorY
|
||||
$response.escapePressed = $state.escapePressed
|
||||
$response.enterPressed = $state.enterPressed
|
||||
$response.f9Pressed = $state.f9Pressed
|
||||
$response.moved = $onTarget
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.alreadyForeground = $focusInfo.alreadyForeground
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
# Never report a click unless the cursor is verifiably on the target.
|
||||
# Real acceptance is proven later by the detail-panel fingerprint.
|
||||
$response.clicked = ($onTarget -and $clickEventsSent -ge 2)
|
||||
$response.inputBlocked = ($onTarget -and $clickEventsSent -lt 2)
|
||||
}
|
||||
"scroll" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
if ($null -ne $cmd.x -and $null -ne $cmd.y) {
|
||||
[Native.InputHelper]::SetCursorPos([int]$cmd.x, [int]$cmd.y) | Out-Null
|
||||
Start-Sleep -Milliseconds 30
|
||||
}
|
||||
$point = Get-CursorPoint
|
||||
$response.cursorX = $point.X
|
||||
$response.cursorY = $point.Y
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$notches = [int]$cmd.notches
|
||||
$stepDelta = 120
|
||||
if ($notches -lt 0) { $stepDelta = -120 }
|
||||
$count = [Math]::Abs($notches)
|
||||
if ($count -gt 60) { $count = 60 }
|
||||
$sentTotal = 0
|
||||
for ($i = 0; $i -lt $count; $i++) {
|
||||
$sentTotal += Send-MouseInput -flags 0x0800 -wheelData $stepDelta
|
||||
Start-Sleep -Milliseconds 45
|
||||
}
|
||||
$response.notchesSent = $sentTotal
|
||||
$response.inputBlocked = (($count -gt 0) -and ($sentTotal -eq 0))
|
||||
}
|
||||
"key" {
|
||||
$focusInfo = Focus-GenshinWindow
|
||||
if ($focusInfo.focused -and -not $focusInfo.alreadyForeground) {
|
||||
Start-Sleep -Milliseconds 120
|
||||
}
|
||||
$vk = Resolve-VirtualKey -key "$($cmd.key)"
|
||||
$sent = Send-KeyPressBatch -virtualKey $vk
|
||||
$response.key = "$($cmd.key)"
|
||||
$response.focused = $focusInfo.focused
|
||||
$response.foregroundProcess = $focusInfo.foregroundProcess
|
||||
$response.targetProcess = $focusInfo.targetProcess
|
||||
$response.isElevated = Get-CurrentProcessElevation
|
||||
$response.eventsSent = $sent
|
||||
$response.inputBlocked = ($sent -lt 2)
|
||||
}
|
||||
"bounds" {
|
||||
$clientBounds = Get-GenshinClientBounds
|
||||
if ($null -eq $clientBounds) {
|
||||
$response.found = $false
|
||||
} else {
|
||||
$response.found = $true
|
||||
$response.left = $clientBounds.Left
|
||||
$response.top = $clientBounds.Top
|
||||
$response.width = $clientBounds.Width
|
||||
$response.height = $clientBounds.Height
|
||||
}
|
||||
}
|
||||
"capture" {
|
||||
$clientBounds = Get-GenshinClientBounds
|
||||
if ($null -eq $clientBounds) {
|
||||
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
|
||||
$clientBounds = @{
|
||||
Left = $screenBounds.Left
|
||||
Top = $screenBounds.Top
|
||||
Width = $screenBounds.Width
|
||||
Height = $screenBounds.Height
|
||||
}
|
||||
$response.captureTarget = "primary-screen"
|
||||
} else {
|
||||
$response.captureTarget = "genshin-client"
|
||||
}
|
||||
$bitmap = New-Object System.Drawing.Bitmap $clientBounds.Width, $clientBounds.Height
|
||||
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
|
||||
$graphics.CopyFromScreen($clientBounds.Left, $clientBounds.Top, 0, 0, $bitmap.Size)
|
||||
$capturePath = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "genshin-assistant-capture-" + [Guid]::NewGuid().ToString() + ".png")
|
||||
$bitmap.Save($capturePath, [System.Drawing.Imaging.ImageFormat]::Png)
|
||||
$graphics.Dispose()
|
||||
$bitmap.Dispose()
|
||||
$response.path = $capturePath
|
||||
$response.width = $clientBounds.Width
|
||||
$response.height = $clientBounds.Height
|
||||
$response.originX = $clientBounds.Left
|
||||
$response.originY = $clientBounds.Top
|
||||
}
|
||||
default {
|
||||
$response.ok = $false
|
||||
$response.error = "unknown op"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$response.ok = $false
|
||||
$response.error = $_.Exception.Message
|
||||
}
|
||||
Write-Output (ConvertTo-Json $response -Compress)
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { inflateSync } from "node:zlib";
|
||||
import type { Bitmap } from "../../src/lib/ocrPreprocess.js";
|
||||
|
||||
interface PngChunk {
|
||||
type: string;
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
function readChunks(buffer: Buffer): PngChunk[] {
|
||||
const signature = buffer.subarray(0, 8);
|
||||
if (!signature.equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
|
||||
throw new Error("Invalid PNG signature.");
|
||||
}
|
||||
const chunks: PngChunk[] = [];
|
||||
let offset = 8;
|
||||
while (offset + 12 <= buffer.length) {
|
||||
const length = buffer.readUInt32BE(offset);
|
||||
const type = buffer.toString("ascii", offset + 4, offset + 8);
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + length;
|
||||
if (dataEnd + 4 > buffer.length) throw new Error("Invalid PNG chunk length.");
|
||||
chunks.push({ type, data: buffer.subarray(dataStart, dataEnd) });
|
||||
offset = dataEnd + 4;
|
||||
if (type === "IEND") break;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function paethPredictor(left: number, up: number, upperLeft: number) {
|
||||
const estimate = left + up - upperLeft;
|
||||
const leftDistance = Math.abs(estimate - left);
|
||||
const upDistance = Math.abs(estimate - up);
|
||||
const upperLeftDistance = Math.abs(estimate - upperLeft);
|
||||
if (leftDistance <= upDistance && leftDistance <= upperLeftDistance) return left;
|
||||
if (upDistance <= upperLeftDistance) return up;
|
||||
return upperLeft;
|
||||
}
|
||||
|
||||
function unfilterScanlines(raw: Buffer, width: number, height: number, bytesPerPixel: number) {
|
||||
const stride = width * bytesPerPixel;
|
||||
const output = Buffer.alloc(stride * height);
|
||||
let rawOffset = 0;
|
||||
for (let row = 0; row < height; row++) {
|
||||
const filter = raw[rawOffset++];
|
||||
const rowOffset = row * stride;
|
||||
const previousRowOffset = rowOffset - stride;
|
||||
for (let col = 0; col < stride; col++) {
|
||||
const value = raw[rawOffset++];
|
||||
const left = col >= bytesPerPixel ? output[rowOffset + col - bytesPerPixel] : 0;
|
||||
const up = row > 0 ? output[previousRowOffset + col] : 0;
|
||||
const upperLeft = row > 0 && col >= bytesPerPixel ? output[previousRowOffset + col - bytesPerPixel] : 0;
|
||||
let restored = value;
|
||||
if (filter === 1) restored = value + left;
|
||||
else if (filter === 2) restored = value + up;
|
||||
else if (filter === 3) restored = value + Math.floor((left + up) / 2);
|
||||
else if (filter === 4) restored = value + paethPredictor(left, up, upperLeft);
|
||||
else if (filter !== 0) throw new Error(`Unsupported PNG filter: ${filter}`);
|
||||
output[rowOffset + col] = restored & 0xff;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function pngBufferToBitmap(buffer: Buffer): Bitmap {
|
||||
const chunks = readChunks(buffer);
|
||||
const ihdr = chunks.find((chunk) => chunk.type === "IHDR")?.data;
|
||||
if (!ihdr) throw new Error("PNG missing IHDR.");
|
||||
const width = ihdr.readUInt32BE(0);
|
||||
const height = ihdr.readUInt32BE(4);
|
||||
const bitDepth = ihdr[8];
|
||||
const colorType = ihdr[9];
|
||||
const compression = ihdr[10];
|
||||
const filter = ihdr[11];
|
||||
const interlace = ihdr[12];
|
||||
if (bitDepth !== 8 || compression !== 0 || filter !== 0 || interlace !== 0) {
|
||||
throw new Error("Unsupported PNG format.");
|
||||
}
|
||||
const sourceBytesPerPixel = colorType === 6 ? 4 : colorType === 2 ? 3 : 0;
|
||||
if (!sourceBytesPerPixel) throw new Error(`Unsupported PNG color type: ${colorType}`);
|
||||
const idat = Buffer.concat(chunks.filter((chunk) => chunk.type === "IDAT").map((chunk) => chunk.data));
|
||||
const unfiltered = unfilterScanlines(inflateSync(idat), width, height, sourceBytesPerPixel);
|
||||
if (colorType === 6) return { data: unfiltered, width, height };
|
||||
|
||||
const rgba = Buffer.alloc(width * height * 4);
|
||||
for (let pixel = 0; pixel < width * height; pixel++) {
|
||||
rgba[pixel * 4] = unfiltered[pixel * 3];
|
||||
rgba[pixel * 4 + 1] = unfiltered[pixel * 3 + 1];
|
||||
rgba[pixel * 4 + 2] = unfiltered[pixel * 3 + 2];
|
||||
rgba[pixel * 4 + 3] = 255;
|
||||
}
|
||||
return { data: rgba, width, height };
|
||||
}
|
||||
Reference in New Issue
Block a user