690 lines
19 KiB
TypeScript
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();
|