690 lines
19 KiB
TypeScript

import { app, BrowserWindow, Menu, Tray, nativeImage, ipcMain } from 'electron';
import * as path from 'path';
import { ConfigManager } from './services/configManager';
import { SyncManager, SyncConfig } from './services/syncManager';
class RekordboxSyncApp {
private mainWindow: BrowserWindow | null = null;
private tray: Tray | null = null;
private configManager: ConfigManager;
private syncManager: SyncManager | null = null;
private isQuitting: boolean = false;
constructor() {
this.configManager = new ConfigManager();
// Set memory limits to prevent crashes
process.setMaxListeners(50);
// Handle uncaught exceptions to prevent EPIPE popups
process.on('uncaughtException', (error) => {
console.error('❌ Uncaught Exception:', error);
this.handleUncaughtException(error);
});
// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
console.error('❌ Unhandled Rejection at:', promise, 'reason:', reason);
this.handleUnhandledRejection(reason, promise);
});
// App event handlers
app.whenReady().then(() => this.initialize());
app.on('window-all-closed', () => this.handleWindowAllClosed());
app.on('before-quit', () => this.handleBeforeQuit());
app.on('activate', () => this.handleActivate());
// IPC handlers
this.setupIpcHandlers();
}
/**
* Initialize the application
*/
private async initialize(): Promise<void> {
await this.createMainWindow();
this.createTray();
this.setupMenu();
// Auto-start sync if configured
const syncConfig = this.configManager.getSyncConfig();
if (syncConfig.autoStart) {
this.startSync();
}
// Start periodic status updates to keep UI informed
this.startPeriodicStatusUpdates();
}
/**
* Create the main application window
*/
private async createMainWindow(): Promise<void> {
this.mainWindow = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 800,
minHeight: 600,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js'),
},
icon: path.join(__dirname, '../assets/icon.png'),
show: false,
});
// Load the main HTML file
await this.mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
// Show window when ready
this.mainWindow.once('ready-to-show', () => {
this.mainWindow?.show();
});
// Handle window close
this.mainWindow.on('close', (event) => {
if (!this.isQuitting) {
event.preventDefault();
this.mainWindow?.hide();
}
});
// Handle window closed
this.mainWindow.on('closed', () => {
this.mainWindow = null;
});
// Add error handling for the renderer process
this.mainWindow.webContents.on('crashed', (event, killed) => {
console.error('❌ Renderer process crashed:', { event, killed });
this.handleRendererCrash();
});
this.mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
console.error('❌ Renderer failed to load:', { errorCode, errorDescription });
this.handleRendererFailure();
});
}
/**
* Create system tray icon
*/
private createTray(): void {
const iconPath = path.join(__dirname, '../assets/icon.png');
const icon = nativeImage.createFromPath(iconPath);
this.tray = new Tray(icon);
this.tray.setToolTip('Rekordbox Sync');
this.tray.on('click', () => {
if (this.mainWindow?.isVisible()) {
this.mainWindow.hide();
} else {
this.mainWindow?.show();
this.mainWindow?.focus();
}
});
this.tray.on('right-click', () => {
this.showTrayContextMenu();
});
}
/**
* Show tray context menu
*/
private showTrayContextMenu(): void {
if (!this.tray) return;
const contextMenu = Menu.buildFromTemplate([
{
label: 'Show App',
click: () => {
this.mainWindow?.show();
this.mainWindow?.focus();
},
},
{
label: 'Start Sync',
click: () => this.startSync(),
enabled: !this.syncManager?.getState().isRunning,
},
{
label: 'Stop Sync',
click: () => this.stopSync(),
enabled: this.syncManager?.getState().isRunning || false,
},
{ type: 'separator' },
{
label: 'Quit',
click: () => {
this.isQuitting = true;
app.quit();
},
},
]);
this.tray.popUpContextMenu(contextMenu);
}
/**
* Setup application menu
*/
private setupMenu(): void {
const template: Electron.MenuItemConstructorOptions[] = [
{
label: 'File',
submenu: [
{
label: 'Settings',
accelerator: 'CmdOrCtrl+,',
click: () => this.showSettings(),
},
{ type: 'separator' },
{
label: 'Quit',
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
click: () => {
this.isQuitting = true;
app.quit();
},
},
],
},
{
label: 'Sync',
submenu: [
{
label: 'Start Sync',
click: () => this.startSync(),
enabled: !this.syncManager?.getState().isRunning,
},
{
label: 'Stop Sync',
click: () => this.stopSync(),
enabled: this.syncManager?.getState().isRunning || false,
},
{ type: 'separator' },
{
label: 'Force Full Sync',
click: () => this.forceFullSync(),
},
],
},
{
label: 'View',
submenu: [
{ role: 'reload' },
{ role: 'forceReload' },
{ role: 'toggleDevTools' },
{ type: 'separator' },
{ role: 'resetZoom' },
{ role: 'zoomIn' },
{ role: 'zoomOut' },
{ type: 'separator' },
{ role: 'togglefullscreen' },
],
},
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
}
/**
* Setup IPC handlers
*/
private setupIpcHandlers(): void {
console.log('🔌 Setting up IPC handlers...');
// Sync control
ipcMain.handle('sync:start', () => {
return this.startSync();
});
ipcMain.handle('sync:stop', () => {
return this.stopSync();
});
ipcMain.handle('sync:force-full', () => {
return this.forceFullSync();
});
ipcMain.handle('sync:trigger-immediate', () => {
return this.triggerImmediateSync();
});
ipcMain.handle('sync:get-status', () => {
return this.syncManager?.getState() || null;
});
// Configuration
ipcMain.handle('config:get', () => this.configManager.getConfig());
ipcMain.handle('config:update-s3', (event, config) => this.configManager.updateS3Config(config));
ipcMain.handle('config:update-sync', (event, config) => this.configManager.updateSyncConfig(config));
ipcMain.handle('config:update-ui', (event, config) => this.configManager.updateUIConfig(config));
ipcMain.handle('config:test-s3', () => this.configManager.testS3Connection());
ipcMain.handle('config:export-env', async (event, exportPath) => {
try {
await this.configManager.exportToEnv(exportPath);
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
});
// File operations
ipcMain.handle('dialog:select-folder', async () => {
// This would need to be implemented with electron dialog
return null;
});
}
/**
* Start sync
*/
private async startSync(): Promise<void> {
try {
// Force reload .env file to ensure latest changes are picked up
this.configManager.reloadEnvironmentVariables();
// Always get fresh config and recreate service to ensure .env changes are picked up
const appConfig = this.configManager.getConfig();
const syncConfig: SyncConfig = {
s3: {
endpoint: appConfig.s3.endpoint,
region: appConfig.s3.region,
accessKeyId: appConfig.s3.accessKeyId,
secretAccessKey: appConfig.s3.secretAccessKey,
bucketName: appConfig.s3.bucketName,
useSSL: appConfig.s3.useSSL
},
sync: {
localPath: appConfig.sync.localPath,
interval: appConfig.sync.syncInterval,
autoStart: appConfig.sync.autoStart,
conflictResolution: appConfig.sync.conflictResolution
}
};
// Recreate sync manager to ensure new config is used
if (this.syncManager) {
this.syncManager.destroy();
}
this.syncManager = new SyncManager(syncConfig);
this.setupSyncManagerEventHandlers();
await this.syncManager.startSync();
} catch (error) {
console.error('❌ Failed to start sync:', error);
throw error;
}
}
/**
* Stop sync
*/
private stopSync(): void {
try {
if (this.syncManager) {
this.syncManager.stopSync();
}
} catch (error) {
console.error('❌ Failed to stop sync:', error);
}
}
/**
* Trigger an immediate sync to propagate local changes (including deletions)
*/
private async triggerImmediateSync(): Promise<void> {
try {
if (this.syncManager) {
await this.syncManager.triggerImmediateSync();
}
} catch (error) {
console.error('❌ Immediate sync failed:', error);
}
}
/**
* Force full sync
*/
private async forceFullSync(): Promise<void> {
try {
if (this.syncManager) {
await this.syncManager.forceFullSync();
console.log('✅ Force full sync completed');
}
} catch (error) {
console.error('❌ Force full sync failed:', error);
}
}
/**
* Setup sync manager event handlers
*/
private setupSyncManagerEventHandlers(): void {
if (!this.syncManager) return;
// Helper function to safely send messages
const safeSend = (channel: string, data: any) => {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
this.mainWindow.webContents.send(channel, data);
}
} catch (error) {
console.warn(`⚠️ Failed to send message to renderer (${channel}):`, error);
}
};
// Sync manager events
this.syncManager.on('stateChanged', (state: any) => {
console.log('📊 Sync state changed:', state);
// Ensure the state is properly formatted for the UI
const uiState = {
...state,
// Add actual file counts from the file system
actualFileCount: this.getActualLocalFileCount(),
lastUpdate: new Date().toISOString()
};
safeSend('sync-status-changed', uiState);
this.updateTrayTooltip(uiState);
});
// Add more detailed sync events
this.syncManager.on('fileChanged', (event: any) => {
safeSend('file-changed', {
path: event.path || event,
type: event.type,
timestamp: new Date().toISOString()
});
});
this.syncManager.on('fileAdded', (filePath: string) => {
safeSend('file-added', { path: filePath, timestamp: new Date().toISOString() });
});
this.syncManager.on('fileRemoved', (filePath: string) => {
safeSend('file-removed', { path: filePath, timestamp: new Date().toISOString() });
// Trigger immediate sync to propagate deletion to S3
this.triggerImmediateSync();
});
this.syncManager.on('syncError', (error: any) => {
console.log('💥 Sync error:', error);
safeSend('sync-error', error);
});
this.syncManager.on('syncStarted', () => {
console.log('🚀 Sync started');
safeSend('sync-started', {});
});
this.syncManager.on('syncStopped', () => {
console.log('⏹️ Sync stopped');
safeSend('sync-stopped', {});
});
this.syncManager.on('forceSyncCompleted', () => {
console.log('🔄 Force sync completed');
safeSend('force-sync-completed', {});
});
this.syncManager.on('awsOutput', (output: any) => {
console.log('🔍 AWS S3 output:', output);
safeSend('aws-output', output);
});
}
/**
* Update tray tooltip with sync status
*/
private updateTrayTooltip(status: any): void {
if (!this.tray) return;
const statusText = status.isRunning ? 'Running' : 'Stopped';
const phaseText = status.currentPhase ? ` - ${status.currentPhase}` : '';
this.tray.setToolTip(`Rekordbox Sync - ${statusText}${phaseText}`);
}
/**
* Show settings dialog
*/
private showSettings(): void {
if (this.mainWindow) {
this.mainWindow.webContents.send('show-settings');
}
}
/**
* Handle window all closed event
*/
private handleWindowAllClosed(): void {
if (process.platform !== 'darwin') {
app.quit();
}
}
/**
* Handle before quit event
*/
private handleBeforeQuit(): void {
this.isQuitting = true;
this.stopSync();
}
/**
* Handle activate event (macOS)
*/
private handleActivate(): void {
if (BrowserWindow.getAllWindows().length === 0) {
this.createMainWindow();
} else {
this.mainWindow?.show();
}
}
/**
* Handle renderer process crash
*/
private handleRendererCrash(): void {
console.log('🔄 Attempting to recover from renderer crash...');
// Stop sync to prevent further issues
if (this.syncManager?.getState().isRunning) {
console.log('⏹️ Stopping sync due to renderer crash');
this.stopSync();
}
// Try to reload the renderer
setTimeout(() => {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
console.log('🔄 Reloading renderer...');
this.mainWindow.reload();
}
} catch (error) {
console.error('❌ Failed to reload renderer:', error);
}
}, 2000);
}
/**
* Handle renderer process failure
*/
private handleRendererFailure(): void {
console.log('🔄 Attempting to recover from renderer failure...');
// Try to reload the renderer
setTimeout(() => {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
console.log('🔄 Reloading renderer...');
this.mainWindow.reload();
}
} catch (error) {
console.error('❌ Failed to reload renderer:', error);
}
}, 1000);
}
/**
* Handle uncaught exceptions to prevent EPIPE popups
*/
private handleUncaughtException(error: Error): void {
console.error('🚨 Handling uncaught exception:', error);
// Don't show error popup for EPIPE errors
if (error.message.includes('EPIPE') || error.message.includes('write EPIPE')) {
console.log('🔇 Suppressing EPIPE error popup');
return;
}
// For other errors, try to recover gracefully
console.log('🔄 Attempting to recover from uncaught exception...');
// Stop sync to prevent further issues
if (this.syncManager?.getState().isRunning) {
console.log('⏹️ Stopping sync due to uncaught exception');
this.stopSync();
}
}
/**
* Start periodic status updates to keep UI informed
*/
private startPeriodicStatusUpdates(): void {
// Send status updates every 2 seconds to keep UI responsive
setInterval(() => {
if (this.syncManager) {
const status = this.syncManager.getState();
// Add actual file counts to the status
if (status.stats) {
try {
const fs = require('fs');
const path = require('path');
let localFileCount = 0;
const countFiles = (dirPath: string) => {
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
countFiles(fullPath);
} else if (stat.isFile()) {
const ext = path.extname(item).toLowerCase();
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
localFileCount++;
}
}
}
} catch (error) {
// Ignore errors for individual files
}
};
const syncPath = this.configManager.getSyncConfig().localPath;
if (fs.existsSync(syncPath)) {
countFiles(syncPath);
}
// Update the stats with actual file count
status.stats.totalFilesSynced = localFileCount;
status.stats.filesDownloaded = localFileCount;
} catch (error) {
console.warn('⚠️ Error updating file count:', error);
}
}
this.safeSendToRenderer('sync-status-changed', status);
}
}, 2000);
}
/**
* Get actual file count from local sync folder
*/
private getActualLocalFileCount(): number {
try {
const fs = require('fs');
const path = require('path');
let fileCount = 0;
const syncPath = this.configManager.getSyncConfig().localPath;
if (!fs.existsSync(syncPath)) {
return 0;
}
const countFiles = (dirPath: string) => {
try {
const items = fs.readdirSync(dirPath);
for (const item of items) {
const fullPath = path.join(dirPath, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
countFiles(fullPath);
} else if (stat.isFile()) {
const ext = path.extname(item).toLowerCase();
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
fileCount++;
}
}
}
} catch (error) {
console.warn(`⚠️ Error counting files in ${dirPath}:`, error);
}
};
countFiles(syncPath);
return fileCount;
} catch (error) {
console.error('❌ Error getting actual file count:', error);
return 0;
}
}
/**
* Safe send to renderer with better error handling
*/
private safeSendToRenderer(channel: string, data: any): void {
try {
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
console.log(`📤 Periodic update to renderer (${channel}):`, data);
this.mainWindow.webContents.send(channel, data);
}
} catch (error) {
console.warn(`⚠️ Failed to send periodic update to renderer (${channel}):`, error);
}
}
/**
* Handle unhandled promise rejections
*/
private handleUnhandledRejection(reason: any, promise: Promise<any>): void {
console.error('🚨 Handling unhandled rejection:', { reason, promise });
// Don't show error popup for EPIPE-related rejections
if (reason && reason.message && reason.message.includes('EPIPE')) {
console.log('🔇 Suppressing EPIPE rejection popup');
return;
}
// For other rejections, log and continue
console.log('🔄 Continuing after unhandled rejection...');
}
}
// Create and run the application
new RekordboxSyncApp();