chore: initialize repository baseline

Import the existing Electron + React + TypeScript app as the version-control
baseline before the scanner rework (C# input/capture sidecar, resolution-anchored
layout profiles, OCR preprocessing, eval harness, rescan-merge, GOOD interop).

Housekeeping in this commit:
- Remove orphaned temp_inputhelper_block.ts (duplicate of the input-helper script).
- Ignore .claude/scheduled_tasks.lock local session state.
- Add .gitattributes to normalize line endings (LF in repo).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
AzuTear
2026-07-05 20:31:01 +02:00
commit e76d88e0c7
147 changed files with 26220 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from "./ipcBootstrap.js";
export * from "./repositoryContext.js";
+98
View File
@@ -0,0 +1,98 @@
import { registerAppHandlers } from "../ipc/appHandlers.js";
import { registerCaptureHandlers } from "../ipc/captureHandlers.js";
import { registerPersistenceHandlers } from "../ipc/persistenceHandlers.js";
import type {
BooleanResult,
FocusGenshinResult,
RuntimeInfo,
LoadScannerLearningRulesResult,
SaveScannerLearningRulesResult,
ScannerLearningRulePayload,
CaptureOptions,
CaptureResult,
CaptureSourceInfo,
ClickResult,
AutomationGuard,
ScrollResult,
SaveResultWithPath,
SaveSnapshotResult,
GoodDatabase,
ScannerStatusPayload,
} from "../../src/types/global.js";
import type {
ArtifactStoreRepositoryPort,
ReviewSamplesRepositoryPort,
ReviewSampleListResult,
} from "../repositories/contracts.js";
import type { AppSnapshot } from "../../src/types/domain.js";
interface AppHandlersDependencies {
focusMainWindow: () => BooleanResult;
moveMainWindowOffGenshin: () => Promise<void>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
readRuntimeInfo: () => Promise<RuntimeInfo>;
loadSnapshotFromDisk: () => Promise<AppSnapshot | null>;
saveSnapshotToDisk: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
runMockScan: () => Promise<AppSnapshot | null>;
showOverlayWindow: () => Promise<BooleanResult>;
hideOverlayWindow: () => Promise<BooleanResult>;
}
type ArtifactStoreAccessor = () => ArtifactStoreRepositoryPort;
type ReviewSamplesAccessor = () => ReviewSamplesRepositoryPort;
interface PersistenceHandlersDependencies {
getArtifactStoreRepository: ArtifactStoreAccessor;
getReviewSamplesRepository: ReviewSamplesAccessor;
artifactStorePath: () => string;
reviewSamplesPath: () => string;
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
}
interface CaptureHandlersDependencies {
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>;
getAutomationGuard: () => Promise<AutomationGuard>;
}
interface IpcBootstrapDependencies extends AppHandlersDependencies, PersistenceHandlersDependencies, CaptureHandlersDependencies {}
export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
registerAppHandlers({
focusMainWindow: dependencies.focusMainWindow,
moveMainWindowOffGenshin: dependencies.moveMainWindowOffGenshin,
focusGenshinForScanStart: dependencies.focusGenshinForScanStart,
publishScannerStatus: dependencies.publishScannerStatus,
getRuntimeInfo: dependencies.readRuntimeInfo,
loadSnapshot: dependencies.loadSnapshotFromDisk,
saveSnapshot: dependencies.saveSnapshotToDisk,
runMockScan: dependencies.runMockScan,
showOverlay: dependencies.showOverlayWindow,
hideOverlay: dependencies.hideOverlayWindow,
});
registerPersistenceHandlers({
getArtifactStoreRepository: dependencies.getArtifactStoreRepository,
getReviewSamplesRepository: dependencies.getReviewSamplesRepository,
artifactStorePath: dependencies.artifactStorePath,
reviewSamplesPath: dependencies.reviewSamplesPath,
loadReviewSamples: dependencies.loadReviewSamples,
loadScannerLearningRules: dependencies.loadScannerLearningRules,
writeScannerLearningRules: dependencies.writeScannerLearningRules,
exportGood: dependencies.exportGood,
});
registerCaptureHandlers({
listSources: dependencies.listSources,
captureSource: dependencies.captureSource,
clickScreen: dependencies.clickScreen,
scrollScreen: dependencies.scrollScreen,
getAutomationGuard: dependencies.getAutomationGuard,
});
}
+40
View File
@@ -0,0 +1,40 @@
import {
JsonArtifactStoreRepository,
JsonSnapshotRepository,
ReviewSamplesRepository,
ScannerLearningRepository,
type ArtifactStoreRepositoryPort,
type ReviewSamplesRepositoryPort,
type ScannerLearningRepositoryPort,
type SnapshotRepositoryPort,
} from "../repositories/index.js";
import path from "node:path";
export interface RepositoryContext {
artifactStorePath: string;
reviewSamplesPath: string;
scannerLearningPath: string;
snapshotPath: string;
artifactStoreRepository: ArtifactStoreRepositoryPort;
reviewSamplesRepository: ReviewSamplesRepositoryPort;
scannerLearningRepository: ScannerLearningRepositoryPort;
snapshotRepository: SnapshotRepositoryPort;
}
export function createRepositoryContext(userDataPath: string): RepositoryContext {
const artifactStorePath = path.join(userDataPath, "artifact-store.json");
const reviewSamplesPath = path.join(userDataPath, "review-samples.jsonl");
const scannerLearningPath = path.join(userDataPath, "scanner-learning.json");
const snapshotPath = path.join(userDataPath, "snapshot.json");
return {
artifactStorePath,
reviewSamplesPath,
scannerLearningPath,
snapshotPath,
artifactStoreRepository: new JsonArtifactStoreRepository(userDataPath),
reviewSamplesRepository: new ReviewSamplesRepository(userDataPath),
scannerLearningRepository: new ScannerLearningRepository(userDataPath),
snapshotRepository: new JsonSnapshotRepository(userDataPath),
};
}
+51
View File
@@ -0,0 +1,51 @@
import { ipcMain } from "electron";
import type {
BooleanResult,
FocusGenshinResult,
RuntimeInfo,
SaveSnapshotResult,
ScannerStatusPayload,
} from "../../src/types/global.js";
import type { AppSnapshot } from "../../src/types/domain.js";
interface AppCommandDependencies {
focusMainWindow: () => BooleanResult;
moveMainWindowOffGenshin: () => Promise<void>;
focusGenshinForScanStart: () => Promise<FocusGenshinResult>;
publishScannerStatus: (status: ScannerStatusPayload) => Promise<BooleanResult>;
getRuntimeInfo: () => Promise<RuntimeInfo>;
loadSnapshot: () => Promise<AppSnapshot | null>;
saveSnapshot: (snapshot: AppSnapshot) => Promise<SaveSnapshotResult>;
runMockScan: () => Promise<AppSnapshot | null>;
showOverlay: () => Promise<BooleanResult>;
hideOverlay: () => Promise<BooleanResult>;
}
export function registerAppHandlers({
focusMainWindow,
moveMainWindowOffGenshin,
focusGenshinForScanStart,
publishScannerStatus,
getRuntimeInfo,
loadSnapshot,
saveSnapshot,
runMockScan,
showOverlay,
hideOverlay,
}: AppCommandDependencies) {
ipcMain.handle("app:focusMainWindow", async () => focusMainWindow());
ipcMain.handle("automation:focusGenshin", async () => {
await moveMainWindowOffGenshin();
return focusGenshinForScanStart();
});
ipcMain.handle("scanner:publishStatus", async (_event, status: ScannerStatusPayload) => {
await publishScannerStatus(status);
return { ok: true };
});
ipcMain.handle("app:getRuntimeInfo", async () => getRuntimeInfo());
ipcMain.handle("snapshot:load", async () => loadSnapshot());
ipcMain.handle("snapshot:save", async (_event, snapshot: AppSnapshot) => saveSnapshot(snapshot));
ipcMain.handle("scan:runMock", async () => runMockScan());
ipcMain.handle("overlay:show", () => showOverlay());
ipcMain.handle("overlay:hide", () => hideOverlay());
}
+26
View File
@@ -0,0 +1,26 @@
import { ipcMain } from "electron";
import type { CaptureOptions, CaptureResult, CaptureSourceInfo, ClickResult, ScrollResult, AutomationGuard } 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>;
getAutomationGuard: () => Promise<AutomationGuard>;
}
export function registerCaptureHandlers({
listSources,
captureSource,
clickScreen,
scrollScreen,
getAutomationGuard,
}: CaptureCommandDependencies) {
ipcMain.handle("capture:listSources", async () => listSources());
ipcMain.handle("capture:captureSource", async (_event, sourceId: string, delayMs = 0, focusGenshin = false, options?: CaptureOptions) => {
return captureSource(sourceId, delayMs, focusGenshin, options);
});
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:getGuard", async () => getAutomationGuard());
}
+84
View File
@@ -0,0 +1,84 @@
import { ipcMain } from "electron";
import type {
ArtifactStoreLoadResult,
ArtifactStoreRepositoryPort,
ArtifactStoreSaveResult,
ReviewSampleListResult,
ReviewSamplePayload,
ReviewSamplesRepositoryPort,
} from "../repositories/contracts.js";
import type {
GoodDatabase,
LoadScannerLearningRulesResult,
SaveScannerLearningRulesResult,
ScannerLearningRulePayload,
SaveResultWithPath,
} from "../../src/types/global.js";
import type { StoredArtifactRecord } from "../../src/types/storage.js";
type ArtifactStoreAccessor = () => ArtifactStoreRepositoryPort;
type ReviewSamplesAccessor = () => ReviewSamplesRepositoryPort;
interface PersistenceDependencies {
getArtifactStoreRepository: ArtifactStoreAccessor;
getReviewSamplesRepository: ReviewSamplesAccessor;
artifactStorePath: () => string;
reviewSamplesPath: () => string;
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
}
export function registerPersistenceHandlers({
getArtifactStoreRepository,
getReviewSamplesRepository,
artifactStorePath,
reviewSamplesPath,
loadReviewSamples,
loadScannerLearningRules,
writeScannerLearningRules,
exportGood,
}: PersistenceDependencies) {
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
try {
return await getReviewSamplesRepository().append(sample);
} catch {
const filePath = reviewSamplesPath();
return { ok: false, path: filePath };
}
});
ipcMain.handle("review:loadSamples", async (_event, limit = 50) => {
return loadReviewSamples(Number(limit) || 50);
});
ipcMain.handle("scanner:loadLearningRules", async () => {
return loadScannerLearningRules();
});
ipcMain.handle("scanner:saveLearningRules", async (_event, rules: ScannerLearningRulePayload) => {
return writeScannerLearningRules(rules);
});
ipcMain.handle("artifacts:load", async () => {
try {
return (await getArtifactStoreRepository().loadAll()) as ArtifactStoreLoadResult;
} catch {
return { ok: false, artifacts: [], total: 0, path: artifactStorePath() };
}
});
ipcMain.handle("artifacts:saveMany", async (_event, records: StoredArtifactRecord[]) => {
try {
const safeRecords = Array.isArray(records) ? records : [];
return (await getArtifactStoreRepository().saveMany(safeRecords)) as ArtifactStoreSaveResult;
} catch {
return { ok: false, added: 0, updated: 0, total: 0, path: artifactStorePath() };
}
});
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
return exportGood(payload);
});
}
+1097
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("assistantApi", {
loadSnapshot: () => ipcRenderer.invoke("snapshot:load"),
saveSnapshot: (snapshot) => ipcRenderer.invoke("snapshot:save", snapshot),
runMockScan: () => ipcRenderer.invoke("scan:runMock"),
listCaptureSources: () => ipcRenderer.invoke("capture:listSources"),
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),
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
saveReviewSample: (sample) => ipcRenderer.invoke("review:saveSample", sample),
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
loadScannerLearningRules: () => ipcRenderer.invoke("scanner:loadLearningRules"),
saveScannerLearningRules: (rules) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
showOverlay: () => ipcRenderer.invoke("overlay:show"),
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
onScannerCommand: (callback) => {
const listener = (_event, command) => callback(command);
ipcRenderer.on("scanner:command", listener);
return () => ipcRenderer.removeListener("scanner:command", listener);
},
});
+33
View File
@@ -0,0 +1,33 @@
import { contextBridge, ipcRenderer } from "electron";
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
import type { StoredArtifactRecord } from "../src/types/storage.js";
import type { AppSnapshot } from "../src/types/domain.js";
contextBridge.exposeInMainWorld("assistantApi", {
loadSnapshot: () => ipcRenderer.invoke("snapshot:load"),
saveSnapshot: (snapshot: AppSnapshot) => ipcRenderer.invoke("snapshot:save", snapshot),
runMockScan: () => ipcRenderer.invoke("scan:runMock"),
listCaptureSources: () => ipcRenderer.invoke("capture:listSources"),
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),
getAutomationGuard: () => ipcRenderer.invoke("automation:getGuard"),
focusMainWindow: () => ipcRenderer.invoke("app:focusMainWindow"),
focusGenshin: () => ipcRenderer.invoke("automation:focusGenshin"),
getRuntimeInfo: () => ipcRenderer.invoke("app:getRuntimeInfo"),
saveReviewSample: (sample: ReviewSamplePayload) => ipcRenderer.invoke("review:saveSample", sample),
loadReviewSamples: (limit = 50) => ipcRenderer.invoke("review:loadSamples", limit),
loadScannerLearningRules: () => ipcRenderer.invoke("scanner:loadLearningRules"),
saveScannerLearningRules: (rules: ScannerLearningRulePayload) => ipcRenderer.invoke("scanner:saveLearningRules", rules),
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
publishScannerStatus: (status: ScannerStatusPayload) => ipcRenderer.invoke("scanner:publishStatus", status),
showOverlay: () => ipcRenderer.invoke("overlay:show"),
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
onScannerCommand: (callback: (command: "start-auto" | "stop") => void) => {
const listener = (_event: Electron.IpcRendererEvent, command: "start-auto" | "stop") => callback(command);
ipcRenderer.on("scanner:command", listener);
return () => ipcRenderer.removeListener("scanner:command", listener);
},
});
@@ -0,0 +1,192 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { StoredArtifactRecord } from "../../src/types/storage.js";
import { isReviewOnlyArtifactSource, resolveStoredArtifactSource } from "../../src/lib/artifactStore.js";
import type { ArtifactStoreLoadResult, ArtifactStoreRepositoryPort, ArtifactStoreSaveResult } from "./contracts.js";
interface ArtifactStoreFile {
version?: number;
artifacts?: StoredArtifactRecord[];
}
export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort {
private readonly filePath: string;
constructor(userDataPath: string, fileName = "artifact-store.json") {
this.filePath = path.join(userDataPath, fileName);
}
async loadAll(): Promise<ArtifactStoreLoadResult> {
const records = await this.loadMap();
const artifacts = [...records.values()].sort((a, b) => (b.lastSeenAt ?? "").localeCompare(a.lastSeenAt ?? ""));
return { ok: true, artifacts, total: artifacts.length, path: this.filePath };
}
async loadMap(): Promise<Map<string, StoredArtifactRecord>> {
try {
const raw = JSON.parse(await fs.readFile(this.filePath, "utf8")) as ArtifactStoreFile;
const records = Array.isArray(raw.artifacts) ? raw.artifacts : [];
const cleaned = records
.filter((record) => record?.id && !isObviousGarbageRecord(record))
.map((record) => normalizeStoredArtifactRecordForLoad(record));
const store = new Map(cleaned.map((record) => [record.id, record]));
const storeChanged = cleaned.length !== records.length || cleaned.some((record, index) => JSON.stringify(record) !== JSON.stringify(records[index]));
if (storeChanged) await this.writeRecords([...store.values()]);
return store;
} catch {
return new Map();
}
}
async saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult> {
const store = await this.loadMap();
const now = new Date().toISOString();
let added = 0;
let updated = 0;
for (const record of records ?? []) {
if (!record?.id || isObviousGarbageRecord(record)) continue;
const existing = store.get(record.id);
if (existing) {
store.set(record.id, {
...existing,
...record,
firstSeenAt: existing.firstSeenAt ?? now,
lastSeenAt: now,
timesSeen: (existing.timesSeen ?? 1) + 1,
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
// A later confident scan clears the review flag; an uncertain rescan
// must not downgrade an already confirmed artifact.
needsReview: Boolean(existing.needsReview) && Boolean(record.needsReview),
source: resolveStoredArtifactSource(existing.source, record.source),
});
updated++;
} else {
const mergeCandidate = [...store.values()].find((candidate) => shouldMergeArtifactRecords(candidate, record));
if (mergeCandidate) {
const merged = mergeArtifactRecords(mergeCandidate, record, now);
if (mergeCandidate.id !== merged.id) store.delete(mergeCandidate.id);
store.set(merged.id, merged);
updated++;
} else {
store.set(record.id, { ...record, firstSeenAt: now, lastSeenAt: now, timesSeen: 1 });
added++;
}
}
}
await this.writeRecords([...store.values()]);
return { ok: true, added, updated, total: store.size, path: this.filePath };
}
private async writeRecords(records: StoredArtifactRecord[]) {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify({ version: 1, artifacts: records }, null, 2), "utf8");
}
}
function artifactMergeKey(record: StoredArtifactRecord) {
return [record.name, record.slot, record.setName, record.mainStat, record.mainValue, record.level ?? ""]
.map((value) => `${value ?? ""}`.trim().toLowerCase())
.join("::");
}
function artifactFamilyKey(record: StoredArtifactRecord) {
return [record.name, record.slot, record.setName, record.mainStat]
.map((value) => `${value ?? ""}`.trim().toLowerCase())
.join("::");
}
function artifactQualityScore(record: StoredArtifactRecord) {
const corePenalty =
(record.name === "Unknown artifact" ? 40 : 0)
+ (record.slot === "Unknown slot" ? 35 : 0)
+ (record.setName === "Unknown set" ? 35 : 0)
+ (record.mainStat === "Unknown main stat" ? 40 : 0)
+ (record.mainValue === "?" ? 20 : 0);
return (record.confidence ?? 0)
+ Math.min(20, (record.substats?.length ?? 0) * 5)
+ (record.needsReview ? -12 : 8)
+ (record.equipped && record.equipped !== "Not detected" ? 2 : 0)
- corePenalty;
}
function isObviousGarbageRecord(record: StoredArtifactRecord) {
return (
!record?.id
|| record.name === "Unknown artifact"
|| record.slot === "Unknown slot"
|| record.setName === "Unknown set"
|| record.mainStat === "Unknown main stat"
|| record.mainValue === "?"
|| (record.substats?.length ?? 0) === 0
|| (record.confidence ?? 0) < 30
);
}
function parseArtifactNumericValue(value: string) {
const numeric = Number.parseFloat(String(value ?? "").replace(/,/g, "").replace("%", "").trim());
return Number.isFinite(numeric) ? numeric : 0;
}
function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) {
const normalizedTimesSeen = Math.max(1, Math.round(record.timesSeen ?? 1));
const reviewOnly = isReviewOnlyArtifactSource(record.source);
return {
...record,
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
};
}
function shouldMergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord) {
if (existing.id === incoming.id) return true;
const overlap = (incoming.substats ?? []).filter((substat: string) => (existing.substats ?? []).includes(substat)).length;
const overlapThreshold = Math.min(2, Math.min(existing.substats?.length ?? 0, incoming.substats?.length ?? 0));
const qualityGap = Math.abs(artifactQualityScore(existing) - artifactQualityScore(incoming)) >= 8;
if (artifactMergeKey(existing) === artifactMergeKey(incoming)) {
return (
overlap >= overlapThreshold
|| existing.needsReview
|| incoming.needsReview
|| (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0)
|| qualityGap
);
}
if (artifactFamilyKey(existing) !== artifactFamilyKey(incoming)) return false;
const sameOrBetterLevel = (incoming.level ?? 0) >= (existing.level ?? 0);
const sameOrBetterMainValue = parseArtifactNumericValue(incoming.mainValue) >= parseArtifactNumericValue(existing.mainValue);
return sameOrBetterLevel && sameOrBetterMainValue && (
overlap >= overlapThreshold
|| existing.needsReview
|| incoming.needsReview
|| (existing.substats?.length ?? 0) !== (incoming.substats?.length ?? 0)
|| qualityGap
);
}
function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredArtifactRecord, now: string): StoredArtifactRecord {
const incomingPreferred = artifactQualityScore(incoming) >= artifactQualityScore(existing);
const preferred = incomingPreferred ? incoming : existing;
const secondary = incomingPreferred ? existing : incoming;
const preferredSubstats = (preferred.substats?.length ?? 0) >= (secondary.substats?.length ?? 0) ? preferred.substats : secondary.substats;
return {
...secondary,
...preferred,
id: incomingPreferred ? incoming.id : existing.id,
level: Math.max(existing.level ?? 0, incoming.level ?? 0),
substats: [...(preferredSubstats ?? [])],
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
source: resolveStoredArtifactSource(existing.source, incoming.source),
firstSeenAt: existing.firstSeenAt ?? now,
lastSeenAt: now,
timesSeen: (existing.timesSeen ?? 1) + 1,
};
}
+77
View File
@@ -0,0 +1,77 @@
import type {
AutomationGuard,
CaptureOptions,
CaptureResult,
CaptureSourceInfo,
ClickResult,
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
ReviewSampleListResult,
ReviewSamplePayload,
LoadScannerLearningRulesResult,
SaveScannerLearningRulesResult,
SaveResultWithPath,
FocusGenshinResult,
RuntimeInfo,
BooleanResult,
ScrollResult,
ScannerStatusPayload,
ScannerLearningRulePayload,
} from "../../src/types/global.js";
import type { StoredArtifactRecord } from "../../src/types/storage.js";
import type { AppSnapshot } from "../../src/types/domain.js";
export type {
ArtifactStoreLoadResult,
ArtifactStoreSaveResult,
ReviewSampleListResult,
ReviewSamplePayload,
LoadScannerLearningRulesResult,
SaveScannerLearningRulesResult,
} from "../../src/types/global.js";
export interface ArtifactStoreRepositoryPort {
loadAll(): Promise<ArtifactStoreLoadResult>;
loadMap(): Promise<Map<string, StoredArtifactRecord>>;
saveMany(records: StoredArtifactRecord[]): Promise<ArtifactStoreSaveResult>;
}
export interface ReviewSamplesRepositoryPort {
list(limit?: number): Promise<ReviewSampleListResult>;
append(sample: ReviewSamplePayload): Promise<SaveResultWithPath>;
}
export type ScannerLearningRules = ScannerLearningRulePayload;
export interface ScannerLearningLoadResult extends LoadScannerLearningRulesResult {}
export interface ScannerLearningSaveResult extends SaveScannerLearningRulesResult {}
export interface ScannerLearningRepositoryPort {
load(): Promise<ScannerLearningLoadResult>;
save(rules: ScannerLearningRules): Promise<ScannerLearningSaveResult>;
}
export interface SnapshotRepositoryPort {
load(): Promise<AppSnapshot | null>;
save(snapshot: AppSnapshot): Promise<SaveResultWithPath>;
}
export interface RuntimeRepositoryPort {
focusMainWindow(): Promise<BooleanResult>;
moveMainWindowOffGenshin(): Promise<void>;
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
publishScannerStatus(status: ScannerStatusPayload): Promise<BooleanResult>;
getRuntimeInfo(): Promise<RuntimeInfo>;
showOverlay: () => Promise<BooleanResult>;
hideOverlay: () => Promise<BooleanResult>;
}
export interface AutomationRepositoryPort {
getAutomationGuard(): Promise<AutomationGuard>;
clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
}
export interface CaptureRepositoryPort {
listSources(): Promise<CaptureSourceInfo[]>;
captureSource(sourceId: string, delayMs?: number, focusGenshin?: boolean, options?: CaptureOptions): Promise<CaptureResult>;
}
+5
View File
@@ -0,0 +1,5 @@
export * from "./contracts.js";
export { JsonArtifactStoreRepository } from "./artifactStoreRepository.js";
export { ReviewSamplesRepository } from "./reviewSamplesRepository.js";
export { ScannerLearningRepository } from "./scannerLearningRepository.js";
export { JsonSnapshotRepository } from "./snapshotRepository.js";
@@ -0,0 +1,39 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ReviewSampleListResult, ReviewSamplesRepositoryPort, ReviewSamplePayload } from "./contracts.js";
import type { ReviewSampleRecord, SaveResultWithPath } from "../../src/types/global.js";
export class ReviewSamplesRepository implements ReviewSamplesRepositoryPort {
private readonly filePath: string;
constructor(userDataPath: string, fileName = "review-samples.jsonl") {
this.filePath = path.join(userDataPath, fileName);
}
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 samples = lines
.slice(-safeLimit)
.map((line) => {
try {
return JSON.parse(line) as ReviewSampleRecord;
} catch {
return null;
}
})
.filter(Boolean) as ReviewSampleRecord[];
return { ok: true, samples: samples.reverse(), total: lines.length, path: this.filePath };
} catch {
return { ok: true, samples: [], total: 0, path: this.filePath };
}
}
async append(sample: ReviewSamplePayload): Promise<SaveResultWithPath> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.appendFile(this.filePath, `${JSON.stringify({ savedAt: new Date().toISOString(), sample })}\n`, "utf8");
return { ok: true, path: this.filePath };
}
}
@@ -0,0 +1,37 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { ScannerLearningLoadResult, ScannerLearningRepositoryPort, ScannerLearningRules, ScannerLearningSaveResult } from "./contracts.js";
export class ScannerLearningRepository implements ScannerLearningRepositoryPort {
private readonly filePath: string;
constructor(userDataPath: string, fileName = "scanner-learning.json") {
this.filePath = path.join(userDataPath, fileName);
}
async load(): Promise<ScannerLearningLoadResult> {
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = JSON.parse(raw) as ScannerLearningRules;
return {
ok: true,
path: this.filePath,
rules: parsed && typeof parsed === "object" ? parsed : { textReplacements: {} },
};
} catch {
return { ok: true, path: this.filePath, rules: { textReplacements: {} } };
}
}
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 };
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 };
}
}
@@ -0,0 +1,27 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { SnapshotRepositoryPort } from "./contracts.js";
import type { SaveResultWithPath } from "../../src/types/global.js";
import type { AppSnapshot } from "../../src/types/domain.js";
export class JsonSnapshotRepository implements SnapshotRepositoryPort {
private readonly filePath: string;
constructor(userDataPath: string, fileName = "snapshot.json") {
this.filePath = path.join(userDataPath, fileName);
}
async load() {
try {
return JSON.parse(await fs.readFile(this.filePath, "utf8")) as AppSnapshot;
} catch {
return null;
}
}
async save(snapshot: AppSnapshot): Promise<SaveResultWithPath> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
await fs.writeFile(this.filePath, JSON.stringify(snapshot, null, 2), "utf8");
return { ok: true, path: this.filePath };
}
}
+622
View File
@@ -0,0 +1,622 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import type {
AutomationGuard,
ClickResult,
FocusGenshinResult,
GdiCaptureResult,
HelperOperationResponse,
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 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
}
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) {
[Native.InputHelper]::ShowWindowAsync($hwnd, 9) | Out-Null
# A previous version tapped ALT (keybd_event) right before this call to
# satisfy Windows' "who's allowed to change the foreground window"
# eligibility check. That tap has a side effect in most Win32 apps: a
# bare ALT press/release toggles menu-mnemonic navigation mode (verified
# live - it left a real app's menu bar highlighted after just this call),
# which then swallows the next several keyboard/mouse events as menu
# navigation instead of routing them to the app - looking exactly like
# "clicks/keys report success but do nothing". This app and Genshin run
# at the same (elevated) integrity level, so plain SetForegroundWindow
# already succeeds without the ALT tap - confirmed with a standalone
# compiled test against a live target window.
$info.setForegroundResult = [Native.InputHelper]::SetForegroundWindow($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)
}
`;
class InputHelperClient {
private child: ChildProcessWithoutNullStreams | null = null;
private pending = new Map<string, { resolve: (value: HelperOperationResponse) => void; reject: (error: Error) => void; timer: NodeJS.Timeout }>();
private buffer = "";
private nextId = 1;
private starting: Promise<void> | null = null;
private disposed = false;
constructor(private readonly scriptUserDataPath: string) {}
private async ensureStarted() {
if (this.child) return;
if (this.disposed) throw new Error("Input helper disposed");
if (!this.starting) {
this.starting = this.start().finally(() => {
this.starting = null;
});
}
await this.starting;
}
private async start() {
const scriptPath = path.join(this.scriptUserDataPath, "input-helper.ps1");
await fs.mkdir(path.dirname(scriptPath), { recursive: true });
await fs.writeFile(scriptPath, INPUT_HELPER_SCRIPT, "utf8");
const child = spawn("powershell.exe", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", scriptPath], {
windowsHide: true,
stdio: ["pipe", "pipe", "pipe"],
});
child.stdout.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => this.handleStdout(chunk));
child.stderr.setEncoding("utf8");
child.stderr.on("data", () => undefined);
child.on("exit", () => {
this.child = null;
this.buffer = "";
for (const entry of this.pending.values()) {
clearTimeout(entry.timer);
entry.reject(new Error("Input helper exited"));
}
this.pending.clear();
});
this.child = child;
// First request compiles the Win32 interop; give it extra time.
await this.send("ping", {}, 20000);
}
private handleStdout(chunk: string) {
this.buffer += chunk;
let newlineIndex = this.buffer.indexOf("\n");
while (newlineIndex >= 0) {
const line = this.buffer.slice(0, newlineIndex).trim();
this.buffer = this.buffer.slice(newlineIndex + 1);
newlineIndex = this.buffer.indexOf("\n");
if (!line.startsWith("{")) continue;
try {
const message = JSON.parse(line) as HelperOperationResponse;
const entry = this.pending.get(String(message.id));
if (!entry) continue;
this.pending.delete(String(message.id));
clearTimeout(entry.timer);
if (message.ok) entry.resolve(message);
else entry.reject(new Error(message.error || "Input helper command failed"));
} catch {
// Ignore non-JSON noise on stdout.
}
}
}
private send(op: string, params: Record<string, unknown>, timeoutMs: number) {
return new Promise<HelperOperationResponse>((resolve, reject) => {
const child = this.child;
if (!child?.stdin.writable) {
reject(new Error("Input helper is not running"));
return;
}
const id = String(this.nextId++);
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Input helper timed out on ${op}`));
}, timeoutMs);
this.pending.set(id, { resolve, reject, timer });
child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`);
});
}
request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return this.ensureStarted().then(() => this.send(op, params, timeoutMs));
}
dispose() {
this.disposed = true;
this.child?.kill();
this.child = null;
}
}
export interface InputHelperService {
getRuntimeInfo(): Promise<RuntimeInfo>;
focusGenshinWindow(): Promise<FocusGenshinResult>;
focusGenshinForScanStart(): Promise<FocusGenshinResult>;
getGenshinWindowBounds(): Promise<WindowBounds | null>;
clickScreen(x: number, y: number): Promise<ClickResult>;
scrollScreen(notches: number, anchorX?: number, anchorY?: number): Promise<ScrollResult>;
getAutomationGuard(): Promise<AutomationGuard>;
capturePrimaryScreenViaGdi(): Promise<GdiCaptureResult>;
dispose(): void;
}
export function createInputHelperService(options: { userDataPath: string }): InputHelperService {
const inputHelper = new InputHelperClient(options.userDataPath);
async function request(op: string, params: Record<string, unknown> = {}, timeoutMs = 8000) {
return inputHelper.request(op, params, timeoutMs);
}
async function getRuntimeInfo() {
const result = await request("runtime", {}, 4000);
return {
ok: true,
isElevated: Boolean(result.isElevated),
platform: process.platform,
genshinFound: Boolean(result.genshinFound),
genshinHwnd: typeof result.genshinHwnd === "number" ? result.genshinHwnd : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
foregroundHwnd: typeof result.foregroundHwnd === "number" ? result.foregroundHwnd : undefined,
helperPid: typeof result.helperPid === "number" ? result.helperPid : undefined,
};
}
async function focusGenshinWindow() {
const result = await request("focus", {}, 6000);
return {
focused: Boolean(result.focused),
alreadyForeground: Boolean(result.alreadyForeground),
genshinFound: Boolean(result.genshinFound),
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
setForegroundResult: typeof result.setForegroundResult === "boolean" ? result.setForegroundResult : undefined,
};
}
async function focusGenshinForScanStart() {
let result = await focusGenshinWindow();
if (result.focused) return result;
await new Promise((resolve) => setTimeout(resolve, 700));
result = await focusGenshinWindow();
return result;
}
async function getGenshinWindowBounds() {
const result = await request("bounds", {}, 4000);
if (!result.found) return null;
return {
x: Number(result.left),
y: Number(result.top),
width: Number(result.width),
height: Number(result.height),
};
}
async function clickScreen(x: number, y: number) {
const result = (await request("click", { x: Math.round(x), y: Math.round(y) }, 8000)) as HelperOperationResponse;
return {
ok: true,
x: Math.round(x),
y: Math.round(y),
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
escapePressed: Boolean(result.escapePressed),
enterPressed: Boolean(result.enterPressed),
f9Pressed: Boolean(result.f9Pressed),
moved: Boolean(result.moved),
clicked: Boolean(result.clicked),
inputBlocked: Boolean(result.inputBlocked),
focused: Boolean(result.focused),
alreadyForeground: Boolean(result.alreadyForeground),
foregroundProcess: typeof result.foregroundProcess === "string" ? result.foregroundProcess : undefined,
targetProcess: typeof result.targetProcess === "string" ? result.targetProcess : undefined,
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
};
}
async function scrollScreen(notches: number, anchorX?: number, anchorY?: number) {
const safeNotches = Math.max(-60, Math.min(60, Math.round(notches)));
const params: Record<string, unknown> = { notches: safeNotches };
if (typeof anchorX === "number" && typeof anchorY === "number") {
params.x = Math.round(anchorX);
params.y = Math.round(anchorY);
}
const result = (await request("scroll", params, 8000 + Math.abs(safeNotches) * 80)) as HelperOperationResponse;
return {
ok: true,
notchesSent: Number(result.notchesSent ?? 0),
inputBlocked: Boolean(result.inputBlocked),
isElevated: typeof result.isElevated === "boolean" ? result.isElevated : undefined,
};
}
async function getAutomationGuard() {
const result = await request("cursor", {}, 4000);
return {
ok: true,
cursorX: typeof result.cursorX === "number" ? result.cursorX : undefined,
cursorY: typeof result.cursorY === "number" ? result.cursorY : undefined,
escapePressed: Boolean(result.escapePressed),
enterPressed: Boolean(result.enterPressed),
f9Pressed: Boolean(result.f9Pressed),
};
}
async function capturePrimaryScreenViaGdi() {
const result = await request("capture", {}, 15000);
const capturePath = String(result.path);
const buffer = await fs.readFile(capturePath);
await fs.unlink(capturePath).catch(() => undefined);
const captureTargetRaw = typeof result.captureTarget === "string" ? result.captureTarget : "";
const captureTarget: "primary-screen" | "genshin-client" = captureTargetRaw === "primary-screen" || captureTargetRaw === "genshin-client"
? captureTargetRaw
: "primary-screen";
return {
dataUrl: `data:image/png;base64,${buffer.toString("base64")}`,
width: Number(result.width),
height: Number(result.height),
originX: Number(result.originX),
originY: Number(result.originY),
captureTarget,
};
}
return {
getRuntimeInfo,
focusGenshinWindow,
focusGenshinForScanStart,
getGenshinWindowBounds,
clickScreen,
scrollScreen,
getAutomationGuard,
capturePrimaryScreenViaGdi,
dispose: () => inputHelper.dispose(),
};
}