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:
@@ -0,0 +1,2 @@
|
||||
export * from "./ipcBootstrap.js";
|
||||
export * from "./repositoryContext.js";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user