/** * MCP-Compatible Command Interface for Playwright Electron Automation * * This module provides a standardized command interface that agents can use * to interact with the desktop application through Playwright automation. * * Usage: * - Import and call executeMCPCommand with appropriate command objects * - Supports launch, logs, close, and sendMessage operations * - Returns structured responses with success/error states */ import type { ElectronApplication, Page } from 'playwright'; import { launchElectronWithWindow, closeElectronApp, waitForAppReady, waitForChatReady, validateCredentials, captureAllLogs, type LogEntry, } from './playwright-automation'; /** * MCP command structure for Playwright automation operations */ export interface MCPCommand { action: | 'launch' | 'getLogs' | 'close' | 'sendMessage' | 'getScreenshot' | 'waitForElement' | 'navigateToPersonalAssistant' | 'checkForForm' | 'submitForm' | 'checkForHashLinkBlock' | 'verifyHashLinkRendering'; options?: { credentials?: { operatorId?: string; operatorKey?: string; }; timeout?: number; logSource?: 'all' | 'main' | 'renderer'; message?: string; selector?: string; screenshotPath?: string; }; } /** * Standardized response structure for MCP command operations */ export interface MCPResponse { success: boolean; data?: any; error?: string; timestamp: string; action: string; } /** * Application state management for persistent operations */ interface AppState { app?: ElectronApplication; window?: Page; logCapture?: ReturnType; isReady: boolean; } let appState: AppState = { isReady: false, }; /** * Main execution function for MCP commands * Routes commands to appropriate handlers and returns structured responses */ export async function executeMCPCommand( command: MCPCommand ): Promise { const response: MCPResponse = { success: false, timestamp: new Date().toISOString(), action: command.action, }; try { switch (command.action) { case 'launch': response.data = await handleLaunchCommand(command.options); response.success = true; break; case 'getLogs': response.data = await handleGetLogsCommand(command.options); response.success = true; break; case 'close': response.data = await handleCloseCommand(); response.success = true; break; case 'sendMessage': response.data = await handleSendMessageCommand(command.options); response.success = true; break; case 'getScreenshot': response.data = await handleGetScreenshotCommand(command.options); response.success = true; break; case 'waitForElement': response.data = await handleWaitForElementCommand(command.options); response.success = true; break; case 'navigateToPersonalAssistant': response.data = await handleNavigateToPersonalAssistantCommand(command.options); response.success = true; break; case 'checkForForm': response.data = await handleCheckForFormCommand(command.options); response.success = true; break; case 'submitForm': response.data = await handleSubmitFormCommand(command.options); response.success = true; break; case 'checkForHashLinkBlock': response.data = await handleCheckForHashLinkBlockCommand(command.options); response.success = true; break; case 'verifyHashLinkRendering': response.data = await handleVerifyHashLinkRenderingCommand(command.options); response.success = true; break; default: throw new Error(`Unknown action: ${command.action}`); } } catch (error) { response.success = false; response.error = error instanceof Error ? error.message : String(error); } return response; } /** * Handles application launch with credential validation */ async function handleLaunchCommand(options?: MCPCommand['options']): Promise<{ launched: boolean; ready: boolean; credentialsValid?: boolean; }> { const timeout = options?.timeout || 30000; const { app, window } = await launchElectronWithWindow(); appState.app = app; appState.window = window; appState.logCapture = captureAllLogs(app, window); const isReady = await waitForAppReady(app, timeout); appState.isReady = isReady; let credentialsValid = false; if (isReady) { credentialsValid = await validateCredentials(app); } return { launched: true, ready: isReady, credentialsValid, }; } /** * Retrieves application logs based on specified source */ async function handleGetLogsCommand(options?: MCPCommand['options']): Promise<{ logs: LogEntry[]; source: string; count: number; }> { if (!appState.logCapture) { throw new Error('Application not launched or log capture not initialized'); } const source = options?.logSource || 'all'; let logs: LogEntry[]; switch (source) { case 'main': logs = appState.logCapture.getBySource('main'); break; case 'renderer': logs = appState.logCapture.getBySource('renderer'); break; case 'all': default: logs = appState.logCapture.getAllLogs(); break; } return { logs, source, count: logs.length, }; } /** * Gracefully closes the application and cleans up state */ async function handleCloseCommand(): Promise<{ closed: boolean; stateCleared: boolean; }> { if (!appState.app) { throw new Error('No application instance to close'); } await closeElectronApp(appState.app); appState = { isReady: false, }; return { closed: true, stateCleared: true, }; } /** * Sends a message through the chat interface */ async function handleSendMessageCommand( options?: MCPCommand['options'] ): Promise<{ messageSent: boolean; message: string; chatReady: boolean; }> { if (!appState.window || !appState.isReady) { throw new Error('Application not ready or window not available'); } if (!options?.message) { throw new Error('Message content is required'); } const timeout = options.timeout || 30000; const chatReady = await waitForChatReady(appState.window, timeout); if (!chatReady) { throw new Error('Chat interface not ready within timeout'); } const inputSelector = 'textarea[placeholder*="Message"], textarea[placeholder*="Type"]'; await appState.window.fill(inputSelector, options.message); const sendButtonSelector = 'button:has-text(""), button[class*="send"], button:has(svg)'; await appState.window.click(sendButtonSelector); return { messageSent: true, message: options.message, chatReady, }; } /** * Takes a screenshot of the current application state */ async function handleGetScreenshotCommand( options?: MCPCommand['options'] ): Promise<{ screenshotTaken: boolean; path: string; }> { if (!appState.window) { throw new Error('No window available for screenshot'); } const defaultPath = `screenshot-${Date.now()}.png`; const screenshotPath = options?.screenshotPath || defaultPath; await appState.window.screenshot({ path: screenshotPath }); return { screenshotTaken: true, path: screenshotPath, }; } /** * Waits for a specific element to become available */ async function handleWaitForElementCommand( options?: MCPCommand['options'] ): Promise<{ elementFound: boolean; selector: string; timeout: number; }> { if (!appState.window) { throw new Error('No window available for element waiting'); } if (!options?.selector) { throw new Error('Element selector is required'); } const timeout = options.timeout || 10000; await appState.window.waitForSelector(options.selector, { timeout, state: 'visible', }); return { elementFound: true, selector: options.selector, timeout, }; } /** * Utility function to check if the application is currently running */ export function isAppRunning(): boolean { return appState.isReady && !!appState.app && !!appState.window; } /** * Utility function to get current application state */ export function getAppState(): Readonly { return { ...appState }; } /** * Convenience function for common launch-and-wait pattern */ export async function launchAndWaitForReady( credentials?: { operatorId?: string; operatorKey?: string }, timeout?: number ): Promise { return executeMCPCommand({ action: 'launch', options: { credentials, timeout, }, }); } /** * Convenience function for sending messages with automatic readiness check */ export async function sendMessageSafely( message: string, timeout?: number ): Promise { return executeMCPCommand({ action: 'sendMessage', options: { message, timeout, }, }); } /** * Convenience function for taking timestamped screenshots */ export async function takeTimestampedScreenshot( prefix: string = 'session' ): Promise { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = `${prefix}-${timestamp}.png`; return executeMCPCommand({ action: 'getScreenshot', options: { screenshotPath: filename, }, }); } /** * Navigates to the Personal Assistant (chat) interface */ async function handleNavigateToPersonalAssistantCommand( options?: MCPCommand['options'] ): Promise<{ navigated: boolean; chatReady: boolean; }> { if (!appState.window) { throw new Error('No window available for navigation'); } const timeout = options?.timeout || 30000; try { const navigationSelectors = [ 'button:has-text("Personal Assistant")', 'a:has-text("Personal Assistant")', '[data-testid="personal-assistant"]', 'button:has-text("Chat")', 'a:has-text("Chat")', '.nav-item:has-text("Assistant")', ]; let navigated = false; for (const selector of navigationSelectors) { try { await appState.window.waitForSelector(selector, { timeout: 5000 }); await appState.window.click(selector); navigated = true; break; } catch { continue; } } if (!navigated) { try { await appState.window.click('[href*="chat"], [href*="assistant"]'); navigated = true; } catch { throw new Error('Could not find navigation to Personal Assistant'); } } const chatReady = await waitForChatReady(appState.window, timeout); return { navigated, chatReady, }; } catch (error) { throw new Error(`Navigation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Checks if a form is present in the current interface */ async function handleCheckForFormCommand( options?: MCPCommand['options'] ): Promise<{ hasForm: boolean; formType?: string; formId?: string; }> { if (!appState.window) { throw new Error('No window available for form check'); } const timeout = options?.timeout || 10000; try { const formSelectors = [ 'form', '[data-testid*="form"]', '.form-container', 'button[type="submit"]', 'input[type="submit"]', 'button:has-text("Submit")', ]; let hasForm = false; let formType = 'unknown'; let formId; for (const selector of formSelectors) { try { const element = await appState.window.waitForSelector(selector, { timeout: 2000, state: 'visible' }); if (element) { hasForm = true; const formElement = await appState.window.$('form'); if (formElement) { formId = await formElement.getAttribute('id'); const className = await formElement.getAttribute('class'); if (className?.includes('hashinal') || className?.includes('inscription')) { formType = 'hashinal-inscription'; } else if (className?.includes('transfer') || className?.includes('transaction')) { formType = 'transaction'; } else { formType = 'general'; } } break; } } catch { } } return { hasForm, formType: hasForm ? formType : undefined, formId: hasForm ? formId : undefined, }; } catch (error) { return { hasForm: false, }; } } /** * Submits the currently displayed form */ async function handleSubmitFormCommand( options?: MCPCommand['options'] ): Promise<{ submitted: boolean; formType?: string; }> { if (!appState.window) { throw new Error('No window available for form submission'); } const timeout = options?.timeout || 30000; try { const formCheck = await handleCheckForFormCommand({ timeout: 5000 }); if (!formCheck.hasForm) { throw new Error('No form found to submit'); } const submitSelectors = [ 'button[type="submit"]', 'input[type="submit"]', 'button:has-text("Submit")', 'button:has-text("Create")', 'button:has-text("Send")', '.submit-button', '[data-testid="submit"]', ]; let submitted = false; for (const selector of submitSelectors) { try { await appState.window.waitForSelector(selector, { timeout: 3000, state: 'visible' }); await appState.window.click(selector); submitted = true; break; } catch { } } if (!submitted) { try { await appState.window.press('form', 'Enter'); submitted = true; } catch { throw new Error('Could not submit form - no submit button found'); } } await new Promise(resolve => setTimeout(resolve, 2000)); return { submitted, formType: formCheck.formType, }; } catch (error) { throw new Error(`Form submission failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Checks if a HashLink block is present in the chat interface */ async function handleCheckForHashLinkBlockCommand( options?: MCPCommand['options'] ): Promise<{ hasHashLinkBlock: boolean; blockId?: string; blockType?: string; }> { if (!appState.window) { throw new Error('No window available for HashLink block check'); } const timeout = options?.timeout || 15000; try { const hashLinkSelectors = [ '.hashlink-block-container', '[data-block-id]', '.hashlink-content', '[class*="hashlink"]', 'div:has-text("HCS-12")', '.hcs-12', ]; let hasHashLinkBlock = false; let blockId; let blockType; for (const selector of hashLinkSelectors) { try { const element = await appState.window.waitForSelector(selector, { timeout: 3000, state: 'visible' }); if (element) { hasHashLinkBlock = true; blockId = await element.getAttribute('data-block-id'); const className = await element.getAttribute('class'); if (className?.includes('image')) { blockType = 'hashinal-image'; } else if (className?.includes('nft')) { blockType = 'nft'; } else { blockType = 'general'; } break; } } catch { } } return { hasHashLinkBlock, blockId: hasHashLinkBlock ? blockId : undefined, blockType: hasHashLinkBlock ? blockType : undefined, }; } catch (error) { return { hasHashLinkBlock: false, }; } } /** * Verifies that HashLink block rendering is correct (not corrupted) */ async function handleVerifyHashLinkRenderingCommand( options?: MCPCommand['options'] ): Promise<{ renderingCorrect: boolean; issues?: string[]; renderingDetails?: any; }> { if (!appState.window) { throw new Error('No window available for rendering verification'); } try { const blockCheck = await handleCheckForHashLinkBlockCommand({ timeout: 5000 }); if (!blockCheck.hasHashLinkBlock) { throw new Error('No HashLink block found to verify'); } const issues: string[] = []; let renderingCorrect = true; try { const corruptedElements = await appState.window.$$eval('*', (elements) => { return elements.some(el => { const text = el.textContent || ''; return ( text.includes('
') || text.includes('