Merge scanner readiness work
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,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ClickResult,
|
||||
AutomationGuard,
|
||||
ScrollResult,
|
||||
KeyPressResult,
|
||||
SaveResultWithPath,
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
@@ -60,6 +61,7 @@ interface CaptureHandlersDependencies {
|
||||
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||
keyPress: (key: string) => Promise<KeyPressResult>;
|
||||
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||
}
|
||||
|
||||
@@ -96,6 +98,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
captureSource: dependencies.captureSource,
|
||||
clickScreen: dependencies.clickScreen,
|
||||
scrollScreen: dependencies.scrollScreen,
|
||||
keyPress: dependencies.keyPress,
|
||||
getAutomationGuard: dependencies.getAutomationGuard,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import fs from "node:fs/promises";
|
||||
import http, { type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
@@ -9,14 +11,20 @@ import type {
|
||||
ReviewSampleListResult,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
AppRuntimeInfo,
|
||||
} from "../src/types/global.js";
|
||||
import { validateLookupPackage } from "../src/lib/genshinLookup.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
interface DevControlServerDependencies {
|
||||
registeredHotkeys: Record<string, boolean>;
|
||||
appBuild: AppRuntimeInfo;
|
||||
hasMainWindow: () => boolean;
|
||||
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scannerStatus: () => ScannerStatusPayload;
|
||||
warmOcr: (engine: "current" | "ik-traineddata") => Promise<unknown>;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (
|
||||
@@ -25,6 +33,7 @@ interface DevControlServerDependencies {
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult>;
|
||||
requestShutdown?: (reason: string) => void;
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
@@ -104,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,
|
||||
@@ -136,13 +146,30 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() });
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow(), appBuild: deps.appBuild });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/dev/shutdown") {
|
||||
if (!deps.requestShutdown) {
|
||||
writeDevJson(res, 501, { ok: false, error: "shutdown not supported" });
|
||||
return;
|
||||
}
|
||||
const reason = url.searchParams.get("reason") || "dev-control shutdown requested";
|
||||
writeDevJson(res, 200, { ok: true, appBuild: deps.appBuild, reason });
|
||||
setTimeout(() => deps.requestShutdown?.(reason), 50);
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const command: ScannerCommand = Number.isFinite(limit) && limit > 0
|
||||
? { type: "start-auto", scanLimit: limit }
|
||||
const entry = url.searchParams.get("entry");
|
||||
const engine = url.searchParams.get("engine");
|
||||
const scanEntryMode = entry === "paimon-menu" || entry === "visible-inventory" || entry === "direct-inventory" || entry === "auto-entry"
|
||||
? entry
|
||||
: undefined;
|
||||
const ocrEngine = engine === "ik-traineddata" ? "ik-traineddata" : engine === "current" ? "current" : undefined;
|
||||
const hasLimit = Number.isFinite(limit) && limit > 0;
|
||||
const command: ScannerCommand = hasLimit || scanEntryMode || ocrEngine
|
||||
? { type: "start-auto", scanLimit: hasLimit ? limit : undefined, scanEntryMode, ocrEngine }
|
||||
: "start-auto";
|
||||
deps.sendScannerCommand(command);
|
||||
writeDevJson(res, 200, { ok: true, command });
|
||||
@@ -174,6 +201,157 @@ export function createDevControlServer(deps: DevControlServerDependencies): Serv
|
||||
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/ocr/warmup") {
|
||||
const engineParam = url.searchParams.get("engine");
|
||||
const engine = engineParam === "ik-traineddata" ? "ik-traineddata" : "current";
|
||||
deps.warmOcr(engine)
|
||||
.then((status) => writeDevJson(res, 200, { ok: true, status }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/lookup/status") {
|
||||
const status = validateLookupPackage();
|
||||
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/lookup/regenerate") {
|
||||
execFileAsync("node", ["scripts/generate-genshin-data.cjs"], { cwd: process.cwd(), windowsHide: true, timeout: 120000 })
|
||||
.then(({ stdout, stderr }) => {
|
||||
const status = validateLookupPackage();
|
||||
writeDevJson(res, status.valid ? 200 : 409, { ok: status.valid, status, stdout, stderr });
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/benchmark-ocr") {
|
||||
const limit = Math.max(1, Math.min(100, Number(url.searchParams.get("limit") ?? 1) || 1));
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const engineParam = url.searchParams.get("engine");
|
||||
const profileParam = url.searchParams.get("profile");
|
||||
const ocrProfile: "full" | "fast" = profileParam === "full" ? "full" : "fast";
|
||||
const engines: Array<"current" | "ik-traineddata"> = engineParam === "compare"
|
||||
? ["current", "ik-traineddata"]
|
||||
: engineParam === "ik-traineddata"
|
||||
? ["ik-traineddata"]
|
||||
: ["current"];
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
const benchmarkSource = source;
|
||||
|
||||
async function runEngineBenchmark(engine: "current" | "ik-traineddata") {
|
||||
const startedAt = Date.now();
|
||||
const captures: Array<{
|
||||
index: number;
|
||||
elapsedMs: number;
|
||||
ocrFields: number;
|
||||
timedOut: boolean;
|
||||
ocrSkipped: boolean;
|
||||
artifactDetailConfidence: number;
|
||||
sanctified: boolean;
|
||||
prepareMs: number;
|
||||
ocrMs: number;
|
||||
totalMs: number;
|
||||
ocrProfile?: "full" | "fast";
|
||||
ocrWorkerPoolSize?: number;
|
||||
ocrFieldMs?: Record<string, number>;
|
||||
}> = [];
|
||||
for (let index = 0; index < limit; index += 1) {
|
||||
const captureStartedAt = Date.now();
|
||||
const capture = await deps.captureSource(benchmarkSource.id, index === 0 ? 150 : 0, true, {
|
||||
ocrMode: "artifact",
|
||||
ocrProfile,
|
||||
ocrEngine: engine,
|
||||
omitFullFrame: true,
|
||||
omitInventoryPreview: true,
|
||||
skipOcrUnlessArtifactDetail: true,
|
||||
});
|
||||
captures.push({
|
||||
index,
|
||||
elapsedMs: Date.now() - captureStartedAt,
|
||||
ocrFields: capture.ocr?.length ?? 0,
|
||||
timedOut: Boolean(capture.ocrTimedOut),
|
||||
ocrSkipped: Boolean(capture.ocrSkipped),
|
||||
artifactDetailConfidence: capture.artifactDetail?.confidence ?? 0,
|
||||
sanctified: Boolean(capture.sanctified),
|
||||
prepareMs: capture.timings?.prepareMs ?? 0,
|
||||
ocrMs: capture.timings?.ocrMs ?? 0,
|
||||
totalMs: capture.timings?.totalMs ?? 0,
|
||||
ocrProfile: capture.timings?.ocrProfile,
|
||||
ocrWorkerPoolSize: capture.timings?.ocrWorkerPoolSize,
|
||||
ocrFieldMs: capture.timings?.ocrFieldMs,
|
||||
});
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const timings = captures.map((capture) => capture.elapsedMs).sort((left, right) => left - right);
|
||||
const ocrTimings = captures.map((capture) => capture.ocrMs).filter((value) => value > 0).sort((left, right) => left - right);
|
||||
const averageMs = Math.round(elapsedMs / limit);
|
||||
const averageOcrMs = ocrTimings.length > 0
|
||||
? Math.round(ocrTimings.reduce((total, value) => total + value, 0) / ocrTimings.length)
|
||||
: 0;
|
||||
const percentile = (ratio: number) => timings[Math.min(timings.length - 1, Math.max(0, Math.ceil(timings.length * ratio) - 1))] ?? 0;
|
||||
const ocrPercentile = (ratio: number) => ocrTimings[Math.min(ocrTimings.length - 1, Math.max(0, Math.ceil(ocrTimings.length * ratio) - 1))] ?? 0;
|
||||
const ocrFieldTotals = captures.reduce<Record<string, { totalMs: number; count: number; maxMs: number }>>((fields, capture) => {
|
||||
for (const [field, elapsed] of Object.entries(capture.ocrFieldMs ?? {})) {
|
||||
const current = fields[field] ?? { totalMs: 0, count: 0, maxMs: 0 };
|
||||
current.totalMs += elapsed;
|
||||
current.count += 1;
|
||||
current.maxMs = Math.max(current.maxMs, elapsed);
|
||||
fields[field] = current;
|
||||
}
|
||||
return fields;
|
||||
}, {});
|
||||
const ocrFieldAverages = Object.fromEntries(
|
||||
Object.entries(ocrFieldTotals).map(([field, timing]) => [
|
||||
field,
|
||||
{
|
||||
averageMs: Math.round(timing.totalMs / Math.max(1, timing.count)),
|
||||
maxMs: timing.maxMs,
|
||||
count: timing.count,
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
engine,
|
||||
nativeTesseract: "not-enabled",
|
||||
workerPoolSize: captures.find((capture) => capture.ocrWorkerPoolSize)?.ocrWorkerPoolSize ?? null,
|
||||
ocrProfile,
|
||||
limit,
|
||||
elapsedMs,
|
||||
averageMs,
|
||||
averageOcrMs,
|
||||
minMs: timings[0] ?? 0,
|
||||
p50Ms: percentile(0.5),
|
||||
p90Ms: percentile(0.9),
|
||||
maxMs: timings[timings.length - 1] ?? 0,
|
||||
ocrP50Ms: ocrPercentile(0.5),
|
||||
ocrP90Ms: ocrPercentile(0.9),
|
||||
ocrFieldAverages,
|
||||
projectedMs: {
|
||||
artifacts20: averageMs * 20,
|
||||
artifacts45: averageMs * 45,
|
||||
artifacts100: averageMs * 100,
|
||||
},
|
||||
skippedOcrCaptures: captures.filter((capture) => capture.ocrSkipped).length,
|
||||
captures,
|
||||
};
|
||||
}
|
||||
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 },
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ipcMain } from "electron";
|
||||
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard } from "../../src/types/global.js";
|
||||
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard, KeyPressResult } from "../../src/types/global.js";
|
||||
|
||||
interface CaptureCommandDependencies {
|
||||
listSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions) => Promise<CaptureResult>;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => Promise<ScrollResult>;
|
||||
keyPress: (key: string) => Promise<KeyPressResult>;
|
||||
getAutomationGuard: () => Promise<AutomationGuard>;
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ export function registerCaptureHandlers({
|
||||
captureSource,
|
||||
clickScreen,
|
||||
scrollScreen,
|
||||
keyPress,
|
||||
getAutomationGuard,
|
||||
}: CaptureCommandDependencies) {
|
||||
ipcMain.handle("capture:listSources", async () => listSources());
|
||||
@@ -22,5 +24,6 @@ export function registerCaptureHandlers({
|
||||
});
|
||||
ipcMain.handle("automation:clickScreen", async (_event, x: number, y: number) => clickScreen(x, y));
|
||||
ipcMain.handle("automation:scrollScreen", async (_event, notches: number, anchorX?: number, anchorY?: number) => scrollScreen(notches, anchorX, anchorY));
|
||||
ipcMain.handle("automation:keyPress", async (_event, key: string) => keyPress(key));
|
||||
ipcMain.handle("automation:getGuard", async () => getAutomationGuard());
|
||||
}
|
||||
|
||||
+701
-229
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
captureSource: (sourceId, delayMs = 0, focusGenshin = false, options) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
|
||||
clickScreen: (x, y) => ipcRenderer.invoke("automation:clickScreen", x, y),
|
||||
scrollScreen: (notches, anchorX, anchorY) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
|
||||
keyPress: (key) => ipcRenderer.invoke("automation:keyPress", key),
|
||||
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
|
||||
@@ -11,6 +11,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
captureSource: (sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => ipcRenderer.invoke("capture:captureSource", sourceId, delayMs, focusGenshin, options),
|
||||
clickScreen: (x: number, y: number) => ipcRenderer.invoke("automation:clickScreen", x, y),
|
||||
scrollScreen: (notches: number, anchorX?: number, anchorY?: number) => ipcRenderer.invoke("automation:scrollScreen", notches, anchorX, anchorY),
|
||||
keyPress: (key: string) => ipcRenderer.invoke("automation:keyPress", key),
|
||||
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
|
||||
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
|
||||
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
|
||||
|
||||
@@ -3,6 +3,9 @@ import path from "node:path";
|
||||
import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js";
|
||||
import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js";
|
||||
|
||||
const SMALL_FILE_LIMIT_BYTES = 8 * 1024 * 1024;
|
||||
const TAIL_READ_LIMIT_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
|
||||
private readonly filePath: string;
|
||||
|
||||
@@ -12,9 +15,12 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
|
||||
|
||||
async list(limit = 50): Promise<ReviewSampleListResult> {
|
||||
try {
|
||||
const raw = await fs.readFile(this.filePath, "utf8");
|
||||
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||
const safeLimit = Math.max(1, Math.min(200, Number(limit) || 50));
|
||||
const stats = await fs.stat(this.filePath);
|
||||
const raw = stats.size <= SMALL_FILE_LIMIT_BYTES
|
||||
? await fs.readFile(this.filePath, "utf8")
|
||||
: await readTailText(this.filePath, stats.size, TAIL_READ_LIMIT_BYTES);
|
||||
const lines = raw.split(/\r?\n/).filter(Boolean);
|
||||
const samples = lines
|
||||
.slice(-safeLimit)
|
||||
.map((line) => {
|
||||
@@ -25,7 +31,8 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
|
||||
}
|
||||
})
|
||||
.filter(Boolean) as ReviewSampleRecord[];
|
||||
return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath };
|
||||
const total = stats.size <= SMALL_FILE_LIMIT_BYTES ? lines.length : Math.max(samples.length, lines.length);
|
||||
return { ok: true, samples: samples.reverse(), total, path: this.filePath };
|
||||
} catch {
|
||||
return { ok: true, samples: [], total: 0, path: this.filePath };
|
||||
}
|
||||
@@ -37,3 +44,17 @@ export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
|
||||
return { ok: true, path: this.filePath };
|
||||
}
|
||||
}
|
||||
|
||||
async function readTailText(filePath: string, fileSize: number, maxBytes: number) {
|
||||
const bytesToRead = Math.min(fileSize, maxBytes);
|
||||
const handle = await fs.open(filePath, "r");
|
||||
try {
|
||||
const buffer = Buffer.alloc(bytesToRead);
|
||||
await handle.read(buffer, 0, bytesToRead, fileSize - bytesToRead);
|
||||
const text = buffer.toString("utf8");
|
||||
const firstNewline = text.indexOf("\n");
|
||||
return fileSize > bytesToRead && firstNewline >= 0 ? text.slice(firstNewline + 1) : text;
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -7,408 +7,13 @@ import type {
|
||||
FocusGenshinResult,
|
||||
GdiCaptureResult,
|
||||
HelperOperationResponse,
|
||||
KeyPressResult,
|
||||
WindowBounds,
|
||||
RuntimeInfo,
|
||||
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 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))
|
||||
}
|
||||
"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;
|
||||
@@ -545,6 +150,7 @@ export interface InputHelperService {
|
||||
getGenshinWindowBounds(): Promise<WindowBounds | null>;
|
||||
clickScreen(x: number, y: number): Promise<ClickResult>;
|
||||
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
|
||||
keyPress(key: string): Promise<KeyPressResult>;
|
||||
getAutomationGuard(): Promise<AutomationGuard>;
|
||||
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
|
||||
dispose(): void;
|
||||
@@ -641,6 +247,20 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
};
|
||||
}
|
||||
|
||||
async function keyPress(key: string) {
|
||||
const result = (await request("key", { key }, 8000)) as HelperOperationResponse;
|
||||
return {
|
||||
ok: Boolean(result.ok) && Number(result.eventsSent ?? 0) >= 2,
|
||||
key,
|
||||
focused: Boolean(result.focused),
|
||||
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
|
||||
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
|
||||
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
|
||||
inputBlocked: Boolean(result.inputBlocked),
|
||||
eventsSent: Number(result.eventsSent ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function getAutomationGuard() {
|
||||
const result = await request("cursor", {}, 4000);
|
||||
return {
|
||||
@@ -688,6 +308,7 @@ export function createInputHelperService(options: { userDataPath: string; exePat
|
||||
getGenshinWindowBounds,
|
||||
clickScreen,
|
||||
scrollScreen,
|
||||
keyPress,
|
||||
getAutomationGuard,
|
||||
capturePrimaryScreenViaGdi,
|
||||
dispose: () => inputHelper.dispose(),
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
// 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