Skip to main content
Glama

PopUI

by kelnishi
main.ts23.5 kB
import {app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, protocol, shell, Tray} from 'electron'; import * as fs from 'fs'; import * as path from 'path'; import {SseServer, startMcp} from './server'; import {getInterfacesDir} from './utils/paths'; import {reloadClaude, sendToClaude} from './shell'; import * as os from "node:os"; import MenuItemConstructorOptions = Electron.MenuItemConstructorOptions; import {runProxy} from '@/mcp-remote/src/proxy'; import * as process from "node:process"; import preferences from './preferences'; let mcpServer: SseServer | null; let mainWindow: BrowserWindow | null = null; const PORT = 3001; let tray: Tray | null; let dropdownWindow: BrowserWindow | null; let windows = new Map<string, BrowserWindow>(); let isQuitting = false; app.on('before-quit', () => { isQuitting = true; }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') { app.quit(); } }); function createMainWindow() { if (mainWindow) { mainWindow.show(); return; } // Create the browser window mainWindow = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, '../renderer/view/preload.js'), nodeIntegration: true, contextIsolation: true, }, }); // Load the index.html from the renderer folder mainWindow.loadFile(path.join(__dirname, '../renderer/view/index.html')); mainWindow.on('close', (event) => { event.preventDefault(); mainWindow?.hide(); }); // Open DevTools in development mode if (process.env.NODE_ENV === 'development') { mainWindow.webContents.openDevTools(); } } // In the createNewWindowWithTSX function, update the asset URLs to use the custom protocol: function createNewWindowWithTSX(filenameWithoutExt: string, tsxFilePath: string): BrowserWindow { // Read the TSX file const tsxCode = fs.readFileSync(tsxFilePath, 'utf-8'); const filename = path.basename(tsxFilePath); // Apply the local variables to the TsxWindow.html template file. // ${tsxCode} and ${filename} are placeholders in the template file. const htmlTemplate = fs.readFileSync(path.join(__dirname, 'templates', 'TsxWindow.html'), 'utf-8'); const htmlContent = htmlTemplate .replace('${tsxCode}', tsxCode) .replace('${filename}', filenameWithoutExt); const tempHtmlPath = path.join(app.getPath('temp'), filenameWithoutExt + '.html'); fs.writeFileSync(tempHtmlPath, htmlContent, 'utf-8'); console.error('Created temp file:', tempHtmlPath); const newWin = new BrowserWindow({ width: 800, height: 600, webPreferences: { preload: path.join(__dirname, '../renderer/view/preload.js'), nodeIntegration: true, contextIsolation: true, }, }); newWin.webContents.on('did-finish-load', async () => { try { // Measure the maximum width and height of the document const {width, height} = await newWin.webContents.executeJavaScript(` (() => { // Wait for the component to be rendered const rootElement = document.getElementById('dynamic-component-container')?.children[0] || document.getElementById('root') || document.body; if (!rootElement) { return {width: 0, height: 0}; } // Get the actual rendered component's size const rect = rootElement.getBoundingClientRect(); return { width: Math.ceil(rect.width), height: Math.ceil(rect.height) }; })()` ); if (width === 0 || height === 0) { console.error('Invalid width or height:', width, height); return; } // Update the window's content size newWin.setContentSize(width, height); } catch (e) { console.error(e); } }); newWin.webContents.on('console-message', (event, level, message, line, sourceId) => { console.error(level, message, line, sourceId); }); try { newWin.loadFile(tempHtmlPath); } catch (e) { console.error(e); } return newWin; } function showDropdownWindow() { if (!dropdownWindow) { dropdownWindow = new BrowserWindow({ width: 600, height: 800, frame: false, resizable: true, show: false, transparent: false, webPreferences: { preload: path.join(__dirname, '../renderer/view/preload.js'), nodeIntegration: false, contextIsolation: true, } }); // Load your React app (e.g., a local HTML file that bootstraps React) dropdownWindow.loadFile(path.join(__dirname, '../renderer/view/index.html')); // Hide window when it loses focus dropdownWindow.on('blur', () => { if (dropdownWindow) dropdownWindow.hide(); }); } // Position the window based on the tray icon's location // (Positioning logic is platform-specific and may require additional code.) dropdownWindow.show(); } function setupProtocolHandlers() { protocol.interceptFileProtocol('file', (request, callback) => { let url = request.url.substr(7); // remove 'file://' // Check if the request matches the problematic script path if (!url.includes('.webpack') && !url.includes('/var')) { // Map it to your actual file location url = path.join(__dirname, '..', 'renderer', url); } // console.error(url); callback({path: url}); }); // Register a custom protocol to serve assets from the local assets directory protocol.registerFileProtocol('assets', (request, callback) => { const assetPath = decodeURI(request.url.replace('assets://', '')); callback({path: path.join(__dirname, 'assets', assetPath)}); }); protocol.registerFileProtocol('styles', (request, callback) => { const assetPath = decodeURI(request.url.replace('styles://', '')); callback({path: path.join(__dirname, 'styles', assetPath)}); }); } function installMenuTrayIcon() { //Install a menubar icon const trayIcon = nativeImage.createEmpty(); const iconPath = path.join(__dirname, 'assets', 'icon.png'); const icon2xPath = path.join(__dirname, 'assets', 'icon@2x.png'); trayIcon.addRepresentation({ scaleFactor: 1, width: 22, height: 22, buffer: fs.readFileSync(iconPath) }); trayIcon.addRepresentation({ scaleFactor: 2, width: 44, height: 44, buffer: fs.readFileSync(icon2xPath) }); trayIcon.setTemplateImage(true); // Create the tray with the composite image tray = new Tray(trayIcon); // Update the tray menu with recent files function updateTrayMenu() { const files = listFiles(); const recentFilesMenu: MenuItemConstructorOptions[] = files .filter(file => { return file.endsWith('.tsx'); }) .map(file => { const filenameWithoutExt = path.basename(file, path.extname(file)); return { label: filenameWithoutExt, click: () => { const filePath = path.join(getInterfacesDir(), file); openFile(filePath); } }; }); if (recentFilesMenu.length > 0) { recentFilesMenu.push({type: 'separator'}); } recentFilesMenu.push( { label: 'Open Interfaces Folder', click: () => { const uploadsDir = getInterfacesDir(); shell.openPath(uploadsDir); } }, ) // Create the context menu with proper types const contextMenu: MenuItemConstructorOptions[] = [ { label: 'PopUI Settings...', click: () => { if (!mainWindow) { createMainWindow(); } else { mainWindow.show(); mainWindow.focus(); } } }, {type: 'separator'}, { label: 'Recent Interfaces', submenu: recentFilesMenu }, {type: 'separator'}, {label: 'Quit', click: () => { app.quit(); }} ]; // Set the context menu tray?.setContextMenu(Menu.buildFromTemplate(contextMenu)); } // Set initial menu updateTrayMenu(); // Update menu when clicked (to refresh recent files) tray.on('click', () => { updateTrayMenu(); }); } function unpackScripts() { const scriptsDir = path.join(__dirname, 'scripts'); const unpackedDir = path.join(app.getPath('userData'), 'scripts'); if (!fs.existsSync(unpackedDir)) { fs.mkdirSync(unpackedDir, {recursive: true}); } // Helper function to copy files and directories recursively function copyRecursively(src: string, dest: string) { const stats = fs.statSync(src); if (stats.isDirectory()) { // Create destination directory if it doesn't exist if (!fs.existsSync(dest)) { fs.mkdirSync(dest, { recursive: true }); } // Copy each item inside the directory const entries = fs.readdirSync(src); for (const entry of entries) { const srcPath = path.join(src, entry); const destPath = path.join(dest, entry); copyRecursively(srcPath, destPath); } } else { // It's a file, copy it directly fs.copyFileSync(src, dest); } } // Start recursive copy from root scripts directory copyRecursively(scriptsDir, unpackedDir); } function detectClaudeDesktopMac(): boolean { const mdfind = `mdfind "kMDItemCFBundleIdentifier == 'com.anthropic.claudefordesktop'"`; try { const result = require('child_process').execSync(mdfind).toString(); return result.trim() !== ''; } catch (error) { console.error('Error checking if Claude is installed:', error); return false; } } function canonicalizeAppDataPath(filePath: string): string { if (process.platform === 'darwin') { // On macOS return path.join(os.homedir(), 'Library', 'Application Support', filePath); } else if (process.platform === 'win32') { // On Windows return path.join(process.env.APPDATA || '', 'Claude', filePath); } else { // On Linux return path.join(os.homedir(), '.config', filePath); } } function tryInstallClaudeDesktop(): boolean { //Read the preferences file to see if the user has enabled the Claude Desktop integration const jsonPath = canonicalizeAppDataPath(path.join('Claude', 'claude_desktop_config.json')); // Load the json file const json = fs.readFileSync(jsonPath, 'utf-8'); let claudePreferences; try { claudePreferences = JSON.parse(json); } catch (error) { claudePreferences = {}; } //Add PopUI to the "mcpServers" object claudePreferences.mcpServers = claudePreferences.mcpServers || {}; //$(mdfind "kMDItemCFBundleIdentifier == 'com.kelnishi.popui'")"/Contents/MacOS/PopUI" claudePreferences.mcpServers.PopUI = { command: 'sh', args: [ "-c", "$(mdfind \"kMDItemCFBundleIdentifier == 'com.kelnishi.popui'\")\"/Contents/MacOS/PopUI\" --sse" ] }; // Write the updated preferences back to the file fs.writeFileSync(jsonPath, JSON.stringify(claudePreferences, null, 2), 'utf-8'); reloadClaude().then(() => { console.error("PopUI installed in Claude Desktop"); }); return false; } function detectClaudeInstallation() { if (process.platform === 'darwin') { const claudeDetected = detectClaudeDesktopMac(); if (!claudeDetected) { dialog.showMessageBox({ type: 'warning', title: 'PopUI Error', message: 'Claude Desktop Not Found', detail: 'PopUI works with Claude Desktop. Please install Claude Desktop from https://claude.ai/download to use PopUI.', buttons: ['Quit', 'Download'], defaultId: 1 }).then(result => { // If user clicked "Download" (button index 0) if (result.response === 1) { shell.openExternal('https://claude.ai/download'); } else { app.quit(); } }).catch(err => { console.error('Dialog error:', err); }); } else { //Check the preferences to see if the user has enabled the Claude Desktop integration const jsonPath = canonicalizeAppDataPath(path.join('Claude', 'claude_desktop_config.json')); // Load the json file const json = fs.readFileSync(jsonPath, 'utf-8'); let claudePreferences; try { claudePreferences = JSON.parse(json); } catch (error) { claudePreferences = {}; } //Look for "PopUI" key in the "mcpServers" object const popuiConfig = claudePreferences.mcpServers?.PopUI; if (!popuiConfig) { // If the key is not found, show a warning dialog.showMessageBox({ type: 'warning', title: 'Install PopUI', message: 'Install the PopUI tool in Claude Desktop', detail: 'Click to install the PopUI tool in Claude Desktop as an MCP Server.', buttons: ['Dismiss', 'Install'], defaultId: 1 }).then(result => { if (result.response === 1) { tryInstallClaudeDesktop(); } }).catch(err => { console.error('Dialog error:', err); }); } } } } app.whenReady().then(async() => { let mode = 'proxy'; const net = require('net'); const server = net.createServer(); server.on('error', (err: any) => { if (err.code === 'EADDRINUSE') { mode = 'proxy'; } }); server.listen(PORT, () => { server.close(); mcpServer = startMcp(PORT); mode = 'host'; setupProtocolHandlers(); installMenuTrayIcon(); unpackScripts(); detectClaudeInstallation(); console.error('Interfaces directory:', getInterfacesDir()); app.on('activate', function () { // On macOS it's common to re-create a window when the dock icon is clicked if (BrowserWindow.getAllWindows().length === 0) createMainWindow(); }); app.on('before-quit', () => { //Display a modal alert dialog.showMessageBox({ type: 'info', title: 'PopUI', message: 'PopUI is closing', detail: 'Any active chat sessions will be disconnected.\nRestart your chat host to reconnect.', buttons: ['Bye'], defaultId: 0 }).then(() => { //Kill all other PopUI processes require('child_process').execSync('pkill -f "PopUI"'); app.exit(0); }); }); app.on('will-quit', () => { if (mcpServer) { mcpServer.server.close(); } }); }); const url = 'http://localhost:3001/sse'; const callbackPort = 3334; const clean = false; //$(mdfind "kMDItemCFBundleIdentifier == 'com.kelnishi.popui'")"/Contents/MacOS/PopUI" --sse console.error(`Running proxy for ${url} with callback port ${callbackPort} with clean mode ${clean}`); try { await runProxy(url, callbackPort, clean); } catch (error) { console.error('Proxy error:', error); process.exit(1); } }); export async function injectWindow(name: string, json: string) { console.error(`Injecting window: ${name}`); const win = windows.get(name); if (!win) { console.warn('Window not found:', name); return null; } console.error(`Found window: ${name}`); await win.webContents.executeJavaScript(`window.dynamicComponent.setState(${json})`); return JSON.stringify(json, null, 2); } export async function readWindow(name: string) { console.error(`Reading window: ${name}`); const win = windows.get(name); if (!win) { const filename = path.join(getInterfacesDir(), `${name}.tsx`); if (fs.existsSync(filename)) { console.error(`Found window file: ${name}`); const newWin = await openFile(filename); if (!newWin) { console.warn('Invalid window:', name); return null; } const state = await newWin.webContents.executeJavaScript('window.dynamicComponent.getState()'); return JSON.stringify(state, null, 2); } console.warn('Window not found:', name); return null; } console.error(`Found window: ${name}`); // Execute the getState method on the component instance const state = await win.webContents.executeJavaScript('window.dynamicComponent.getState()'); return JSON.stringify(state, null, 2); } export async function closeWindow(name: string) { const win = windows.get(name); if (win) { win.close(); windows.delete(name); } console.error(`Window closed: ${name}`); return name; } export async function describeWindow(name: string) { console.error(`Describing window: ${name}`); const win = windows.get(name); if (!win) { console.warn('Window not found:', name); return null; } console.error(`Found window: ${name}`); // Execute the describeState method on the component instance const state = await win.webContents.executeJavaScript('window.describeState()'); return JSON.stringify(state, null, 2); } export function listWindows() { return Array.from(windows.keys()); } export function listFiles() { const uploadsDir = getInterfacesDir(); return fs.readdirSync(uploadsDir); } // Reveal the file in the system's file manager export function showFile(selectedFile: string) { const uploadsDir = getInterfacesDir(); if (!selectedFile.startsWith(uploadsDir)) { console.warn('Selected file is not in uploadsDir'); return; } const filepath = path.resolve(selectedFile); console.error('Revealing file:', filepath); // Reveal the file in the system's file manager shell.showItemInFolder(filepath); } export function openFile(selectedFile: string): BrowserWindow | undefined { const uploadsDir = getInterfacesDir(); if (!selectedFile.startsWith(uploadsDir)) { console.warn('Selected file is not in uploadsDir'); return; } //Get any existing window for the file const existingWin = windows.get(selectedFile); if (existingWin) { existingWin.focus(); return; } //filename without ext const filenameWithoutExt = path.basename(selectedFile, path.extname(selectedFile)); const newWin = createNewWindowWithTSX(filenameWithoutExt, selectedFile); //When the window is closed, remove it from the windows map newWin.on('closed', () => { windows.delete(filenameWithoutExt); }); //Save the window to a global map windows.set(filenameWithoutExt, newWin); return newWin; } ipcMain.handle('link-external', async (_, url) => { if (typeof url === 'string') { await shell.openExternal(url); return true; } return false; }); ipcMain.handle('list-files', async () => { const files = listFiles(); return files.filter(file => file.endsWith('.tsx')).map(file => { return { name: file.replace('.tsx', ''), path: path.join(getInterfacesDir(), file), schema: {} }; }); }); ipcMain.handle('open-file', async (_, selectedFile: string) => { openFile(selectedFile); return selectedFile; }); ipcMain.handle('show-file', async (_, selectedFile: string) => { showFile(selectedFile); return selectedFile; }); ipcMain.handle('delete-file', async (_, selectedFile: string) => { const uploadsDir = getInterfacesDir(); if (!selectedFile.startsWith(uploadsDir)) { console.warn('Selected file is not in uploadsDir'); return; } const filepath = path.resolve(selectedFile); console.error('Deleting file:', filepath); fs.unlinkSync(filepath); return selectedFile; }); ipcMain.handle('send-to-host', async (_, message: string) => { console.error("Sending message to host:", message); return await sendToClaude(message); }); ipcMain.handle('get-pref', async (_, key: string) => { return preferences.get(key) as string; }); ipcMain.handle('set-pref', async (_, key: string, value: string) => { const bool = value === 'true'; preferences.set(key, bool); return bool; }); // Handle IPC requests from renderer to server ipcMain.handle('server-request', async (_, endpoint, data) => { try { // Base URL for server requests const baseUrl = `http://localhost:${PORT}`; console.error(`Making request to ${baseUrl}${endpoint}`, data ? 'with data' : 'without data'); // Handle different HTTP methods if (data && endpoint === '/upload') { console.error('Handling file upload request'); // For file uploads, make a POST request with the data const response = await fetch(`${baseUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(data) }); if (!response.ok) { const errorText = await response.text(); console.error(`Server responded with ${response.status}:`, errorText); throw new Error(`Server error: ${response.status}`); } const result = await response.json(); console.error('Upload response:', result); return result; } else { // For other endpoints, make a GET request const response = await fetch(`${baseUrl}${endpoint}`); if (!response.ok) { const errorText = await response.text(); console.error(`Server responded with ${response.status}:`, errorText); throw new Error(`Server error: ${response.status}`); } const result = await response.json(); // console.error('API response:', result); return result; } } catch (error) { console.error('Error in server request:', error); throw error; } });

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/kelnishi/PopUI'

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