Files
cinny-desktop/electron/main.js
Max Litruv Boonzaayer 51b13e0fcd
Some checks failed
Build / increment-version (push) Successful in 11s
Build / build-linux (push) Successful in 2m5s
Build / build-windows (push) Failing after 4m44s
Build / create-release (push) Successful in 44s
autoupdate
2026-05-13 04:15:20 +10:00

1093 lines
32 KiB
JavaScript

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');