Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
type VerifyOcrResult,
} from './mcp/state';
import { saveCaptureToDownloads } from './saveCapture';
import { resolveSequenceStepsById } from './mcp/sequenceResolver';
import { resolveSequenceStepsByIdForDevice } from './mcp/sequenceSetResolver';

/**
* Build output directory structure:
Expand Down Expand Up @@ -54,8 +54,18 @@ let hasCompletedArmCleanupBeforeQuit = false;
/** Use bracket notation to avoid vite:define plugin transformation */
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL'];

const shouldUseSingleInstanceLock = !VITE_DEV_SERVER_URL;
const gotSingleInstanceLock = shouldUseSingleInstanceLock
? app.requestSingleInstanceLock()
: true;

if (shouldUseSingleInstanceLock && !gotSingleInstanceLock) {
app.quit();
}

interface RendererSequenceExecutionRequestPayload {
sequenceId: string;
deviceTestSetId?: string;
armState: {
isConnected: boolean;
resourceHandle: number;
Expand Down Expand Up @@ -365,7 +375,8 @@ async function runMnemonicOcrFromRenderer(
}

async function runSequenceInRenderer(
sequenceId: string
sequenceId: string,
deviceTestSetId?: string
): Promise<RendererSequenceExecutionResult | null> {
if (!mainWindow) {
console.warn('Cannot run sequence in renderer: mainWindow is null');
Expand All @@ -375,6 +386,7 @@ async function runSequenceInRenderer(
const armState = getArmState();
const payload: RendererSequenceExecutionRequestPayload = {
sequenceId,
deviceTestSetId,
armState: {
isConnected: armState.isConnected,
resourceHandle: armState.resourceHandle,
Expand Down Expand Up @@ -468,7 +480,24 @@ async function startMcpServer(): Promise<void> {
* Starts MCP Server for Claude/Cursor integration.
* On macOS, recreates window when dock icon is clicked with no windows open.
*/
if (shouldUseSingleInstanceLock && gotSingleInstanceLock) {
app.on('second-instance', () => {
if (!mainWindow) {
return;
}

if (mainWindow.isMinimized()) {
mainWindow.restore();
}
mainWindow.focus();
});
}

app.whenReady().then(async () => {
if (!gotSingleInstanceLock) {
return;
}

createWindow();

// Start MCP Server after window is created
Expand All @@ -495,10 +524,20 @@ app.on('window-all-closed', () => {

/** Clean up MCP Server before quitting */
app.on('before-quit', (event) => {
if (shouldUseSingleInstanceLock && !gotSingleInstanceLock) {
return;
}

if (hasCompletedArmCleanupBeforeQuit) {
return;
}

const armState = getArmState();
if (!armState.isConnected || armState.resourceHandle <= 0) {
resetArmState();
return;
}

event.preventDefault();
void disconnectArmController('application quit').finally(() => {
hasCompletedArmCleanupBeforeQuit = true;
Expand Down Expand Up @@ -536,8 +575,8 @@ ipcMain.handle('http-request', async (_event, url: string) => {
return performNetRequest(url, 'renderer-http-request');
});

ipcMain.handle('resolve-sequence-steps', async (_event, sequenceId: string) => {
return resolveSequenceStepsById(sequenceId);
ipcMain.handle('resolve-sequence-steps', async (_event, sequenceId: string, deviceTestSetId?: string) => {
return resolveSequenceStepsByIdForDevice(sequenceId, deviceTestSetId);
});

ipcMain.handle(
Expand Down Expand Up @@ -637,6 +676,7 @@ ipcMain.handle(
imageDataUrl: string;
layoutHint?: 'mnemonic' | 'verify-options' | 'verify-number' | 'generic';
expectedWordCount?: number;
recVariantCount?: number;
}
) => runPaddleOcrEn(payload)
);
Expand Down
11 changes: 9 additions & 2 deletions electron/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
executeMnemonicVerify,
} from './tools';
import { ARM_STATUS_URI, getArmStatusResource } from './resources';
import { getAllSequenceIds } from './sequences';
import { DEVICE_TEST_SETS, getAllSequenceIds } from './sequenceSets';
import { sendMcpLog } from './state';

/** MCP Server configuration */
Expand Down Expand Up @@ -257,7 +257,11 @@ export class QAAutoHardwareMcpServer {
inputSchema: executeSequenceSchema,
},
async (args) => {
sendMcpLog({ type: 'request', action: 'execute-sequence', detail: `Sequence: ${args.sequenceId}` });
sendMcpLog({
type: 'request',
action: 'execute-sequence',
detail: `${args.deviceTestSetId ?? 'pro'} / ${args.sequenceId}`,
});
const { output, frame } = await executeExecuteSequence(args, this.httpRequest);
sendMcpLog({
type: output.success ? 'response' : 'error',
Expand Down Expand Up @@ -673,6 +677,9 @@ export class QAAutoHardwareMcpServer {
message: ocr.message,
ocr,
sequenceIds: getAllSequenceIds(),
sequenceSets: Object.fromEntries(
DEVICE_TEST_SETS.map((set) => [set.id, getAllSequenceIds(set.id)])
),
activeSessions: {
streamable: this.streamableTransports.size,
sse: this.sseTransports.size,
Expand Down
6 changes: 6 additions & 0 deletions electron/mcp/ocrScenes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export {
DEVICE_OCR_SCENES,
PRO2_OCR_SCENES,
type DeviceOcrScenes,
type SequenceOcrSceneConfig,
} from '../../src/ocr/deviceScenes';
97 changes: 97 additions & 0 deletions electron/mcp/pro2SequenceResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import fs from 'fs';
import path from 'path';
import {
generateSlip39ShareSteps,
generateWordSteps,
getPageAction,
getSequence,
pickRandomShares,
type AutoSequence,
type AutoStep,
type MnemonicSource,
type PageAction,
} from './pro2Sequences';

interface MnemonicsData {
bip39: Record<string, string>;
slip39: Record<string, string>;
}

function loadMnemonics(): MnemonicsData {
const candidates = [
path.join(process.cwd(), 'mnemonics.local.json'),
path.join(__dirname, '..', '..', 'mnemonics.local.json'),
];

for (const filePath of candidates) {
if (fs.existsSync(filePath)) {
try {
const raw = fs.readFileSync(filePath, 'utf-8');
return JSON.parse(raw) as MnemonicsData;
} catch (err) {
console.error(`[mnemonics] Failed to parse ${filePath}:`, err);
}
}
}

console.warn(
'[mnemonics] mnemonics.local.json not found. Import-wallet sequences will use placeholder words.\n' +
' Copy mnemonics.example.json -> mnemonics.local.json and fill in your test mnemonics.'
);
return { bip39: {}, slip39: {} };
}

const MNEMONICS = loadMnemonics();

function getMnemonicWords(section: 'bip39' | 'slip39', key: string): string[] {
const phrase = MNEMONICS[section]?.[key] ?? '';
return phrase ? phrase.split(' ') : [];
}

function resolveMnemonicSource(source: MnemonicSource): AutoStep[] {
const shares = source.keys.map((key) => getMnemonicWords(source.section, key));

switch (source.mode) {
case 'single':
return generateWordSteps(shares[0] ?? []);
case 'shares-all':
return generateSlip39ShareSteps(shares);
case 'shares-random':
return generateSlip39ShareSteps(pickRandomShares(shares, source.pickCount ?? 1));
default:
return [];
}
}

export function resolvePro2PageActionSteps(action: PageAction): AutoStep[] {
if (action.mnemonicSource) {
return resolveMnemonicSource(action.mnemonicSource);
}
if (action.buildSteps) {
return action.buildSteps();
}
return action.steps;
}

export function resolvePro2SequenceSteps(sequence: AutoSequence): AutoStep[] {
const steps: AutoStep[] = [];

for (const actionId of sequence.actions) {
const action = getPageAction(actionId);
if (!action) {
console.warn(`[pro2-sequence-resolver] Unknown page action ID: ${actionId}`);
continue;
}
steps.push(...resolvePro2PageActionSteps(action));
}

return steps;
}

export function resolvePro2SequenceStepsById(sequenceId: string): AutoStep[] {
const sequence = getSequence(sequenceId);
if (!sequence) {
throw new Error(`Unknown Pro2 sequence ID: ${sequenceId}`);
}
return resolvePro2SequenceSteps(sequence);
}
Loading
Loading