Skip to main content
Glama
ui.ts20.2 kB
import express, { Request, Response, RequestHandler } from 'express'; // Import RequestHandler import * as path from 'path'; import * as fs from 'fs'; import { EmulatorService } from './emulatorService'; // Import EmulatorService import { GameBoyButton } from './types'; // Import GameBoyButton import { log } from './utils/logger'; /** * Sets up the web UI routes for the GameBoy emulator * @param app Express application * @param emulatorService Emulator service instance */ export function setupWebUI(app: express.Application, emulatorService: EmulatorService): void { // Route for the main emulator page app.get('/emulator', (req: Request, res: Response) => { const currentRomPath = emulatorService.getRomPath(); const displayRomPath = currentRomPath || 'No ROM loaded'; const romName = currentRomPath ? path.basename(currentRomPath) : 'No ROM loaded'; res.send(` <!DOCTYPE html> <html> <head> <title>GameBoy Emulator</title> <style> body { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100vh; margin: 0; background-color: #f0f0f0; font-family: Arial, sans-serif; } h1 { margin-bottom: 20px; } #gameboy { border: 10px solid #333; border-radius: 10px; background-color: #9bbc0f; padding: 20px; } #screen { width: 320px; height: 288px; image-rendering: pixelated; background-color: #0f380f; display: block; } #info { margin-top: 10px; text-align: center; } #controls { margin-top: 15px; display: flex; flex-direction: column; align-items: center; } .control-row { display: flex; margin-bottom: 10px; align-items: center; } .dpad { display: grid; grid-template-columns: repeat(3, 40px); grid-template-rows: repeat(3, 40px); gap: 5px; margin-right: 20px; } .action-buttons { display: grid; grid-template-columns: repeat(2, 40px); grid-template-rows: repeat(2, 40px); gap: 5px; margin-left: 20px; } .menu-buttons { display: flex; gap: 10px; margin-top: 10px; } .control-button, .menu-button { background-color: #333; color: white; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; display: flex; align-items: center; justify-content: center; } .control-button { width: 40px; height: 40px; } .menu-button { padding: 5px 10px; } .control-button:hover, .menu-button:hover { background-color: #555; } .control-button:active { background-color: #777; } .skip-button { margin-top: 10px; padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 5px; cursor: pointer; } .skip-button:hover { background-color: #45a049; } #auto-play-container { margin-top: 10px; display: flex; align-items: center; } #auto-play-checkbox { margin-right: 5px; } #back-button { margin-top: 15px; padding: 5px 10px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; text-decoration: none; } #back-button:hover { background-color: #45a049; } </style> </head> <body> <h1>GameBoy Emulator</h1> <div id="gameboy"> <img id="screen" src="/screen" alt="GameBoy Screen" /> </div> <div id="info"> <p>ROM: ${romName}</p> </div> <div id="controls"> <div class="control-row"> <div class="dpad"> <div></div><button class="control-button" id="btn-up">↑</button><div></div> <button class="control-button" id="btn-left">←</button><div></div><button class="control-button" id="btn-right">→</button> <div></div><button class="control-button" id="btn-down">↓</button><div></div> </div> <div class="action-buttons"> <div></div><div></div> <button class="control-button" id="btn-b">B</button> <button class="control-button" id="btn-a">A</button> </div> </div> <div class="menu-buttons"> <button class="menu-button" id="btn-select">SELECT</button> <button class="menu-button" id="btn-start">START</button> </div> <button class="skip-button" id="btn-skip">Skip 100 Frames</button> <div id="auto-play-container"> <input type="checkbox" id="auto-play-checkbox"> <label for="auto-play-checkbox">Auto-Play</label> </div> <div id="button-duration-container" style="margin-top: 10px; display: flex; align-items: center;"> <label for="button-duration-input" style="margin-right: 5px;">Button Press Duration:</label> <input type="number" id="button-duration-input" min="1" value="25" style="width: 50px;"> </div> </div> <a id="back-button" href="/">Back to ROM Selection</a> <script> let autoPlayEnabled = false; let updateTimeoutId = null; const autoPlayCheckbox = document.getElementById('auto-play-checkbox'); const buttonDurationInput = document.getElementById('button-duration-input'); const screenImg = document.getElementById('screen'); autoPlayCheckbox.addEventListener('change', (event) => { autoPlayEnabled = event.target.checked; console.log('Auto-play toggled:', autoPlayEnabled); // Clear existing timeout and immediately trigger an update if (updateTimeoutId) clearTimeout(updateTimeoutId); updateScreen(); }); async function updateScreen() { if (!screenImg) return; // Exit if image element not found const endpoint = autoPlayEnabled ? '/api/advance_and_get_screen' : '/screen'; const timestamp = new Date().getTime(); // Prevent caching try { const response = await fetch(endpoint + '?' + timestamp); if (!response.ok) { console.error('Error fetching screen:', response.status, response.statusText); // Schedule next attempt after a delay, even on error scheduleNextUpdate(); return; } const blob = await response.blob(); // Revoke previous object URL to free memory if (screenImg.src.startsWith('blob:')) { URL.revokeObjectURL(screenImg.src); } screenImg.src = URL.createObjectURL(blob); } catch (error) { console.error('Error fetching screen:', error); } finally { scheduleNextUpdate(); } } function scheduleNextUpdate() { // Schedule next update // ~60fps if auto-playing (1000ms / 60fps ≈ 16.67ms) // Slower update rate if not auto-playing to reduce load const delay = autoPlayEnabled ? 17 : 100; updateTimeoutId = setTimeout(updateScreen, delay); } async function callApiTool(toolName, params = {}) { console.log(\`Calling tool: \${toolName} with params:\`, params); try { const response = await fetch('/api/tool', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tool: toolName, params: params }) }); if (!response.ok) { const errorText = await response.text(); console.error(\`Error calling tool \${toolName}: \${response.status} \${errorText}\`); } else { console.log(\`Tool \${toolName} called successfully.\`); // Optionally force a screen update if not auto-playing // if (!autoPlayEnabled) { // if (updateTimeoutId) clearTimeout(updateTimeoutId); // updateScreen(); // } } } catch (error) { console.error(\`Network error calling tool \${toolName}:\`, error); } } // Add event listeners for buttons document.getElementById('btn-up')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_up', { duration_frames: duration }); }); document.getElementById('btn-down')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_down', { duration_frames: duration }); }); document.getElementById('btn-left')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_left', { duration_frames: duration }); }); document.getElementById('btn-right')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_right', { duration_frames: duration }); }); document.getElementById('btn-a')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_a', { duration_frames: duration }); }); document.getElementById('btn-b')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_b', { duration_frames: duration }); }); document.getElementById('btn-start')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_start', { duration_frames: duration }); }); document.getElementById('btn-select')?.addEventListener('click', () => { const duration = parseInt(buttonDurationInput.value) || 5; callApiTool('press_select', { duration_frames: duration }); }); document.getElementById('btn-skip')?.addEventListener('click', () => callApiTool('wait_frames', { duration_frames: 100 })); // Start the screen update loop updateScreen(); </script> </body> </html> `); }); // Route to get the current screen image (no frame advance) const screenHandler: RequestHandler = (req, res) => { if (!emulatorService.isRomLoaded()) { res.status(400).send('No ROM loaded'); } else { try { const screen = emulatorService.getScreen(); // Does not advance frame const screenBuffer = Buffer.from(screen.data, 'base64'); res.setHeader('Content-Type', 'image/png'); res.send(screenBuffer); // Ends the response } catch (error) { log.error('Error getting screen:', error); res.status(500).send('Error getting screen'); // Ends the response } } }; app.get('/screen', screenHandler); // API endpoint for advancing one frame and getting the screen (for Auto-Play) const advanceAndGetScreenHandler: RequestHandler = (req, res) => { if (!emulatorService.isRomLoaded()) { res.status(400).send('No ROM loaded'); } else { try { const screen = emulatorService.advanceFrameAndGetScreen(); // Advances frame const screenBuffer = Buffer.from(screen.data, 'base64'); res.setHeader('Content-Type', 'image/png'); res.send(screenBuffer); // Ends the response } catch (error) { log.error('Error advancing frame and getting screen:', error); res.status(500).send('Error advancing frame and getting screen'); // Ends the response } } }; app.get('/api/advance_and_get_screen', advanceAndGetScreenHandler); // API endpoint to call emulator tools (used by UI buttons) const apiToolHandler: RequestHandler = async (req, res) => { const { tool, params } = req.body; log.info(`API /api/tool called: ${tool}`, params); if (!tool) { res.status(400).json({ error: 'Tool name is required' }); return; // Exit early if no tool provided } if (!emulatorService.isRomLoaded() && tool !== 'load_rom') { res.status(400).json({ error: 'No ROM loaded' }); return; // Exit early if ROM not loaded (except for load_rom tool) } try { let result: any; // Should be ImageContent switch (tool) { case 'get_screen': result = emulatorService.getScreen(); break; case 'load_rom': if (!params || !params.romPath) { res.status(400).json({ error: 'ROM path is required' }); return; // Exit early } result = emulatorService.loadRom(params.romPath); break; case 'wait_frames': const duration_frames_wait = params?.duration_frames ?? 100; if (typeof duration_frames_wait !== 'number' || duration_frames_wait <= 0) { res.status(400).json({ error: 'Invalid duration_frames' }); return; // Exit early } result = emulatorService.waitFrames(duration_frames_wait); break; default: if (tool.startsWith('press_')) { const buttonName = tool.replace('press_', '').toUpperCase(); if (!(Object.values(GameBoyButton) as string[]).includes(buttonName)) { res.status(400).json({ error: `Invalid button: ${buttonName}` }); return; // Exit early } const duration_frames_press = params?.duration_frames ?? 5; if (typeof duration_frames_press !== 'number' || duration_frames_press <= 0) { res.status(400).json({ error: 'Invalid duration_frames for press' }); return; // Exit early } emulatorService.pressButton(buttonName as GameBoyButton, duration_frames_press); result = emulatorService.getScreen(); } else { res.status(400).json({ error: `Unknown tool: ${tool}` }); return; // Exit early } } // Send response if result was obtained res.json({ content: [result] }); // Ends the response } catch (error) { log.error(`Error calling tool ${tool} via API:`, error); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ error: `Failed to call tool: ${errorMessage}` }); // Ends the response } }; app.post('/api/tool', apiToolHandler); // --- Test Interface Endpoints (Keep as is for now, assuming they are correct or will be fixed separately if needed) --- // Add a route for the test interface app.get('/test', (req: Request, res: Response) => { res.sendFile(path.join(process.cwd(), 'public', 'test-interface.html')); }); // Get list of available ROMs app.get('/api/roms', (req: Request, res: Response) => { try { const romsDir = path.join(process.cwd(), 'roms'); if (!fs.existsSync(romsDir)) { fs.mkdirSync(romsDir); } const romFiles = fs.readdirSync(romsDir) .filter(file => file.endsWith('.gb') || file.endsWith('.gbc')) .map(file => ({ name: file, path: path.join(romsDir, file) })); res.json(romFiles); } catch (error) { log.error('Error getting ROM list:', error); res.status(500).json({ error: 'Failed to get ROM list' }); } }); // Check MCP server status app.get('/api/status', (req: Request, res: Response) => { try { const romLoaded = emulatorService.isRomLoaded(); res.json({ connected: true, // Assume connected if UI is reachable romLoaded, romPath: emulatorService.getRomPath() }); } catch (error) { log.error('Error checking MCP server status:', error); res.status(500).json({ error: 'Failed to check MCP server status' }); } }); } /** * Sets up the ROM selection page * @param app Express application * @param emulatorService Emulator service instance (No longer needs emulator directly) */ export function setupRomSelectionUI(app: express.Application, emulatorService: EmulatorService): void { // Create the ROM selection page app.get('/', (req: Request, res: Response) => { // No need to check emulatorService.isRomLoaded() here, // let the user always see the selection page. const romsDir = path.join(process.cwd(), 'roms'); let romFiles: { name: string; path: string }[] = []; try { if (!fs.existsSync(romsDir)) { fs.mkdirSync(romsDir); } romFiles = fs.readdirSync(romsDir) .filter(file => file.endsWith('.gb') || file.endsWith('.gbc')) .map(file => ({ name: file, // IMPORTANT: Use relative path for security and consistency path: path.join('roms', file) // Use relative path from project root })); } catch (error) { log.error("Error reading ROM directory:", error); // Proceed with empty list if directory reading fails } // Render the ROM selection page res.send(` <!DOCTYPE html> <html> <head> <title>GameBoy ROM Selection</title> <style> body { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background-color: #f0f0f0; font-family: Arial, sans-serif; padding: 20px; box-sizing: border-box; } h1 { margin-bottom: 20px; text-align: center; } .container { max-width: 500px; width: 100%; } .rom-list, .upload-form { width: 100%; border: 1px solid #ccc; border-radius: 5px; padding: 15px; background-color: white; margin-bottom: 20px; box-sizing: border-box; } .rom-list { max-height: 400px; overflow-y: auto; } .rom-item { padding: 10px; border-bottom: 1px solid #eee; cursor: pointer; word-break: break-all; } .rom-item:hover { background-color: #f5f5f5; } .rom-item:last-child { border-bottom: none; } .upload-form h2 { margin-top: 0; margin-bottom: 15px; } .upload-form input[type="file"] { margin-bottom: 10px; display: block; width: 100%; } .upload-form button { padding: 8px 15px; background-color: #4CAF50; color: white; border: none; border-radius: 3px; cursor: pointer; font-size: 1em; } .upload-form button:hover { background-color: #45a049; } .no-roms { text-align: center; color: #555; } </style> </head> <body> <div class="container"> <h1>Select a GameBoy ROM</h1> <div class="rom-list"> ${romFiles.length > 0 ? romFiles.map(rom => ` <div class="rom-item" onclick="selectRom('${rom.path.replace(/\\/g, '\\\\')}')"> ${rom.name} </div>`).join('') // Escape backslashes for JS string literal : '<p class="no-roms">No ROM files found in ./roms directory. Upload one below.</p>' } </div> <div class="upload-form"> <h2>Upload a ROM</h2> <form action="/upload" method="post" enctype="multipart/form-data"> <input type="file" name="rom" accept=".gb,.gbc" required /> <button type="submit">Upload</button> </form> </div> </div> <script> function selectRom(romPath) { // Use the /gameboy endpoint which now handles loading via the service window.location.href = '/gameboy?rom=' + encodeURIComponent(romPath); } </script> </body> </html> `); }); }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mario-andreschak/mcp-gameboy'

If you have feedback or need assistance with the MCP directory API, please join our Discord server