const { app, BrowserWindow, ipcMain, shell, Tray, Menu, nativeImage, clipboard, session, desktopCapturer } = require('electron'); const path = require('path'); const fs = require('fs'); const { exec } = require('child_process'); const { promisify } = require('util'); const Store = require('electron-store'); const open = require('open'); const PaarrotAPIServer = require('./api-server'); const https = require('https'); const http = require('http'); const AdmZip = require('adm-zip'); const { autoUpdater } = require('electron-updater'); // Development mode detection - MUST be declared before any code that might use it const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged; const VITE_DEV_SERVER = 'http://localhost:38347'; const PORT = 44548; const execAsync = promisify(exec); const store = new Store(); let mainWindow = null; let tray = null; let isQuitting = false; let apiServer = null; // Allow audio playback without requiring a prior user gesture (needed for notification sounds) app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required'); // Enable GPU acceleration app.commandLine.appendSwitch('ignore-gpu-blocklist'); app.commandLine.appendSwitch('enable-gpu-rasterization'); app.commandLine.appendSwitch('enable-zero-copy'); app.commandLine.appendSwitch('enable-webgl'); app.commandLine.appendSwitch('enable-accelerated-2d-canvas'); // Set app name for notifications and system tray app.setName('Paarrot'); // Configure auto-updater autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; // Set feed URL manually as fallback if app-update.yml is missing if (!isDev) { try { autoUpdater.setFeedURL({ provider: 'github', owner: 'Paarrot', repo: 'Paarrot-Desktop', releaseType: 'release' }); } catch (err) { console.warn('Failed to set feed URL:', err); } } autoUpdater.on('checking-for-update', () => { console.log('Checking for updates...'); if (mainWindow) { mainWindow.webContents.send('update-checking'); } }); autoUpdater.on('update-available', (info) => { console.log('Update available:', info.version); if (mainWindow) { mainWindow.webContents.send('update-available', { version: info.version, releaseNotes: info.releaseNotes, releaseDate: info.releaseDate }); } }); autoUpdater.on('update-not-available', (info) => { console.log('No update available. Current version:', info.version); if (mainWindow) { mainWindow.webContents.send('update-not-available', { version: info.version }); } }); autoUpdater.on('error', (err) => { console.error('Update error:', err); if (mainWindow) { mainWindow.webContents.send('update-error', { error: err.message }); } }); autoUpdater.on('download-progress', (progressObj) => { console.log(`Download progress: ${progressObj.percent}%`); if (mainWindow) { mainWindow.webContents.send('update-download-progress', { percent: progressObj.percent, transferred: progressObj.transferred, total: progressObj.total }); } }); autoUpdater.on('update-downloaded', (info) => { console.log('Update downloaded:', info.version); if (mainWindow) { mainWindow.webContents.send('update-downloaded', { version: info.version, releaseNotes: info.releaseNotes }); } }); // Single instance lock const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); } else { app.on('second-instance', () => { // Someone tried to run a second instance, focus our window if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } }); } // Helper to get correct icon path in dev vs packaged app function getIconPath(iconName) { if (isDev) { return path.join(__dirname, '../icons', iconName); } // In packaged app, extraResources puts icons in resources/icons/ return path.join(process.resourcesPath, 'icons', iconName); } function createWindow() { // Restore window state or use defaults const savedState = store.get('windowState', {}); // Validate saved position is within visible screen bounds const { screen } = require('electron'); let validPosition = false; if (savedState.x !== undefined && savedState.y !== undefined) { const displays = screen.getAllDisplays(); validPosition = displays.some((display) => { const { x, y, width, height } = display.workArea; return savedState.x >= x && savedState.x < x + width && savedState.y >= y && savedState.y < y + height; }); } const windowState = { width: savedState.width ?? 1280, height: savedState.height ?? 905, x: validPosition ? savedState.x : undefined, y: validPosition ? savedState.y : undefined, isMaximized: savedState.isMaximized ?? false, }; mainWindow = new BrowserWindow({ width: windowState.width, height: windowState.height, x: windowState.x, y: windowState.y, title: 'Paarrot', frame: false, // Custom window decorations resizable: true, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false, webSecurity: true, allowRunningInsecureContent: false, // Enable navigation gestures (works on macOS and some Linux environments) enableBlinkFeatures: 'OverscrollHistoryNavigation' }, icon: getIconPath(process.platform === 'win32' ? 'icon.ico' : 'icon.png'), show: false // Don't show until ready }); // Enable trackpad swipe gestures for navigation on macOS if (process.platform === 'darwin') { try { mainWindow.on('swipe', (event, direction) => { console.log(`Paarrot: Swipe gesture detected: ${direction}`); if (direction === 'left' && mainWindow.webContents.canGoForward()) { console.log('Paarrot: Navigating forward'); mainWindow.webContents.goForward(); } else if (direction === 'right' && mainWindow.webContents.canGoBack()) { console.log('Paarrot: Navigating back'); mainWindow.webContents.goBack(); } }); } catch (error) { console.error('Paarrot: Failed to set up swipe gestures:', error); } } // On Linux, the OverscrollHistoryNavigation Blink feature should handle it automatically // On Windows, mouse buttons 4 & 5 are typically used for navigation (handled by Chromium) // Restore maximized state if (windowState.isMaximized) { mainWindow.maximize(); } // Save window state on resize/move const saveWindowState = () => { if (!mainWindow.isMaximized() && !mainWindow.isMinimized() && !mainWindow.isFullScreen()) { const bounds = mainWindow.getBounds(); store.set('windowState', { width: bounds.width, height: bounds.height, x: bounds.x, y: bounds.y, isMaximized: false }); } }; mainWindow.on('resize', saveWindowState); mainWindow.on('move', saveWindowState); mainWindow.on('maximize', () => { store.set('windowState.isMaximized', true); }); mainWindow.on('unmaximize', () => { store.set('windowState.isMaximized', false); }); // Set up session handlers BEFORE loading the app // Auto-approve media permissions including screen capture mainWindow.webContents.session.setPermissionRequestHandler((webContents, permission, callback) => { const allowedPermissions = ['media', 'microphone', 'camera', 'display-capture', 'notifications']; if (allowedPermissions.includes(permission)) { console.log(`Paarrot: Auto-approving permission: ${permission}`); callback(true); } else { console.log(`Paarrot: Denying permission: ${permission}`); callback(false); } }); // Handle screen capture requests - always allow mainWindow.webContents.session.setPermissionCheckHandler((webContents, permission, requestingOrigin, details) => { console.log(`Paarrot: Permission check - ${permission} from ${requestingOrigin}`); return true; // Allow all permissions }); // Handle display media request (screen sharing) - this is the key for Electron screen capture mainWindow.webContents.session.setDisplayMediaRequestHandler(async (request, callback) => { console.log('Paarrot: Display media request received', request); try { const sources = await desktopCapturer.getSources({ types: ['screen', 'window'], thumbnailSize: { width: 150, height: 150 } }); console.log('Paarrot: Available sources:', sources.length); sources.forEach((s, i) => console.log(` ${i}: ${s.name} (${s.id})`)); if (sources.length === 0) { console.log('Paarrot: No sources available, denying request'); callback({}); return; } // Prefer screens over windows const source = sources.find(s => s.id.startsWith('screen:')) || sources[0]; console.log('Paarrot: Selected source:', source.name, source.id); // Return the selected source - video is required callback({ video: source }); } catch (error) { console.error('Paarrot: Error getting display sources:', error); callback({}); } }, { useSystemPicker: false }); // Disable default context menu, only show for text selection and images mainWindow.webContents.on('context-menu', (event, params) => { const { selectionText, isEditable, mediaType, srcURL } = params; // Show context menu for text selection, editable fields, or images if (selectionText || isEditable || mediaType === 'image') { // Build a minimal menu for text operations const template = []; if (params.misspelledWord) { // Add spelling suggestions params.dictionarySuggestions.slice(0, 5).forEach(suggestion => { template.push({ label: suggestion, click: () => mainWindow.webContents.replaceMisspelling(suggestion) }); }); if (template.length > 0) { template.push({ type: 'separator' }); } } // Image context menu options if (mediaType === 'image' && srcURL) { template.push( { label: 'Copy Image', click: () => { mainWindow.webContents.copyImageAt(params.x, params.y); } }, { label: 'Save Image As...', click: () => { mainWindow.webContents.downloadURL(srcURL); } } ); if (selectionText) { template.push({ type: 'separator' }); } } if (selectionText) { template.push( { label: 'Copy', role: 'copy', accelerator: 'CmdOrCtrl+C' } ); } if (isEditable) { if (selectionText) { template.push( { label: 'Cut', role: 'cut', accelerator: 'CmdOrCtrl+X' } ); } template.push( { label: 'Paste', role: 'paste', accelerator: 'CmdOrCtrl+V' } ); if (selectionText) { template.push({ type: 'separator' }); template.push( { label: 'Select All', role: 'selectAll', accelerator: 'CmdOrCtrl+A' } ); } } if (template.length > 0) { const menu = Menu.buildFromTemplate(template); menu.popup(mainWindow); } } // If no text selected, not editable, and not an image, suppress the context menu entirely }); // Load the app if (isDev) { mainWindow.loadURL(VITE_DEV_SERVER); // Open DevTools in development mainWindow.webContents.openDevTools(); } else { // In production, serve from built files mainWindow.loadFile(path.join(__dirname, '../cinny/dist/index.html')); } // Show window when ready mainWindow.once('ready-to-show', () => { // Check for --minimized flag (autostart) if (!process.argv.includes('--minimized')) { mainWindow.show(); } }); // Handle window close - minimize to tray mainWindow.on('close', (event) => { if (!isQuitting) { event.preventDefault(); mainWindow.hide(); return false; } }); mainWindow.on('closed', () => { mainWindow = null; }); // Open external links in default browser mainWindow.webContents.setWindowOpenHandler(({ url, features }) => { if (url.startsWith('http://') || url.startsWith('https://')) { shell.openExternal(url); return { action: 'deny' }; } // Allow blank windows for screen share pop-outs with custom features if (url === '' || url === 'about:blank') { return { action: 'allow', overrideBrowserWindowOptions: { frame: false, resizable: true, backgroundColor: '#000000', webPreferences: { contextIsolation: true, nodeIntegration: false, backgroundThrottling: false, } } }; } return { action: 'allow' }; }); // Navigation handler - open external links in browser mainWindow.webContents.on('will-navigate', (event, url) => { const allowedOrigins = [ 'http://localhost:8080', 'http://localhost:44548', 'http://127.0.0.1:8080', 'http://127.0.0.1:44548' ]; const isAllowed = allowedOrigins.some(origin => url.startsWith(origin)) || url.startsWith('blob:') || url.startsWith('data:'); if (!isAllowed && (url.startsWith('http://') || url.startsWith('https://'))) { event.preventDefault(); shell.openExternal(url); } }); return mainWindow; } function createTray() { const iconPath = getIconPath('icon.png'); const trayIcon = nativeImage.createFromPath(iconPath); tray = new Tray(trayIcon.resize({ width: 16, height: 16 })); const contextMenu = Menu.buildFromTemplate([ { label: 'Show Paarrot', click: () => { if (mainWindow) { mainWindow.show(); mainWindow.focus(); } } }, { type: 'separator' }, { label: 'Quit', click: () => { isQuitting = true; app.quit(); } } ]); tray.setContextMenu(contextMenu); tray.setToolTip('Paarrot'); // Click tray icon to show window tray.on('click', () => { if (mainWindow) { if (mainWindow.isVisible()) { mainWindow.hide(); } else { mainWindow.show(); mainWindow.focus(); } } }); } // App lifecycle app.whenReady().then(() => { createWindow(); createTray(); // Start API server apiServer = new PaarrotAPIServer(mainWindow); apiServer.start().catch(err => { console.error('Failed to start API server:', err); }); // Check for updates on startup (not in development mode) if (!isDev) { // Check for updates on start (after 3 seconds) setTimeout(() => { autoUpdater.checkForUpdates().catch(err => { console.error('Failed to check for updates:', err); }); }, 3000); // Check for updates every 6 hours setInterval(() => { autoUpdater.checkForUpdates().catch(err => { console.error('Failed to check for updates:', err); }); }, 6 * 60 * 60 * 1000); } }); app.on('window-all-closed', () => { // On macOS, apps stay active until Cmd+Q if (process.platform !== 'darwin') { // But we want to stay in tray, so don't quit // app.quit(); } }); app.on('activate', () => { // On macOS, recreate window when dock icon is clicked if (BrowserWindow.getAllWindows().length === 0) { createWindow(); } else if (mainWindow) { mainWindow.show(); } }); app.on('before-quit', () => { isQuitting = true; // Stop API server if (apiServer) { apiServer.stop(); } }); // ==================== IPC Handlers ==================== // Window controls ipcMain.handle('window:minimize', () => { if (mainWindow) mainWindow.minimize(); }); ipcMain.handle('window:maximize', () => { if (mainWindow) { if (mainWindow.isMaximized()) { mainWindow.unmaximize(); } else { mainWindow.maximize(); } } }); ipcMain.handle('window:unmaximize', () => { if (mainWindow) mainWindow.unmaximize(); }); ipcMain.handle('window:close', () => { if (mainWindow) mainWindow.close(); }); ipcMain.handle('window:focus', () => { if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore(); mainWindow.show(); mainWindow.focus(); } }); ipcMain.handle('window:flash-frame', (event, flash = true) => { console.log('[Main] window:flash-frame called with:', flash); if (mainWindow) { console.log('[Main] Calling flashFrame on mainWindow'); mainWindow.flashFrame(flash); return { success: true }; } console.log('[Main] No mainWindow available'); return { success: false, error: 'No window' }; }); ipcMain.handle('window:is-maximized', () => { return mainWindow ? mainWindow.isMaximized() : false; }); ipcMain.handle('window:start-drag', () => { if (mainWindow) mainWindow.webContents.startDrag({ file: '', icon: nativeImage.createEmpty() }); }); // Get app version (Tauri-compatible API) ipcMain.handle('plugin:app|version', () => { return app.getVersion(); }); // Open external URL ipcMain.handle('open-external-url', async (event, url) => { try { await shell.openExternal(url); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); // Read clipboard image ipcMain.handle('read-clipboard-image', async () => { try { const image = clipboard.readImage(); if (image.isEmpty()) { return { success: true, data: null }; } // Convert to PNG base64 const pngBuffer = image.toPNG(); const base64 = pngBuffer.toString('base64'); return { success: true, data: `data:image/png;base64,${base64}` }; } catch (error) { return { success: false, error: error.message }; } }); // Write text to clipboard ipcMain.handle('write-clipboard-text', async (event, text) => { try { clipboard.writeText(text); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('get-sound-base-url', () => { if (isDev) return null; // renderer uses ./sound/ relative path in dev return `file://${path.join(process.resourcesPath, 'sound').replace(/\\/g, '/')}`; }); // Play notification sound via the renderer's Chromium audio engine. // Chromium has native OGG support on all platforms, so this is always reliable. ipcMain.handle('play-notification-sound', async (event, soundType = 'message') => { if (!mainWindow || mainWindow.isDestroyed()) { return { success: false, error: 'No main window' }; } const soundFile = soundType === 'invite' ? 'invite.ogg' : 'notification.ogg'; // In dev, Vite serves sounds via HTTP so relative path works. // In production, sounds are in resources/sound/ as plain files (extraResources). const audioSrc = isDev ? `./sound/${soundFile}` : `file://${path.join(process.resourcesPath, 'sound', soundFile).replace(/\\/g, '/')}`; try { await mainWindow.webContents.executeJavaScript( `new Audio('${audioSrc}').play().catch(() => {}); true;` ); return { success: true }; } catch (error) { console.error('[Audio] Failed to play sound:', error); return { success: false, error: error.message }; } }); // Get YouTube stream using yt-dlp ipcMain.handle('get-youtube-stream', async (event, url) => { try { // Check if yt-dlp is available try { await execAsync('yt-dlp --version'); } catch { return { success: false, error: 'yt-dlp is not installed. Please install yt-dlp to use YouTube features.' }; } // Get title let title = 'YouTube Video'; try { const titleResult = await execAsync(`yt-dlp --get-title "${url}"`); title = titleResult.stdout.trim(); } catch (e) { console.warn('Failed to get video title:', e.message); } // Get video URL const result = await execAsync( `yt-dlp -g -f "best[height<=1080]/bestvideo[height<=1080]+bestaudio/best" "${url}"` ); const videoUrl = result.stdout.trim().split('\n')[0]; if (!videoUrl) { return { success: false, error: 'yt-dlp returned empty URL' }; } return { success: true, data: { video_url: videoUrl, title } }; } catch (error) { return { success: false, error: `yt-dlp error: ${error.message}` }; } }); // Background sync stubs (desktop doesn't need it) ipcMain.handle('start-background-sync', async () => { return { success: true }; }); ipcMain.handle('stop-background-sync', async () => { return { success: true }; }); ipcMain.handle('get-background-sync-state', async () => { return { success: true, data: 'NotApplicable' }; }); // Auto-updater IPC handlers ipcMain.handle('check-for-updates', async () => { if (isDev) { return { success: false, error: 'Updates are not available in development mode' }; } try { const result = await autoUpdater.checkForUpdates(); return { success: true, data: result }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('download-update', async () => { if (isDev) { return { success: false, error: 'Updates are not available in development mode' }; } try { await autoUpdater.downloadUpdate(); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('install-update', () => { if (isDev) { return { success: false, error: 'Updates are not available in development mode' }; } autoUpdater.quitAndInstall(); return { success: true }; }); ipcMain.handle('download-and-install-update', async () => { if (isDev) { return { success: false, error: 'Updates are not available in development mode' }; } try { await autoUpdater.downloadUpdate(); autoUpdater.quitAndInstall(); return { success: true }; } catch (error) { return { success: false, error: error.message }; } }); // Screen capture / desktopCapturer handler ipcMain.handle('get-desktop-sources', async (event, opts) => { try { const sources = await desktopCapturer.getSources(opts); return { success: true, data: sources }; } catch (error) { console.error('Failed to get desktop sources:', error); return { success: false, error: error.message }; } }); // Desktop notification handler ipcMain.handle('show-notification', async (event, { title, body, icon, roomId, eventId }) => { try { const { Notification } = require('electron'); if (!Notification.isSupported()) { console.error('Notifications not supported on this system'); return { success: false, error: 'Notifications not supported on this system' }; } const notification = new Notification({ title: title || 'Paarrot', body: body || '', icon: icon || getIconPath(process.platform === 'win32' ? 'icon.ico' : 'icon.png'), silent: false, }); notification.on('click', () => { console.log('Paarrot: Notification clicked, roomId:', roomId); if (mainWindow) { console.log('Paarrot: Sending navigation to renderer'); mainWindow.show(); mainWindow.focus(); if (roomId) { mainWindow.webContents.send('notification:navigate', { roomId, eventId }); console.log('Paarrot: Navigation event sent'); } } else { console.log('Paarrot: No mainWindow available'); } }); notification.on('show', () => console.log('Paarrot: Notification shown')); notification.on('failed', (e, err) => console.error('Paarrot: Notification failed:', err)); notification.show(); return { success: true }; } catch (error) { console.error('Failed to show notification:', error); return { success: false, error: error.message }; } }); // Plugin management const getPluginsDir = () => { try { const pluginsPath = path.join(app.getPath('userData'), 'plugins'); if (!fs.existsSync(pluginsPath)) { fs.mkdirSync(pluginsPath, { recursive: true }); } return pluginsPath; } catch (error) { console.error('Failed to get plugins directory:', error); throw error; } }; /** * Resolves plugin entry file from directory. * Supports direct `index.js` or `package.json#main` entries. */ const resolvePluginEntryFromDir = (pluginDir) => { const packageJsonPath = path.join(pluginDir, 'package.json'); if (fs.existsSync(packageJsonPath)) { try { const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); if (typeof packageJson.main === 'string') { const mainPath = path.resolve(pluginDir, packageJson.main); if (fs.existsSync(mainPath)) { return mainPath; } } } catch (error) { console.warn(`Failed to parse package.json in ${pluginDir}:`, error); } } const indexPath = path.join(pluginDir, 'index.js'); return fs.existsSync(indexPath) ? indexPath : null; }; /** * Resolves plugin entry file from extracted plugin directory. * Handles archives that unpack into single nested root folder. */ const resolvePluginEntryPath = (pluginDir) => { const directEntry = resolvePluginEntryFromDir(pluginDir); if (directEntry) { return directEntry; } const childDirectories = fs.readdirSync(pluginDir, { withFileTypes: true }).filter((entry) => entry.isDirectory()); if (childDirectories.length !== 1) { return null; } return resolvePluginEntryFromDir(path.join(pluginDir, childDirectories[0].name)); }; ipcMain.handle('plugin:get-path', async () => { try { // Wait for app to be ready before accessing userData path await app.whenReady(); return { success: true, data: getPluginsDir() }; } catch (error) { return { success: false, error: error.message }; } }); ipcMain.handle('plugin:download', async (event, { pluginId, downloadUrl, name }) => { try { // Wait for app to be ready before accessing userData path await app.whenReady(); const pluginsDir = getPluginsDir(); const pluginDir = path.join(pluginsDir, pluginId); // Check if already installed if (fs.existsSync(pluginDir)) { return { success: false, error: 'Plugin already installed' }; } // Validate downloadUrl if (!downloadUrl || typeof downloadUrl !== 'string' || downloadUrl.trim() === '') { return { success: false, error: 'Plugin metadata missing downloadUrl field' }; } // Download the zip file const tempZipPath = path.join(app.getPath('temp'), `${pluginId}.zip`); await new Promise((resolve, reject) => { const protocol = downloadUrl.startsWith('https') ? https : http; const file = fs.createWriteStream(tempZipPath); protocol.get(downloadUrl, (response) => { if (response.statusCode === 302 || response.statusCode === 301) { // Handle redirect const redirectUrl = response.headers.location; const redirectProtocol = redirectUrl.startsWith('https') ? https : http; redirectProtocol.get(redirectUrl, (redirectResponse) => { redirectResponse.pipe(file); file.on('finish', () => { file.close(resolve); }); }).on('error', reject); } else { response.pipe(file); file.on('finish', () => { file.close(resolve); }); } }).on('error', (err) => { fs.unlinkSync(tempZipPath); reject(err); }); }); // Extract the zip file to temp location first const tempExtractDir = path.join(app.getPath('temp'), `${pluginId}-extract`); if (fs.existsSync(tempExtractDir)) { fs.rmSync(tempExtractDir, { recursive: true, force: true }); } fs.mkdirSync(tempExtractDir, { recursive: true }); const zip = new AdmZip(tempZipPath); zip.extractAllTo(tempExtractDir, true); // Clean up temp zip fs.unlinkSync(tempZipPath); // GitHub archives extract to repo-name-branch/ folder, flatten it const extractedContents = fs.readdirSync(tempExtractDir); let sourceDir = tempExtractDir; // If single folder exists, move contents up one level if (extractedContents.length === 1) { const singleItem = path.join(tempExtractDir, extractedContents[0]); if (fs.statSync(singleItem).isDirectory()) { sourceDir = singleItem; } } // Move contents to final plugin directory fs.mkdirSync(pluginDir, { recursive: true }); const files = fs.readdirSync(sourceDir); for (const file of files) { const srcPath = path.join(sourceDir, file); const destPath = path.join(pluginDir, file); fs.renameSync(srcPath, destPath); } // Clean up temp extract dir fs.rmSync(tempExtractDir, { recursive: true, force: true }); // Store metadata const metadataPath = path.join(pluginDir, 'plugin-metadata.json'); fs.writeFileSync(metadataPath, JSON.stringify({ id: pluginId, name: name, installedDate: new Date().toISOString(), }, null, 2)); return { success: true, data: { path: pluginDir } }; } catch (error) { console.error('Failed to download plugin:', error); return { success: false, error: error.message }; } }); ipcMain.handle('plugin:list', async () => { try { // Wait for app to be ready before accessing userData path await app.whenReady(); const pluginsDir = getPluginsDir(); const plugins = []; if (fs.existsSync(pluginsDir)) { const items = fs.readdirSync(pluginsDir); for (const item of items) { const itemPath = path.join(pluginsDir, item); const stats = fs.statSync(itemPath); if (stats.isDirectory()) { const metadataPath = path.join(itemPath, 'plugin-metadata.json'); if (fs.existsSync(metadataPath)) { try { const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); plugins.push({ id: item, path: itemPath, ...metadata }); } catch (err) { console.error(`Failed to read metadata for plugin ${item}:`, err); } } } } } return { success: true, data: plugins }; } catch (error) { console.error('Failed to list plugins:', error); return { success: false, error: error.message }; } }); ipcMain.handle('plugin:uninstall', async (event, pluginId) => { try { // Wait for app to be ready before accessing userData path await app.whenReady(); const pluginsDir = getPluginsDir(); const pluginDir = path.join(pluginsDir, pluginId); if (!fs.existsSync(pluginDir)) { return { success: false, error: 'Plugin not found' }; } // Recursively delete the plugin directory const deleteDir = (dirPath) => { if (fs.existsSync(dirPath)) { fs.readdirSync(dirPath).forEach((file) => { const curPath = path.join(dirPath, file); if (fs.lstatSync(curPath).isDirectory()) { deleteDir(curPath); } else { fs.unlinkSync(curPath); } }); fs.rmdirSync(dirPath); } }; deleteDir(pluginDir); return { success: true }; } catch (error) { console.error('Failed to uninstall plugin:', error); return { success: false, error: error.message }; } }); ipcMain.handle('plugin:read-code', async (event, pluginId) => { try { await app.whenReady(); const pluginsDir = getPluginsDir(); const pluginDir = path.join(pluginsDir, pluginId); const pluginPath = resolvePluginEntryPath(pluginDir); if (!pluginPath || !fs.existsSync(pluginPath)) { return { success: false, error: 'Plugin index.js not found' }; } const code = fs.readFileSync(pluginPath, 'utf8'); return { success: true, data: code }; } catch (error) { console.error('Failed to read plugin code:', error); return { success: false, error: error.message }; } }); console.log('Paarrot Electron main process started'); console.log('Development mode:', isDev); console.log('Platform:', process.platform); console.log('Plugin handlers registered');