feat(scanner): validate elevated live automation
This commit is contained in:
@@ -17,6 +17,7 @@ import type {
|
||||
SaveResultWithPath,
|
||||
SaveSnapshotResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
ScannerStatusPayload,
|
||||
} from "../../src/types/global.js";
|
||||
import type {
|
||||
@@ -51,6 +52,7 @@ interface PersistenceHandlersDependencies {
|
||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
interface CaptureHandlersDependencies {
|
||||
@@ -86,6 +88,7 @@ export function registerIpcHandlers(dependencies: IpcBootstrapDependencies) {
|
||||
loadScannerLearningRules: dependencies.loadScannerLearningRules,
|
||||
writeScannerLearningRules: dependencies.writeScannerLearningRules,
|
||||
exportGood: dependencies.exportGood,
|
||||
importGoodFile: dependencies.importGoodFile,
|
||||
});
|
||||
|
||||
registerCaptureHandlers({
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import fs from "node:fs/promises";
|
||||
import http, { type Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
CaptureSourceInfo,
|
||||
ClickResult,
|
||||
ReviewSampleListResult,
|
||||
ScannerCommand,
|
||||
ScannerStatusPayload,
|
||||
} from "../src/types/global.js";
|
||||
|
||||
interface DevControlServerDependencies {
|
||||
registeredHotkeys: Record<string, boolean>;
|
||||
hasMainWindow: () => boolean;
|
||||
sendScannerCommand: (command: ScannerCommand | "probe-click") => void;
|
||||
clickScreen: (x: number, y: number) => Promise<ClickResult>;
|
||||
scannerStatus: () => ScannerStatusPayload;
|
||||
loadReviewSamples: (limit?: number) => Promise<ReviewSampleListResult>;
|
||||
listCaptureSources: () => Promise<CaptureSourceInfo[]>;
|
||||
captureSource: (
|
||||
id: string,
|
||||
delayMs?: number,
|
||||
focusGenshin?: boolean,
|
||||
options?: CaptureOptions,
|
||||
) => Promise<CaptureResult>;
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function dataUrlBase64(dataUrl: string) {
|
||||
return dataUrl.replace(/^data:image\/png;base64,/, "");
|
||||
}
|
||||
|
||||
function devCaptureOutputDir() {
|
||||
return path.join(process.cwd(), "outputs", "live-capture");
|
||||
}
|
||||
|
||||
function safeDebugFilePart(value: string) {
|
||||
return value.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "capture";
|
||||
}
|
||||
|
||||
function dataUrlFingerprint(dataUrl: string | undefined) {
|
||||
if (!dataUrl) return "";
|
||||
let hash = 2166136261;
|
||||
const stride = Math.max(1, Math.floor(dataUrl.length / 4096));
|
||||
for (let index = 0; index < dataUrl.length; index += stride) {
|
||||
hash ^= dataUrl.charCodeAt(index);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return `${dataUrl.length.toString(16)}:${(hash >>> 0).toString(16)}`;
|
||||
}
|
||||
|
||||
async function writeDevCaptureImage(filePath: string, dataUrl: string | undefined) {
|
||||
if (!dataUrl) return null;
|
||||
await fs.writeFile(filePath, Buffer.from(dataUrlBase64(dataUrl), "base64"));
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function writeDevCaptureSnapshot(capture: CaptureResult) {
|
||||
const outputDir = devCaptureOutputDir();
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const stamp = new Date().toISOString().replace(/[\\/:]/g, "-").replace(/\..+?$/, "");
|
||||
const prefix = safeDebugFilePart(`${stamp}-${capture.name}`);
|
||||
const files = {
|
||||
full: await writeDevCaptureImage(path.join(outputDir, `${prefix}-full.png`), capture.dataUrl),
|
||||
detail: await writeDevCaptureImage(path.join(outputDir, `${prefix}-detail.png`), capture.detailDataUrl),
|
||||
inventory: await writeDevCaptureImage(path.join(outputDir, `${prefix}-inventory.png`), capture.inventoryDataUrl),
|
||||
crops: [] as Array<{ id: string; label: string; path: string; rect: { x: number; y: number; width: number; height: number } }>,
|
||||
};
|
||||
|
||||
for (const crop of capture.crops ?? []) {
|
||||
const cropPath = path.join(outputDir, `${prefix}-${safeDebugFilePart(crop.id)}.png`);
|
||||
const written = await writeDevCaptureImage(cropPath, crop.dataUrl);
|
||||
if (written) files.crops.push({ id: crop.id, label: crop.label, path: written, rect: crop.rect });
|
||||
}
|
||||
|
||||
const summary = {
|
||||
id: capture.id,
|
||||
name: capture.name,
|
||||
width: capture.width,
|
||||
height: capture.height,
|
||||
capturedAt: capture.capturedAt,
|
||||
captureTarget: capture.captureTarget,
|
||||
ocrSkipped: capture.ocrSkipped,
|
||||
ocrTimedOut: capture.ocrTimedOut,
|
||||
layout: capture.layout,
|
||||
inventoryGrid: capture.inventoryGrid
|
||||
? {
|
||||
rows: capture.inventoryGrid.rows,
|
||||
cols: capture.inventoryGrid.cols,
|
||||
confidence: capture.inventoryGrid.confidence,
|
||||
source: capture.inventoryGrid.source,
|
||||
firstCenter: capture.inventoryGrid.centers[0] ?? null,
|
||||
lastCenter: capture.inventoryGrid.centers.at(-1) ?? null,
|
||||
}
|
||||
: null,
|
||||
inventoryCount: capture.inventoryCount ?? null,
|
||||
locked: capture.locked,
|
||||
crops: (capture.crops ?? []).map((crop) => ({ id: crop.id, label: crop.label, rect: crop.rect })),
|
||||
ocr: capture.ocr ?? [],
|
||||
files,
|
||||
};
|
||||
const summaryPath = path.join(outputDir, `${prefix}-summary.json`);
|
||||
await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), "utf8");
|
||||
return { ...summary, summaryPath };
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function findGenshinSource(sources: CaptureSourceInfo[], sourceId: string | null) {
|
||||
return sourceId
|
||||
? sources.find((entry) => entry.id === sourceId)
|
||||
: sources.find((entry) => entry.isGenshinCandidate);
|
||||
}
|
||||
|
||||
function sourceListForError(sources: CaptureSourceInfo[]) {
|
||||
return sources.map(({ id, name, isGenshinCandidate }) => ({ id, name, isGenshinCandidate }));
|
||||
}
|
||||
|
||||
export function createDevControlServer(deps: DevControlServerDependencies): Server {
|
||||
const server = http.createServer((req, res) => {
|
||||
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
||||
writeDevJson(res, 403, { ok: false, error: "local only" });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: deps.registeredHotkeys, hasWindow: deps.hasMainWindow() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
const limit = Number(url.searchParams.get("limit") ?? Number.NaN);
|
||||
const command: ScannerCommand = Number.isFinite(limit) && limit > 0
|
||||
? { type: "start-auto", scanLimit: limit }
|
||||
: "start-auto";
|
||||
deps.sendScannerCommand(command);
|
||||
writeDevJson(res, 200, { ok: true, command });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/stop") {
|
||||
deps.sendScannerCommand("stop");
|
||||
writeDevJson(res, 200, { ok: true, command: "stop" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/probe") {
|
||||
deps.sendScannerCommand("probe-click");
|
||||
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/automation/click") {
|
||||
const x = Number(url.searchParams.get("x"));
|
||||
const y = Number(url.searchParams.get("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
||||
return;
|
||||
}
|
||||
deps.clickScreen(Math.round(x), Math.round(y))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/status") {
|
||||
writeDevJson(res, 200, { ok: true, status: deps.scannerStatus() });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
deps.loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/capture/smart") {
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const focus = url.searchParams.get("focus") !== "0";
|
||||
const skipOcr = url.searchParams.get("skipOcr") === "1";
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
const capture = await deps.captureSource(source.id, 250, focus, { skipOcr });
|
||||
const summary = await writeDevCaptureSnapshot(capture);
|
||||
writeDevJson(res, 200, { ok: true, source: { id: source.id, name: source.name }, summary });
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/automation/probe-click") {
|
||||
const sourceId = url.searchParams.get("sourceId");
|
||||
const requestedIndex = Number(url.searchParams.get("index") ?? "1");
|
||||
const requestedRow = Number(url.searchParams.get("row") ?? Number.NaN);
|
||||
const requestedCol = Number(url.searchParams.get("col") ?? Number.NaN);
|
||||
deps.listCaptureSources()
|
||||
.then(async (sources) => {
|
||||
const source = findGenshinSource(sources, sourceId);
|
||||
if (!source) {
|
||||
writeDevJson(res, 404, { ok: false, error: "No Genshin capture source found.", sources: sourceListForError(sources) });
|
||||
return;
|
||||
}
|
||||
|
||||
const before = await deps.captureSource(source.id, 150, true, { skipOcr: true });
|
||||
const centers = before.inventoryGrid?.centers ?? [];
|
||||
const target = Number.isFinite(requestedRow) && Number.isFinite(requestedCol)
|
||||
? centers.find((center) => center.row === requestedRow && center.col === requestedCol)
|
||||
: centers[Math.max(0, Math.min(centers.length - 1, Number.isFinite(requestedIndex) ? requestedIndex : 1))];
|
||||
if (!target) {
|
||||
writeDevJson(res, 409, { ok: false, error: "No inventory grid target available.", grid: before.inventoryGrid ?? null });
|
||||
return;
|
||||
}
|
||||
|
||||
const beforeFingerprint = dataUrlFingerprint(before.detailDataUrl);
|
||||
const click = await deps.clickScreen(target.x, target.y);
|
||||
await wait(650);
|
||||
const after = await deps.captureSource(source.id, 0, true, { skipOcr: true });
|
||||
const afterFingerprint = dataUrlFingerprint(after.detailDataUrl);
|
||||
const changed = Boolean(beforeFingerprint && afterFingerprint && beforeFingerprint !== afterFingerprint);
|
||||
writeDevJson(res, 200, {
|
||||
ok: Boolean(click.ok && click.clicked && changed),
|
||||
changed,
|
||||
target,
|
||||
click,
|
||||
before: {
|
||||
captureTarget: before.captureTarget,
|
||||
grid: before.inventoryGrid ? { rows: before.inventoryGrid.rows, cols: before.inventoryGrid.cols, source: before.inventoryGrid.source, confidence: before.inventoryGrid.confidence } : null,
|
||||
detailFingerprint: beforeFingerprint,
|
||||
},
|
||||
after: {
|
||||
captureTarget: after.captureTarget,
|
||||
grid: after.inventoryGrid ? { rows: after.inventoryGrid.rows, cols: after.inventoryGrid.cols, source: after.inventoryGrid.source, confidence: after.inventoryGrid.confidence } : null,
|
||||
detailFingerprint: afterFingerprint,
|
||||
},
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
|
||||
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
||||
});
|
||||
|
||||
server.listen(17317, "127.0.0.1");
|
||||
return server;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
SaveScannerLearningRulesResult,
|
||||
ScannerLearningRulePayload,
|
||||
SaveResultWithPath,
|
||||
GoodImportFileResult,
|
||||
} from "../../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../../src/types/storage.js";
|
||||
|
||||
@@ -28,6 +29,7 @@ interface PersistenceDependencies {
|
||||
loadScannerLearningRules: () => Promise<LoadScannerLearningRulesResult>;
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => Promise<SaveScannerLearningRulesResult>;
|
||||
exportGood: (payload: GoodDatabase) => Promise<SaveResultWithPath>;
|
||||
importGoodFile: () => Promise<GoodImportFileResult>;
|
||||
}
|
||||
|
||||
export function registerPersistenceHandlers({
|
||||
@@ -39,6 +41,7 @@ export function registerPersistenceHandlers({
|
||||
loadScannerLearningRules,
|
||||
writeScannerLearningRules,
|
||||
exportGood,
|
||||
importGoodFile,
|
||||
}: PersistenceDependencies) {
|
||||
ipcMain.handle("review:saveSample", async (_event, sample: ReviewSamplePayload) => {
|
||||
try {
|
||||
@@ -81,4 +84,8 @@ export function registerPersistenceHandlers({
|
||||
ipcMain.handle("good:export", async (_event, payload: GoodDatabase) => {
|
||||
return exportGood(payload);
|
||||
});
|
||||
|
||||
ipcMain.handle("good:importFile", async () => {
|
||||
return importGoodFile();
|
||||
});
|
||||
}
|
||||
|
||||
+56
-65
@@ -1,19 +1,22 @@
|
||||
import { app, BrowserWindow, Menu, desktopCapturer, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||
import { app, BrowserWindow, Menu, desktopCapturer, dialog, globalShortcut, nativeImage, screen, type NativeImage } from "electron";
|
||||
import fs from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import http, { type Server } from "node:http";
|
||||
import type { Server } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createWorker } from "tesseract.js";
|
||||
import { createInputHelperService, type InputHelperService } from "./services/inputHelper.js";
|
||||
import { createRepositoryContext, type RepositoryContext } from "./bootstrap/repositoryContext.js";
|
||||
import { registerIpcHandlers } from "./bootstrap/ipcBootstrap.js";
|
||||
import { createDevControlServer } from "./devControlServer.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
import type {
|
||||
CaptureOptions,
|
||||
CaptureResult,
|
||||
GoodDatabase,
|
||||
GoodImportFileResult,
|
||||
SaveResultWithPath,
|
||||
ScannerCommand,
|
||||
ScannerLearningRulePayload,
|
||||
ScannerStatusPayload,
|
||||
} from "../src/types/global.js";
|
||||
@@ -33,6 +36,7 @@ import {
|
||||
profileDetailRect,
|
||||
} from "../src/lib/layoutProfile.js";
|
||||
import { binarizeForOcr } from "../src/lib/ocrPreprocess.js";
|
||||
import { detectLockState, lockIconCropRect } from "../src/lib/lockDetection.js";
|
||||
|
||||
// Chromium's renderer sandbox can refuse to fully initialize (or silently
|
||||
// crash the GPU/renderer process) when the hosting process runs with a full
|
||||
@@ -416,7 +420,7 @@ function focusMainWindow() {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function sendScannerCommand(command: "start-auto" | "stop" | "probe-click") {
|
||||
function sendScannerCommand(command: ScannerCommand | "probe-click") {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send("scanner:command", command);
|
||||
}
|
||||
@@ -431,71 +435,18 @@ function registerScannerHotkeys() {
|
||||
};
|
||||
}
|
||||
|
||||
function writeDevJson(res: http.ServerResponse, statusCode: number, payload: unknown) {
|
||||
res.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function startDevControlServer() {
|
||||
if (!isDev || devControlServer) return;
|
||||
|
||||
devControlServer = http.createServer((req, res) => {
|
||||
if (req.socket.remoteAddress && !["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(req.socket.remoteAddress)) {
|
||||
writeDevJson(res, 403, { ok: false, error: "local only" });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
writeDevJson(res, 200, { ok: true, hotkeys: registeredHotkeys, hasWindow: Boolean(mainWindow && !mainWindow.isDestroyed()) });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/start") {
|
||||
sendScannerCommand("start-auto");
|
||||
writeDevJson(res, 200, { ok: true, command: "start-auto" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/stop") {
|
||||
sendScannerCommand("stop");
|
||||
writeDevJson(res, 200, { ok: true, command: "stop" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/probe") {
|
||||
sendScannerCommand("probe-click");
|
||||
writeDevJson(res, 200, { ok: true, command: "probe-click" });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/automation/click") {
|
||||
const x = Number(url.searchParams.get("x"));
|
||||
const y = Number(url.searchParams.get("y"));
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
||||
writeDevJson(res, 400, { ok: false, error: "x and y query params are required" });
|
||||
return;
|
||||
}
|
||||
getInputHelperService()
|
||||
.clickScreen(Math.round(x), Math.round(y))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, { ok: true, payload }))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/scanner/status") {
|
||||
writeDevJson(res, 200, { ok: true, status: scannerDevStatus });
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/review/samples") {
|
||||
loadReviewSamples(Number(url.searchParams.get("limit") ?? 20))
|
||||
.then((payload: unknown) => writeDevJson(res, 200, payload))
|
||||
.catch((error: unknown) => writeDevJson(res, 500, { ok: false, error: error instanceof Error ? error.message : String(error) }));
|
||||
return;
|
||||
}
|
||||
|
||||
writeDevJson(res, 404, { ok: false, error: "unknown endpoint" });
|
||||
devControlServer = createDevControlServer({
|
||||
registeredHotkeys,
|
||||
hasMainWindow: () => Boolean(mainWindow && !mainWindow.isDestroyed()),
|
||||
sendScannerCommand,
|
||||
clickScreen: clickScreenCommand,
|
||||
scannerStatus: () => scannerDevStatus,
|
||||
loadReviewSamples,
|
||||
listCaptureSources,
|
||||
captureSource,
|
||||
});
|
||||
|
||||
devControlServer.listen(17317, "127.0.0.1");
|
||||
}
|
||||
|
||||
function createOverlayWindow() {
|
||||
@@ -802,6 +753,10 @@ function createCrops(
|
||||
}
|
||||
|
||||
function inferDetailRect(bitmap: Buffer, imageSize: { width: number; height: number }) {
|
||||
if (isSixteenNine(imageSize)) {
|
||||
return profileDetailRect(imageSize);
|
||||
}
|
||||
|
||||
const { width, height } = imageSize;
|
||||
const sampleStrideX = width > 2200 ? 4 : 3;
|
||||
const sampleStrideY = height > 1400 ? 4 : 3;
|
||||
@@ -869,6 +824,12 @@ async function buildCaptureResult(
|
||||
const detailRect = inferDetailRect(bitmap, size);
|
||||
const inventoryRect = inferInventoryRect(size, detailRect);
|
||||
const crops = createCrops(sourceImage, size, detailRect, inventoryRect);
|
||||
const lockRect = clampCaptureRect(lockIconCropRect(detailRect, size), size);
|
||||
const lockImage = sourceImage.crop(lockRect);
|
||||
const lockSize = lockImage.getSize();
|
||||
const locked = lockSize.width > 0 && lockSize.height > 0
|
||||
? detectLockState({ data: lockImage.getBitmap(), width: lockSize.width, height: lockSize.height })
|
||||
: undefined;
|
||||
const croppedPayload = crops.map((crop) => ({
|
||||
id: crop.id,
|
||||
label: crop.label,
|
||||
@@ -905,6 +866,7 @@ async function buildCaptureResult(
|
||||
})),
|
||||
inventoryGrid: inferInventoryGrid(size, detailRect),
|
||||
inventoryCount: count,
|
||||
locked,
|
||||
layout: {
|
||||
aspect: aspectRatioLabel(size),
|
||||
isSixteenNine: isSixteenNine(size),
|
||||
@@ -965,6 +927,34 @@ async function exportGood(payload: GoodDatabase): Promise<SaveResultWithPath> {
|
||||
}
|
||||
}
|
||||
|
||||
async function importGoodFile(): Promise<GoodImportFileResult> {
|
||||
const dialogOptions = {
|
||||
title: "GOOD-Datei importieren",
|
||||
properties: ["openFile"],
|
||||
filters: [{ name: "GOOD JSON", extensions: ["json"] }],
|
||||
} satisfies Electron.OpenDialogOptions;
|
||||
const dialogResult = mainWindow && !mainWindow.isDestroyed()
|
||||
? await dialog.showOpenDialog(mainWindow, dialogOptions)
|
||||
: await dialog.showOpenDialog(dialogOptions);
|
||||
|
||||
if (dialogResult.canceled || dialogResult.filePaths.length === 0) {
|
||||
return { ok: false, canceled: true, path: "" };
|
||||
}
|
||||
|
||||
const filePath = dialogResult.filePaths[0];
|
||||
try {
|
||||
const text = await fs.readFile(filePath, "utf8");
|
||||
return { ok: true, canceled: false, path: filePath, database: JSON.parse(text) };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
canceled: false,
|
||||
path: filePath,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function initializeAppLifecycle() {
|
||||
app.whenReady().then(() => {
|
||||
const userDataPath = app.getPath("userData");
|
||||
@@ -993,6 +983,7 @@ function initializeAppLifecycle() {
|
||||
loadScannerLearningRules: () => loadScannerLearningRules(),
|
||||
writeScannerLearningRules: (rules: ScannerLearningRulePayload) => writeScannerLearningRules(rules),
|
||||
exportGood: (exportPayload: GoodDatabase) => exportGood(exportPayload),
|
||||
importGoodFile: () => importGoodFile(),
|
||||
listSources: () => listCaptureSources(),
|
||||
captureSource: (
|
||||
id: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||
saveArtifacts: (records) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||
exportGood: (payload) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
publishScannerStatus: (status) => ipcRenderer.invoke("scanner:publishStatus", status),
|
||||
showOverlay: () => ipcRenderer.invoke("overlay:show"),
|
||||
hideOverlay: () => ipcRenderer.invoke("overlay:hide"),
|
||||
|
||||
+4
-3
@@ -1,5 +1,5 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { CaptureOptions, GoodDatabase, ReviewSamplePayload, ScannerCommand, ScannerStatusPayload, ScannerLearningRulePayload } from "../src/types/global.js";
|
||||
import type { StoredArtifactRecord } from "../src/types/storage.js";
|
||||
import type { AppSnapshot } from "../src/types/domain.js";
|
||||
|
||||
@@ -23,11 +23,12 @@ contextBridge.exposeInMainWorld("assistantApi", {
|
||||
loadArtifacts: () => ipcRenderer.invoke("artifacts:load"),
|
||||
saveArtifacts: (records: StoredArtifactRecord[]) => ipcRenderer.invoke("artifacts:saveMany", records),
|
||||
exportGood: (payload: GoodDatabase) => ipcRenderer.invoke("good:export", payload),
|
||||
importGoodFile: () => ipcRenderer.invoke("good:importFile"),
|
||||
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);
|
||||
onScannerCommand: (callback: (command: ScannerCommand) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, command: ScannerCommand) => callback(command);
|
||||
ipcRenderer.on("scanner:command", listener);
|
||||
return () => ipcRenderer.removeListener("scanner:command", listener);
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ export class JsonArtifactStoreRepository implements ArtifactStoreRepositoryPort
|
||||
lastSeenAt: now,
|
||||
timesSeen: (existing.timesSeen ?? 1) + 1,
|
||||
confidence: Math.max(existing.confidence ?? 0, record.confidence ?? 0),
|
||||
locked: typeof record.locked === "boolean" ? record.locked : existing.locked,
|
||||
// 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),
|
||||
@@ -136,6 +137,7 @@ function normalizeStoredArtifactRecordForLoad(record: StoredArtifactRecord) {
|
||||
...record,
|
||||
timesSeen: reviewOnly ? 1 : normalizedTimesSeen,
|
||||
firstSeenAt: record.firstSeenAt ?? record.lastSeenAt,
|
||||
locked: typeof record.locked === "boolean" ? record.locked : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +185,7 @@ function mergeArtifactRecords(existing: StoredArtifactRecord, incoming: StoredAr
|
||||
substats: [...(preferredSubstats ?? [])],
|
||||
equipped: preferred.equipped && preferred.equipped !== "Not detected" ? preferred.equipped : secondary.equipped,
|
||||
confidence: Math.max(existing.confidence ?? 0, incoming.confidence ?? 0),
|
||||
locked: typeof incoming.locked === "boolean" ? incoming.locked : existing.locked,
|
||||
needsReview: Boolean(existing.needsReview) && Boolean(incoming.needsReview),
|
||||
source: resolveStoredArtifactSource(existing.source, incoming.source),
|
||||
firstSeenAt: existing.firstSeenAt ?? now,
|
||||
|
||||
Reference in New Issue
Block a user