diff --git a/packages/desktop-sync/package.json b/packages/desktop-sync/package.json
new file mode 100644
index 0000000..cd6bf4b
--- /dev/null
+++ b/packages/desktop-sync/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "rekordbox-sync",
+ "version": "1.0.0",
+ "description": "Desktop companion for bidirectional S3 sync with Rekordbox music library",
+ "main": "dist/main.js",
+ "scripts": {
+ "dev": "tsc && electron .",
+ "build": "tsc",
+ "start": "electron dist/main.js",
+ "package": "tsc && electron-builder",
+ "dist": "tsc && electron-builder --publish=never",
+ "postinstall": "node scripts/check-aws-cli.js",
+ "check-aws-cli": "node scripts/check-aws-cli.js",
+ "install-aws-cli": "./install-aws-cli.sh"
+ },
+ "keywords": [
+ "music",
+ "sync",
+ "s3",
+ "rekordbox",
+ "garage"
+ ],
+ "author": "Geert Rademakers",
+ "license": "MIT",
+ "devDependencies": {
+ "@types/node": "^20.0.0",
+ "electron": "^28.0.0",
+ "electron-builder": "^24.0.0",
+ "typescript": "^5.0.0"
+ },
+ "dependencies": {
+ "chokidar": "^3.5.0",
+ "dotenv": "^17.2.1",
+ "electron-store": "^8.1.0"
+ },
+ "build": {
+ "appId": "com.geertrademakers.rekordbox-sync",
+ "productName": "Rekordbox Sync",
+ "directories": {
+ "output": "dist-build"
+ },
+ "files": [
+ "dist/**/*",
+ "node_modules/**/*"
+ ],
+ "mac": {
+ "category": "public.app-category.music"
+ },
+ "win": {
+ "target": "nsis"
+ },
+ "linux": {
+ "target": "AppImage"
+ }
+ }
+}
diff --git a/packages/desktop-sync/renderer/index.html b/packages/desktop-sync/renderer/index.html
new file mode 100644
index 0000000..b535b0e
--- /dev/null
+++ b/packages/desktop-sync/renderer/index.html
@@ -0,0 +1,225 @@
+
+
+
+
+
+ Rekordbox Sync
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sync Status:
+ Stopped
+
+
+ Last Sync:
+ Never
+
+
+ Local Files:
+ 0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Sync Configuration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Interface
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/desktop-sync/renderer/renderer.js b/packages/desktop-sync/renderer/renderer.js
new file mode 100644
index 0000000..8a6ab42
--- /dev/null
+++ b/packages/desktop-sync/renderer/renderer.js
@@ -0,0 +1,584 @@
+class RekordboxSyncRenderer {
+ constructor() {
+ this.initializeElements();
+ this.setupEventListeners();
+ this.setupElectronListeners();
+ this.loadInitialState();
+ }
+
+ /**
+ * Initialize DOM elements
+ */
+ initializeElements() {
+ // Buttons
+ this.startBtn = document.getElementById('startSyncBtn');
+ this.stopBtn = document.getElementById('stopSyncBtn');
+ this.exportEnvBtn = document.getElementById('exportEnvBtn');
+
+ // Status elements
+ this.syncStatusElement = document.getElementById('syncStatus');
+ this.filesSyncedElement = document.getElementById('filesSynced');
+ this.lastSyncElement = document.getElementById('lastSync');
+ this.syncDetailsElement = document.getElementById('syncDetails');
+
+ // Activity log
+ this.activityLogElement = document.getElementById('activityLog');
+
+ // Configuration elements
+ this.s3EndpointInput = document.getElementById('s3Endpoint');
+ this.s3AccessKeyInput = document.getElementById('s3AccessKey');
+ this.s3SecretKeyInput = document.getElementById('s3SecretKey');
+ this.s3BucketInput = document.getElementById('s3Bucket');
+ this.s3RegionInput = document.getElementById('s3Region');
+ this.localPathInput = document.getElementById('localPath');
+ this.syncIntervalInput = document.getElementById('syncInterval');
+
+ // Save button
+ this.saveConfigBtn = document.getElementById('saveConfigBtn');
+ }
+
+ /**
+ * Setup event listeners for UI elements
+ */
+ setupEventListeners() {
+ // Sync control buttons
+ if (this.startBtn) {
+ this.startBtn.addEventListener('click', () => this.startSync());
+ }
+
+ if (this.stopBtn) {
+ this.stopBtn.addEventListener('click', () => this.stopSync());
+ }
+
+ if (this.exportEnvBtn) {
+ this.exportEnvBtn.addEventListener('click', () => this.exportToEnv());
+ }
+
+ // Configuration save button
+ if (this.saveConfigBtn) {
+ this.saveConfigBtn.addEventListener('click', () => this.saveConfiguration());
+ }
+
+ // Force sync button
+ const forceSyncBtn = document.getElementById('forceSyncBtn');
+ if (forceSyncBtn) {
+ forceSyncBtn.addEventListener('click', () => this.forceFullSync());
+ }
+
+ // Immediate sync button
+ const immediateSyncBtn = document.getElementById('immediateSyncBtn');
+ if (immediateSyncBtn) {
+ immediateSyncBtn.addEventListener('click', () => this.triggerImmediateSync());
+ }
+ }
+
+ /**
+ * Setup Electron IPC listeners
+ */
+ setupElectronListeners() {
+ console.log('๐ Setting up Electron IPC listeners...');
+ console.log('๐ Window object:', window);
+ console.log('๐ Electron API object:', window.electronAPI);
+
+ if (!window.electronAPI) {
+ console.error('โ Electron API not available');
+ console.error('โ This means the preload script failed to load');
+ return;
+ }
+
+ console.log('โ
Electron API is available, setting up listeners...');
+
+ // Sync status updates
+ window.electronAPI.on('sync-status-changed', (status) => {
+ console.log('๐ Received sync status update:', status);
+ console.log('๐ Status details:', {
+ isRunning: status.isRunning,
+ currentPhase: status.currentPhase,
+ actualFileCount: status.actualFileCount,
+ stats: status.stats
+ });
+ console.log('๐ Calling updateSyncStatus...');
+ this.updateSyncStatus(status);
+ console.log('โ
updateSyncStatus completed');
+ });
+
+ // File change events
+ window.electronAPI.on('file-changed', (event) => {
+ console.log('๐ File changed:', event);
+ this.addActivityLog('info', `File changed: ${event.path}`);
+ });
+
+ window.electronAPI.on('file-added', (event) => {
+ console.log('โ File added:', event);
+ this.addActivityLog('success', `File added: ${event.path}`);
+ });
+
+ window.electronAPI.on('file-removed', (event) => {
+ console.log('โ File removed:', event);
+ this.addActivityLog('info', `File removed: ${event.path}`);
+ });
+
+ // Sync operation updates
+ window.electronAPI.on('sync-operation-started', (operation) => {
+ console.log('๐ Operation started:', operation);
+ this.addActivityLog('info', `Started ${operation.type}: ${operation.s3Key || operation.localPath}`);
+ });
+
+ window.electronAPI.on('sync-operation-completed', (operation) => {
+ console.log('โ
Operation completed:', operation);
+ this.addActivityLog('success', `Completed ${operation.type}: ${operation.s3Key || operation.localPath}`);
+ });
+
+ window.electronAPI.on('sync-operation-failed', (operation) => {
+ console.log('โ Operation failed:', operation);
+ this.addActivityLog('error', `Failed ${operation.type}: ${operation.s3Key || operation.localPath} - ${operation.error || 'Unknown error'}`);
+ });
+
+ // Sync lifecycle events
+ window.electronAPI.on('sync-started', (type) => {
+ console.log('๐ Sync started:', type);
+ this.addActivityLog('info', `Sync started: ${type}`);
+ });
+
+ window.electronAPI.on('sync-completed', (type) => {
+ console.log('๐ Sync completed:', type);
+ this.addActivityLog('success', `Sync completed: ${type}`);
+ });
+
+ window.electronAPI.on('sync-error', (error) => {
+ console.log('๐ฅ Sync error:', error);
+ this.addActivityLog('error', `Sync error: ${error.message || 'Unknown error'}`);
+
+ // Update UI to show error state
+ if (this.syncStatusElement) {
+ this.syncStatusElement.textContent = 'Error - Sync failed';
+ this.syncStatusElement.className = 'status-value error';
+ }
+
+ // Re-enable start button on error
+ if (this.startBtn) this.startBtn.disabled = false;
+ if (this.stopBtn) this.stopBtn.disabled = true;
+
+ // Reset progress bar on error
+ const progressBar = document.getElementById('progressBar');
+ if (progressBar) {
+ progressBar.style.width = '0%';
+ }
+ });
+
+ // Engine events
+ window.electronAPI.on('sync-engine-started', () => {
+ console.log('โ
Sync engine started');
+ this.addActivityLog('success', 'Sync engine started');
+ });
+
+ window.electronAPI.on('sync-engine-stopped', () => {
+ console.log('โน๏ธ Sync engine stopped');
+ this.addActivityLog('info', 'Sync engine stopped');
+ });
+
+ // MinIO output events
+ window.electronAPI.on('aws-output', (output) => {
+ console.log('๐ AWS S3 output received:', output);
+ this.addActivityLog('info', `AWS S3 ${output.direction}: ${output.output}`);
+ });
+
+ // File change events
+ window.electronAPI.on('file-changed', (event) => {
+ console.log('๐ File changed:', event);
+ this.addActivityLog('info', `File changed: ${event.path}`);
+ });
+ }
+
+ /**
+ * Load initial application state
+ */
+ async loadInitialState() {
+ try {
+ // Load configuration
+ const config = await window.electronAPI.invoke('config:get');
+ this.populateConfigurationForm(config);
+
+ // Load current sync status
+ const status = await window.electronAPI.invoke('sync:get-status');
+ if (status) {
+ this.updateSyncStatus(status);
+ }
+
+
+
+ } catch (error) {
+ console.error('โ Failed to load initial state:', error);
+ this.addActivityLog('error', `Failed to load initial state: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Start sync
+ */
+ async startSync() {
+ try {
+ this.addActivityLog('info', 'Starting sync...');
+ await window.electronAPI.invoke('sync:start');
+ this.addActivityLog('success', 'Sync started successfully');
+ } catch (error) {
+ console.error('โ Failed to start sync:', error);
+ this.addActivityLog('error', `Failed to start sync: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Stop sync
+ */
+ async stopSync() {
+ try {
+ this.addActivityLog('info', 'Stopping sync...');
+ await window.electronAPI.invoke('sync:stop');
+ this.addActivityLog('info', 'Sync stopped');
+ } catch (error) {
+ console.error('โ Failed to stop sync:', error);
+ this.addActivityLog('error', `Failed to stop sync: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Force full sync
+ */
+ async forceFullSync() {
+ try {
+ this.addActivityLog('info', 'Forcing full sync...');
+ await window.electronAPI.invoke('sync:force-full');
+ this.addActivityLog('success', 'Full sync initiated');
+ } catch (error) {
+ console.error('โ Failed to force full sync:', error);
+ this.addActivityLog('error', `Failed to force full sync: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Update sync status display
+ */
+ updateSyncStatus(status) {
+ if (!status) return;
+
+ // Update main status with more detailed information
+ if (this.syncStatusElement) {
+ if (status.isRunning) {
+ let statusText = 'Running';
+
+ // Add current phase information
+ if (status.currentPhase) {
+ statusText += ` - ${status.currentPhase}`;
+ }
+
+ // Add file counts (cleaner display)
+ if (status.stats && status.stats.totalFilesSynced > 0) {
+ statusText += ` - ${status.stats.totalFilesSynced} local files`;
+ }
+
+ this.syncStatusElement.textContent = statusText;
+ this.syncStatusElement.className = 'status-value running';
+ if (this.startBtn) this.startBtn.disabled = true;
+ if (this.stopBtn) this.stopBtn.disabled = false;
+ } else {
+ if (status.completedCount > 0 && status.pendingCount === 0 && status.inProgressCount === 0) {
+ this.syncStatusElement.textContent = 'Completed';
+ this.syncStatusElement.className = 'status-value completed';
+ } else {
+ this.syncStatusElement.textContent = 'Stopped';
+ this.syncStatusElement.className = 'status-value stopped';
+
+ // Reset progress bar when stopped
+ const progressBar = document.getElementById('progressBar');
+ if (progressBar) {
+ progressBar.style.width = '0%';
+ }
+ }
+ if (this.startBtn) this.startBtn.disabled = false;
+ if (this.stopBtn) this.stopBtn.disabled = true;
+ }
+
+ // Also update the force sync button state
+ const forceSyncBtn = document.getElementById('forceSyncBtn');
+ if (forceSyncBtn) {
+ forceSyncBtn.disabled = status.isRunning;
+ }
+ }
+
+ // Update files synced count with more detail
+ if (this.filesSyncedElement) {
+ console.log('๐ Updating files synced element with:', {
+ actualFileCount: status.actualFileCount,
+ statsTotalFiles: status.stats?.totalFilesSynced
+ });
+
+ // Use actual file count from main process if available
+ if (status.actualFileCount !== undefined) {
+ const text = `${status.actualFileCount} files in local folder`;
+ console.log('๐ Setting files synced text to:', text);
+ this.filesSyncedElement.textContent = text;
+ } else if (status.stats && status.stats.totalFilesSynced > 0) {
+ const text = `${status.stats.totalFilesSynced} files in local folder`;
+ console.log('๐ Setting files synced text to:', text);
+ this.filesSyncedElement.textContent = text;
+ } else {
+ console.log('๐ Setting files synced text to: 0 files');
+ this.filesSyncedElement.textContent = '0 files';
+ }
+ } else {
+ console.warn('โ ๏ธ filesSyncedElement not found!');
+ }
+
+ // Update last sync time
+ if (this.lastSyncElement) {
+ if (status.lastSync) {
+ const date = new Date(status.lastSync);
+ this.lastSyncElement.textContent = date.toLocaleString();
+ } else {
+ this.lastSyncElement.textContent = 'Never';
+ }
+ }
+
+ // Update detailed status
+ this.updateDetailedStatus(status);
+
+ // Update phase and progress
+ if (status.currentPhase && status.progressMessage) {
+ this.addActivityLog('info', `${status.currentPhase}: ${status.progressMessage}`);
+ }
+
+ // Update progress bar
+ if (status.progress && status.progress.percent > 0) {
+ const progressBar = document.getElementById('progressBar');
+ if (progressBar) {
+ progressBar.style.width = `${status.progress.percent}%`;
+ }
+
+ // Only log progress changes to reduce spam
+ const currentProgress = status.progress.percent;
+ if (!this.lastLoggedProgress || Math.abs(currentProgress - this.lastLoggedProgress) >= 10) {
+ const progressText = `Progress: ${currentProgress}% - ${status.progress.message || 'Syncing files...'}`;
+ this.addActivityLog('info', progressText);
+ this.lastLoggedProgress = currentProgress;
+ }
+ }
+
+ // Add file count updates (but only when they change significantly)
+ if (status.stats && status.stats.totalFilesSynced > 0) {
+ const currentFileCount = status.stats.totalFilesSynced;
+ if (!this.lastLoggedFileCount || Math.abs(currentFileCount - this.lastLoggedFileCount) >= 50) {
+ const fileText = `Local files: ${currentFileCount} (${status.stats.filesDownloaded} downloaded, ${status.stats.filesUploaded} uploaded)`;
+ this.addActivityLog('success', fileText);
+ this.lastLoggedFileCount = currentFileCount;
+ }
+ }
+ }
+
+ /**
+ * Update detailed status display
+ */
+ updateDetailedStatus(status) {
+ if (!this.syncDetailsElement) return;
+
+ let detailsHTML = '';
+
+ if (status.pendingCount > 0) {
+ detailsHTML += `๐ ${status.pendingCount} pending
`;
+ }
+
+ if (status.inProgressCount > 0) {
+ detailsHTML += `๐ ${status.inProgressCount} in progress
`;
+ }
+
+ if (status.completedCount > 0) {
+ detailsHTML += `โ
${status.completedCount} completed
`;
+ }
+
+ if (status.failedCount > 0) {
+ detailsHTML += `โ ${status.failedCount} failed
`;
+ }
+
+ if (status.errors && status.errors.length > 0) {
+ detailsHTML += `โ ๏ธ ${status.errors.length} errors
`;
+ }
+
+ this.syncDetailsElement.innerHTML = detailsHTML;
+ }
+
+
+
+ /**
+ * Populate configuration form
+ */
+ populateConfigurationForm(config) {
+ if (!config) return;
+
+ if (this.s3EndpointInput) this.s3EndpointInput.value = config.s3?.endpoint || '';
+ if (this.s3AccessKeyInput) this.s3AccessKeyInput.value = config.s3?.accessKeyId || '';
+ if (this.s3SecretKeyInput) this.s3SecretKeyInput.value = config.s3?.secretAccessKey || '';
+ if (this.s3BucketInput) this.s3BucketInput.value = config.s3?.bucketName || '';
+ if (this.s3RegionInput) this.s3RegionInput.value = config.s3?.region || '';
+ if (this.localPathInput) this.localPathInput.value = config.sync?.localPath || '';
+ if (this.syncIntervalInput) this.syncIntervalInput.value = config.sync?.syncInterval || 30000;
+ }
+
+ /**
+ * Save configuration
+ */
+ async saveConfiguration() {
+ try {
+ const config = {
+ s3: {
+ endpoint: this.s3EndpointInput?.value || '',
+ accessKeyId: this.s3AccessKeyInput?.value || '',
+ secretAccessKey: this.s3SecretKeyInput?.value || '',
+ bucketName: this.s3BucketInput?.value || '',
+ region: this.s3RegionInput?.value || '',
+ },
+ sync: {
+ localPath: this.localPathInput?.value || '',
+ syncInterval: parseInt(this.syncIntervalInput?.value || '30000'),
+ }
+ };
+
+ // Update S3 config
+ await window.electronAPI.invoke('config:update-s3', config.s3);
+
+ // Update sync config
+ await window.electronAPI.invoke('config:update-sync', config.sync);
+
+ this.addActivityLog('success', 'Configuration saved successfully');
+
+ // Test S3 connection
+ try {
+ const testResult = await window.electronAPI.invoke('config:test-s3');
+ if (testResult.success) {
+ this.addActivityLog('success', 'S3 connection test successful');
+ } else {
+ this.addActivityLog('error', `S3 connection test failed: ${testResult.error}`);
+ }
+ } catch (error) {
+ this.addActivityLog('error', `S3 connection test failed: ${error.message || 'Unknown error'}`);
+ }
+
+ } catch (error) {
+ console.error('โ Failed to save configuration:', error);
+ this.addActivityLog('error', `Failed to save configuration: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Force full sync
+ */
+ async forceFullSync() {
+ try {
+ this.addActivityLog('info', '๐ Starting force full sync...');
+ await window.electronAPI.invoke('sync:force-full');
+ this.addActivityLog('success', 'โ
Force full sync completed');
+ } catch (error) {
+ console.error('โ Force full sync failed:', error);
+ this.addActivityLog('error', `โ Force full sync failed: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Trigger immediate sync to propagate local changes
+ */
+ async triggerImmediateSync() {
+ try {
+ this.addActivityLog('info', '๐ Triggering immediate sync to propagate local changes...');
+ await window.electronAPI.invoke('sync:trigger-immediate');
+ this.addActivityLog('success', 'โ
Immediate sync completed');
+ } catch (error) {
+ console.error('โ Immediate sync failed:', error);
+ this.addActivityLog('error', `โ Immediate sync failed: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Export configuration to .env file
+ */
+ async exportToEnv() {
+ try {
+ const result = await window.electronAPI.invoke('config:export-env');
+ if (result.success) {
+ this.addActivityLog('success', 'Configuration exported to .env file successfully');
+ } else {
+ this.addActivityLog('error', `Failed to export configuration: ${result.error}`);
+ }
+ } catch (error) {
+ console.error('โ Failed to export configuration:', error);
+ this.addActivityLog('error', `Failed to export configuration: ${error.message || 'Unknown error'}`);
+ }
+ }
+
+ /**
+ * Add entry to activity log
+ */
+ addActivityLog(type, message) {
+ if (!this.activityLogElement) return;
+
+ const timestamp = new Date().toLocaleTimeString();
+ const logEntry = document.createElement('div');
+ logEntry.className = `log-entry ${type}`;
+ logEntry.innerHTML = `
+ [${timestamp}]
+ ${message}
+ `;
+
+ this.activityLogElement.appendChild(logEntry);
+
+ // Scroll to bottom
+ this.activityLogElement.scrollTop = this.activityLogElement.scrollHeight;
+
+ // Limit log entries to prevent memory issues
+ const entries = this.activityLogElement.querySelectorAll('.log-entry');
+ if (entries.length > 100) {
+ entries[0].remove();
+ }
+
+ // Update the activity count in the header if it exists
+ this.updateActivityCount();
+ }
+
+ /**
+ * Update activity count in the header
+ */
+ updateActivityCount() {
+ const entries = this.activityLogElement?.querySelectorAll('.log-entry') || [];
+ const count = entries.length;
+
+ // Find and update any activity count display
+ const activityCountElement = document.querySelector('.activity-count');
+ if (activityCountElement) {
+ activityCountElement.textContent = count;
+ }
+ }
+
+ /**
+ * Show notification
+ */
+ showNotification(type, title, message) {
+ // You could implement desktop notifications here
+ }
+}
+
+// Initialize the renderer when DOM is loaded
+document.addEventListener('DOMContentLoaded', () => {
+ try {
+ window.rekordboxSyncRenderer = new RekordboxSyncRenderer();
+ } catch (error) {
+ console.error('โ Failed to initialize renderer:', error);
+ }
+});
+
+// Fallback initialization if DOMContentLoaded doesn't fire
+if (document.readyState === 'loading') {
+ // Wait for DOMContentLoaded
+} else {
+ try {
+ window.rekordboxSyncRenderer = new RekordboxSyncRenderer();
+ } catch (error) {
+ console.error('โ Failed to initialize renderer (fallback):', error);
+ }
+}
diff --git a/packages/desktop-sync/renderer/styles.css b/packages/desktop-sync/renderer/styles.css
new file mode 100644
index 0000000..a5e8f6f
--- /dev/null
+++ b/packages/desktop-sync/renderer/styles.css
@@ -0,0 +1,620 @@
+/* Reset and base styles */
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: #333;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.app-container {
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ background: rgba(255, 255, 255, 0.95);
+ backdrop-filter: blur(10px);
+}
+
+/* Header */
+.app-header {
+ background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
+ color: white;
+ padding: 1rem 1.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ z-index: 100;
+}
+
+.header-left h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.header-left h1 i {
+ color: #3498db;
+}
+
+.header-right {
+ display: flex;
+ gap: 0.5rem;
+}
+
+/* Buttons */
+.btn {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: all 0.2s ease;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ text-decoration: none;
+}
+
+.btn:hover {
+ transform: translateY(-1px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+}
+
+.btn:active {
+ transform: translateY(0);
+}
+
+.btn-primary {
+ background: linear-gradient(135deg, #3498db, #2980b9);
+ color: white;
+}
+
+.btn-primary:hover {
+ background: linear-gradient(135deg, #2980b9, #1f5f8b);
+}
+
+.btn-secondary {
+ background: linear-gradient(135deg, #95a5a6, #7f8c8d);
+ color: white;
+}
+
+.btn-secondary:hover {
+ background: linear-gradient(135deg, #7f8c8d, #6c7b7d);
+}
+
+.btn-danger {
+ background: linear-gradient(135deg, #e74c3c, #c0392b);
+ color: white;
+}
+
+.btn-danger:hover {
+ background: linear-gradient(135deg, #c0392b, #a93226);
+}
+
+.btn-info {
+ background: linear-gradient(135deg, #17a2b8, #138496);
+ color: white;
+}
+
+.btn-info:hover {
+ background: linear-gradient(135deg, #138496, #117a8b);
+}
+
+.btn:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn:disabled:hover {
+ transform: none;
+ box-shadow: none;
+}
+
+/* Main content */
+.main-content {
+ flex: 1;
+ padding: 1.5rem;
+ overflow-y: auto;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ grid-template-rows: auto auto 1fr;
+ gap: 1.5rem;
+ grid-template-areas:
+ "status control"
+ "activity activity"
+ "activity activity";
+}
+
+/* Status Panel */
+.status-panel {
+ grid-area: status;
+ background: white;
+ padding: 1.5rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+}
+
+.status-item {
+ text-align: center;
+}
+
+.status-label {
+ display: block;
+ font-size: 0.8rem;
+ color: #7f8c8d;
+ margin-bottom: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.status-value {
+ display: block;
+ font-size: 1.2rem;
+ font-weight: 600;
+ color: #2c3e50;
+}
+
+#syncStatus {
+ padding: 0.25rem 0.75rem;
+ border-radius: 20px;
+ background: #ecf0f1;
+ color: #7f8c8d;
+}
+
+#syncStatus.running {
+ background: #d5f4e6;
+ color: #27ae60;
+}
+
+#syncStatus.stopped {
+ background: #fadbd8;
+ color: #e74c3c;
+}
+
+#syncStatus.completed {
+ background: #d4edda;
+ color: #28a745;
+}
+
+#syncStatus.error {
+ background: #f8d7da;
+ color: #721c24;
+}
+
+/* Progress Bar */
+.progress-container {
+ width: 100%;
+ height: 8px;
+ background: #e0e0e0;
+ border-radius: 4px;
+ overflow: hidden;
+ margin-top: 0.5rem;
+}
+
+.progress-bar {
+ height: 100%;
+ background: linear-gradient(90deg, #3498db, #2ecc71);
+ border-radius: 4px;
+ transition: width 0.3s ease;
+ position: relative;
+}
+
+.progress-bar::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
+ animation: shimmer 2s infinite;
+}
+
+@keyframes shimmer {
+ 0% { transform: translateX(-100%); }
+ 100% { transform: translateX(100%); }
+}
+
+/* Control Panel */
+.control-panel {
+ grid-area: control;
+ background: white;
+ padding: 1.5rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ display: flex;
+ gap: 1rem;
+ justify-content: center;
+ align-items: center;
+}
+
+
+
+/* Activity Panel */
+.activity-panel {
+ grid-area: activity;
+ background: white;
+ padding: 1.5rem;
+ border-radius: 12px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
+ display: flex;
+ flex-direction: column;
+}
+
+.activity-panel h3 {
+ margin-bottom: 1rem;
+ color: #2c3e50;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.activity-log {
+ flex: 1;
+ overflow-y: auto;
+}
+
+.empty-activity {
+ text-align: center;
+ color: #95a5a6;
+ font-style: italic;
+ padding: 2rem;
+}
+
+.activity-item {
+ padding: 0.75rem;
+ margin-bottom: 0.5rem;
+ border-radius: 6px;
+ background: #f8f9fa;
+ border-left: 4px solid #95a5a6;
+ font-size: 0.9rem;
+}
+
+.activity-item.info {
+ border-left-color: #3498db;
+}
+
+.activity-item.success {
+ border-left-color: #27ae60;
+}
+
+.activity-item.warning {
+ border-left-color: #f39c12;
+}
+
+.activity-item.error {
+ border-left-color: #e74c3c;
+}
+
+.activity-time {
+ font-size: 0.8rem;
+ color: #7f8c8d;
+ margin-bottom: 0.25rem;
+}
+
+.activity-message {
+ color: #2c3e50;
+}
+
+/* Modal */
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5);
+ backdrop-filter: blur(5px);
+}
+
+.modal.show {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal-content {
+ background: white;
+ border-radius: 12px;
+ width: 90%;
+ max-width: 800px;
+ max-height: 90vh;
+ overflow: hidden;
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+ animation: modalSlideIn 0.3s ease-out;
+}
+
+@keyframes modalSlideIn {
+ from {
+ opacity: 0;
+ transform: translateY(-50px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+
+.modal-header {
+ background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
+ color: white;
+ padding: 1.5rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-header h2 {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.close-btn {
+ background: none;
+ border: none;
+ color: white;
+ font-size: 1.5rem;
+ cursor: pointer;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: background-color 0.2s ease;
+}
+
+.close-btn:hover {
+ background-color: rgba(255, 255, 255, 0.1);
+}
+
+.modal-body {
+ padding: 1.5rem;
+ max-height: 60vh;
+ overflow-y: auto;
+}
+
+.modal-footer {
+ padding: 1.5rem;
+ background: #f8f9fa;
+ display: flex;
+ justify-content: flex-end;
+ gap: 1rem;
+}
+
+/* Settings sections */
+.settings-section {
+ margin-bottom: 2rem;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid #ecf0f1;
+}
+
+.settings-section:last-child {
+ border-bottom: none;
+ margin-bottom: 0;
+}
+
+.settings-section h3 {
+ color: #2c3e50;
+ margin-bottom: 1rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.form-group {
+ margin-bottom: 1rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+ color: #2c3e50;
+}
+
+.form-group input[type="text"],
+.form-group input[type="url"],
+.form-group input[type="password"],
+.form-group input[type="number"],
+.form-group select {
+ width: 100%;
+ padding: 0.75rem;
+ border: 2px solid #ecf0f1;
+ border-radius: 6px;
+ font-size: 0.9rem;
+ transition: border-color 0.2s ease;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: #3498db;
+}
+
+.form-group input[type="checkbox"] {
+ margin-right: 0.5rem;
+}
+
+.path-input-group {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.path-input-group input {
+ flex: 1;
+}
+
+/* Notifications */
+.notification-container {
+ position: fixed;
+ top: 20px;
+ right: 20px;
+ z-index: 1001;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.notification {
+ background: white;
+ padding: 1rem 1.5rem;
+ border-radius: 8px;
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
+ border-left: 4px solid #3498db;
+ min-width: 300px;
+ animation: notificationSlideIn 0.3s ease-out;
+}
+
+.notification.success {
+ border-left-color: #27ae60;
+}
+
+.notification.warning {
+ border-left-color: #f39c12;
+}
+
+.notification.error {
+ border-left-color: #e74c3c;
+}
+
+.notification-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+.notification-title {
+ font-weight: 600;
+ color: #2c3e50;
+}
+
+.notification-close {
+ background: none;
+ border: none;
+ color: #95a5a6;
+ cursor: pointer;
+ font-size: 1.2rem;
+ padding: 0;
+ width: 20px;
+ height: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 50%;
+ transition: background-color 0.2s ease;
+}
+
+.notification-close:hover {
+ background-color: #ecf0f1;
+}
+
+.notification-message {
+ color: #7f8c8d;
+ font-size: 0.9rem;
+}
+
+@keyframes notificationSlideIn {
+ from {
+ opacity: 0;
+ transform: translateX(100%);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+/* Responsive design */
+@media (max-width: 768px) {
+ .main-content {
+ grid-template-columns: 1fr;
+ grid-template-areas:
+ "status"
+ "control"
+ "queue"
+ "activity";
+ }
+
+ .status-panel {
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .control-panel {
+ flex-direction: column;
+ }
+
+ .modal-content {
+ width: 95%;
+ margin: 1rem;
+ }
+
+ .path-input-group {
+ flex-direction: column;
+ }
+}
+
+/* Sync Details Styling */
+.sync-details {
+ margin-top: 10px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.status-detail {
+ padding: 4px 8px;
+ border-radius: 4px;
+ font-size: 12px;
+ font-weight: 500;
+}
+
+.status-detail.pending {
+ background-color: #fff3cd;
+ color: #856404;
+ border: 1px solid #ffeaa7;
+}
+
+.status-detail.in-progress {
+ background-color: #d1ecf1;
+ color: #0c5460;
+ border: 1px solid #bee5eb;
+}
+
+.status-detail.completed {
+ background-color: #d4edda;
+ color: #155724;
+ border: 1px solid #c3e6cb;
+}
+
+.status-detail.failed {
+ background-color: #f8d7da;
+ color: #721c24;
+ border: 1px solid #f5c6cb;
+}
+
+.status-detail.errors {
+ background-color: #f8d7da;
+ color: #721c24;
+ border: 1px solid #f5c6cb;
+}
diff --git a/packages/desktop-sync/src/main.ts b/packages/desktop-sync/src/main.ts
new file mode 100644
index 0000000..dfff7ea
--- /dev/null
+++ b/packages/desktop-sync/src/main.ts
@@ -0,0 +1,689 @@
+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 {
+ 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 {
+ 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 {
+ 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 {
+ try {
+ if (this.syncManager) {
+ await this.syncManager.triggerImmediateSync();
+ }
+ } catch (error) {
+ console.error('โ Immediate sync failed:', error);
+ }
+ }
+
+ /**
+ * Force full sync
+ */
+ private async forceFullSync(): Promise {
+ 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): 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();
diff --git a/packages/desktop-sync/src/preload.ts b/packages/desktop-sync/src/preload.ts
new file mode 100644
index 0000000..48182e8
--- /dev/null
+++ b/packages/desktop-sync/src/preload.ts
@@ -0,0 +1,81 @@
+const { contextBridge, ipcRenderer } = require('electron');
+
+// Expose protected methods that allow the renderer process to use
+// the ipcRenderer without exposing the entire object
+contextBridge.exposeInMainWorld('electronAPI', {
+ // Sync control
+ invoke: (channel: string, ...args: any[]) => {
+ const validChannels = [
+ 'sync:start',
+ 'sync:stop',
+ 'sync:force-full',
+ 'sync:trigger-immediate',
+ 'sync:get-status',
+
+ 'config:get',
+ 'config:update-s3',
+ 'config:update-sync',
+ 'config:update-ui',
+ 'config:test-s3',
+ 'config:export-env',
+ 'dialog:select-folder'
+ ];
+
+ if (validChannels.includes(channel)) {
+ return ipcRenderer.invoke(channel, ...args);
+ }
+
+ throw new Error(`Invalid channel: ${channel}`);
+ },
+
+ // Event listeners for sync updates
+ on: (channel: string, func: (...args: any[]) => void) => {
+ const validChannels = [
+ 'sync-status-changed',
+ 'sync-operation-started',
+ 'sync-operation-completed',
+ 'sync-operation-failed',
+ 'sync-started',
+ 'sync-completed',
+ 'sync-error',
+ 'sync-engine-started',
+ 'sync-engine-stopped',
+ 'file-changed',
+ 'aws-output',
+ 'show-settings'
+ ];
+
+ if (validChannels.includes(channel)) {
+ ipcRenderer.on(channel, (event: any, ...args: any[]) => func(...args));
+ }
+ },
+
+ // Remove event listeners
+ removeAllListeners: (channel: string) => {
+ const validChannels = [
+ 'sync-status-changed',
+ 'sync-operation-started',
+ 'sync-operation-completed',
+ 'sync-operation-failed',
+ 'sync-started',
+ 'sync-completed',
+ 'sync-error',
+ 'sync-engine-started',
+ 'sync-engine-stopped',
+ 'file-changed',
+ 'aws-output',
+ 'show-settings'
+ ];
+
+ if (validChannels.includes(channel)) {
+ ipcRenderer.removeAllListeners(channel);
+ }
+ },
+
+ // Export environment variables
+ exportEnv: async (exportPath?: string) => {
+ return await ipcRenderer.invoke('config:export-env', exportPath);
+ }
+});
+
+// Note: Type definitions are handled in a separate .d.ts file
diff --git a/packages/desktop-sync/src/services/awsS3Service.ts b/packages/desktop-sync/src/services/awsS3Service.ts
new file mode 100644
index 0000000..4b9be2f
--- /dev/null
+++ b/packages/desktop-sync/src/services/awsS3Service.ts
@@ -0,0 +1,715 @@
+import { spawn, ChildProcess } from 'child_process';
+import { EventEmitter } from 'events';
+import * as path from 'path';
+import * as fs from 'fs';
+
+export interface AwsS3Config {
+ endpoint: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ bucketName: string;
+ region: string;
+ useSSL: boolean;
+ localPath: string;
+ configManager?: any; // Reference to ConfigManager for persistent storage
+}
+
+export interface AwsS3Status {
+ isRunning: boolean;
+ lastSync: Date | null;
+ filesSynced: number;
+ currentPhase: string;
+ progressMessage: string;
+ progressPercent: number;
+ transferSpeed: string;
+ eta: string;
+}
+
+export class AwsS3Service extends EventEmitter {
+ private config: AwsS3Config;
+ private isRunning: boolean = false;
+ private currentProcess: ChildProcess | null = null;
+ private lastSyncTime: Date | null = null;
+ private currentPhase: string = 'idle';
+ private progressMessage: string = 'Ready';
+ private progressPercent: number = 0;
+ private transferSpeed: string = '';
+ private eta: string = '';
+ private filesSynced: number = 0;
+ private watchProcesses: ChildProcess[] = [];
+ private retryCount: number = 0;
+ private maxRetries: number = 3;
+
+ constructor(config: AwsS3Config) {
+ super();
+ this.config = config;
+ }
+
+ /**
+ * Start AWS S3 bidirectional sync
+ */
+ async startSync(): Promise {
+ if (this.isRunning) {
+ throw new Error('Sync is already running');
+ }
+
+ this.isRunning = true;
+ this.currentPhase = 'starting';
+ this.progressMessage = 'Initializing AWS S3 sync...';
+ this.emitStatusUpdate();
+
+ try {
+ // First, ensure AWS CLI is available
+ await this.ensureAwsCliAvailable();
+
+ // Configure AWS credentials for Garage
+ await this.configureAwsCredentials();
+
+ // Run the sync strategy
+ await this.runBidirectionalSync();
+
+ } catch (error) {
+ console.error('โ AWS S3 sync failed:', error);
+ this.currentPhase = 'error';
+ this.progressMessage = `Sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
+ this.isRunning = false;
+ this.emitStatusUpdate();
+ throw error;
+ }
+ }
+
+ /**
+ * Stop the current sync process
+ */
+ stopSync(): void {
+ if (!this.isRunning) {
+ return;
+ }
+
+ console.log('โน๏ธ Stopping AWS S3 sync...');
+ this.currentPhase = 'stopping';
+ this.progressMessage = 'Stopping sync...';
+ this.emitStatusUpdate();
+
+ try {
+ // Stop all watch processes
+ this.watchProcesses.forEach(process => {
+ if (!process.killed) {
+ process.kill('SIGTERM');
+ }
+ });
+ this.watchProcesses = [];
+
+ // Stop current process
+ if (this.currentProcess && !this.currentProcess.killed) {
+ this.currentProcess.kill('SIGTERM');
+ }
+
+ } catch (error) {
+ console.error('โ Error stopping AWS S3 service:', error);
+ }
+
+ this.isRunning = false;
+ this.currentProcess = null;
+ this.currentPhase = 'stopped';
+ this.progressMessage = 'Sync stopped';
+ this.emitStatusUpdate();
+ }
+
+ /**
+ * Force a full sync (delete and re-sync)
+ */
+ async forceFullSync(): Promise {
+ if (this.isRunning) {
+ this.stopSync();
+ // Wait a bit for the process to stop
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+
+ console.log('๐ Starting force full sync...');
+ this.currentPhase = 'force-sync';
+ this.progressMessage = 'Preparing force sync...';
+ this.emitStatusUpdate();
+
+ try {
+ await this.ensureAwsCliAvailable();
+ await this.configureAwsCredentials();
+ await this.runForceFullSync();
+ } catch (error) {
+ console.error('โ Force full sync failed:', error);
+ this.currentPhase = 'error';
+ this.progressMessage = `Force sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
+ this.isRunning = false;
+ this.emitStatusUpdate();
+ throw error;
+ }
+ }
+
+ /**
+ * Get current status
+ */
+ getStatus(): AwsS3Status {
+ return {
+ isRunning: this.isRunning,
+ lastSync: this.lastSyncTime,
+ filesSynced: this.filesSynced,
+ currentPhase: this.currentPhase as any,
+ progressMessage: this.progressMessage,
+ progressPercent: this.progressPercent,
+ transferSpeed: this.transferSpeed,
+ eta: this.eta
+ };
+ }
+
+ /**
+ * Check if AWS CLI is available
+ */
+ private async ensureAwsCliAvailable(): Promise {
+ return new Promise((resolve, reject) => {
+ const process = spawn('aws', ['--version'], { stdio: 'pipe' });
+
+ process.on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error('AWS CLI is not available. Please install AWS CLI v2.'));
+ }
+ });
+
+ process.on('error', () => {
+ reject(new Error('AWS CLI is not available. Please install AWS CLI v2.'));
+ });
+ });
+ }
+
+ /**
+ * Configure AWS credentials for Garage
+ */
+ private async configureAwsCredentials(): Promise {
+ return new Promise((resolve, reject) => {
+ // Set environment variables for this process
+ process.env.AWS_ACCESS_KEY_ID = this.config.accessKeyId;
+ process.env.AWS_SECRET_ACCESS_KEY = this.config.secretAccessKey;
+ process.env.AWS_DEFAULT_REGION = this.config.region;
+
+ // Configure AWS CLI for Garage endpoint
+ const configureProcess = spawn('aws', [
+ 'configure', 'set', 'default.s3.endpoint_url', this.config.endpoint
+ ], { stdio: 'pipe' });
+
+ configureProcess.on('close', (code) => {
+ if (code === 0) {
+ resolve();
+ } else {
+ reject(new Error('Failed to configure AWS credentials'));
+ }
+ });
+
+ configureProcess.on('error', (error) => {
+ reject(new Error(`Failed to configure AWS credentials: ${error.message}`));
+ });
+ });
+ }
+
+ /**
+ * Run bidirectional sync strategy
+ */
+ private async runBidirectionalSync(): Promise {
+ return new Promise(async (resolve, reject) => {
+ try {
+ // Step 0: Clean up temporary files before syncing
+ console.log('๐งน Step 0: Cleaning up temporary files...');
+ this.currentPhase = 'cleaning';
+ this.progressMessage = 'Cleaning up temporary files...';
+ this.emitStatusUpdate();
+
+ await this.cleanupTemporaryFiles();
+
+ // Step 1: Initial sync from S3 to local (download everything first time)
+ const lastSync = this.getLastSyncTime();
+ if (!lastSync) {
+ console.log('๐ฅ Step 1: Initial sync - downloading all files from S3...');
+ this.currentPhase = 'downloading';
+ this.progressMessage = 'Initial sync - downloading all files from S3...';
+ this.emitStatusUpdate();
+
+ await this.runAwsCommand([
+ 's3', 'sync',
+ `s3://${this.config.bucketName}/`,
+ this.config.localPath,
+ '--delete', // Remove local files that don't exist in S3
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+ } else {
+ console.log(`๐ฅ Step 1: Incremental sync - downloading new/changed files from S3...`);
+ this.currentPhase = 'downloading';
+ this.progressMessage = 'Incremental sync - downloading new/changed files from S3...';
+ this.emitStatusUpdate();
+
+ await this.runAwsCommand([
+ 's3', 'sync',
+ `s3://${this.config.bucketName}/`,
+ this.config.localPath,
+ '--delete', // Remove local files that don't exist in S3
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+ }
+
+ // Step 2: Upload new/changed files from local to S3 (with delete to propagate local deletions)
+ console.log('๐ค Step 2: Uploading new/changed files to S3 and propagating local deletions...');
+ this.currentPhase = 'uploading';
+ this.progressMessage = 'Uploading new/changed files to S3 and propagating local deletions...';
+ this.emitStatusUpdate();
+
+ await this.runAwsCommand([
+ 's3', 'sync',
+ this.config.localPath,
+ `s3://${this.config.bucketName}/`,
+ '--delete', // Remove S3 files that don't exist locally (this propagates local deletions)
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+
+ // Step 3: Start continuous bidirectional sync
+ console.log('๐ Step 3: Starting continuous bidirectional sync...');
+ this.currentPhase = 'watching';
+ this.progressMessage = 'Continuous bidirectional sync active...';
+ this.emitStatusUpdate();
+
+ await this.startContinuousSync();
+
+ console.log('โ
Bidirectional sync completed successfully');
+ this.currentPhase = 'completed';
+ this.progressMessage = 'Bidirectional sync completed successfully!';
+
+ // Save the last sync time persistently
+ const syncTime = new Date();
+ this.lastSyncTime = syncTime;
+ if (this.config.configManager && this.config.configManager.setLastSyncTime) {
+ this.config.configManager.setLastSyncTime(syncTime);
+ }
+
+ this.emitStatusUpdate();
+ resolve();
+
+ } catch (error) {
+ console.error('โ Bidirectional sync failed:', error);
+ this.currentPhase = 'error';
+ this.progressMessage = `Bidirectional sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
+ this.emitStatusUpdate();
+ reject(error);
+ }
+ });
+ }
+
+ /**
+ * Run force full sync
+ */
+ private async runForceFullSync(): Promise {
+ return new Promise(async (resolve, reject) => {
+ try {
+ // Step 1: Force download from S3 to local (with delete)
+ console.log('๐ฅ Step 1: Force downloading from S3 (with delete)...');
+ this.currentPhase = 'force-downloading';
+ this.progressMessage = 'Force downloading from S3...';
+ this.emitStatusUpdate();
+
+ await this.runAwsCommand([
+ 's3', 'sync',
+ `s3://${this.config.bucketName}/`,
+ this.config.localPath,
+ '--delete',
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+
+ // Step 2: Force upload from local to S3 (with delete)
+ console.log('๐ค Step 2: Force uploading to S3 (with delete)...');
+ this.currentPhase = 'force-uploading';
+ this.progressMessage = 'Force uploading to S3...';
+ this.emitStatusUpdate();
+
+ await this.runAwsCommand([
+ 's3', 'sync',
+ this.config.localPath,
+ `s3://${this.config.bucketName}/`,
+ '--delete',
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+
+ console.log('โ
Force full sync completed successfully');
+ this.currentPhase = 'completed';
+ this.progressMessage = 'Force full sync completed successfully!';
+ this.isRunning = false;
+ this.emitStatusUpdate();
+ resolve();
+
+ } catch (error) {
+ console.error('โ Force full sync failed:', error);
+ this.currentPhase = 'error';
+ this.progressMessage = `Force full sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`;
+ this.isRunning = false;
+ this.emitStatusUpdate();
+ reject(error);
+ }
+ });
+ }
+
+ /**
+ * Start continuous sync with periodic intervals
+ */
+ private async startContinuousSync(): Promise {
+ return new Promise((resolve) => {
+ try {
+ console.log('๐ Starting continuous bidirectional sync...');
+
+ // Set up periodic sync every 30 seconds
+ const syncInterval = setInterval(async () => {
+ if (!this.isRunning) {
+ clearInterval(syncInterval);
+ return;
+ }
+
+ try {
+ console.log('๐ Running periodic bidirectional sync...');
+
+ // Step 1: Download changes from S3 (propagate S3 deletions to local)
+ console.log('๐ฅ Periodic sync: Downloading changes from S3...');
+ await this.runAwsCommand([
+ 's3', 'sync',
+ `s3://${this.config.bucketName}/`,
+ this.config.localPath,
+ '--delete', // Remove local files that don't exist in S3
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+
+ // Step 2: Upload changes to S3 (propagate local deletions to S3)
+ console.log('๐ค Periodic sync: Uploading changes to S3 and propagating local deletions...');
+ await this.runAwsCommand([
+ 's3', 'sync',
+ this.config.localPath,
+ `s3://${this.config.bucketName}/`,
+ '--delete', // Remove S3 files that don't exist locally (this propagates local deletions)
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+
+ console.log('โ
Periodic sync completed');
+
+ } catch (error) {
+ console.error('โ Periodic sync failed:', error);
+ this.emit('syncError', error);
+ }
+ }, 30000); // 30 seconds
+
+ // Store the interval for cleanup
+ this.watchProcesses.push({
+ killed: false,
+ kill: () => clearInterval(syncInterval)
+ } as any);
+
+ // Resolve after a short delay to ensure the interval is set up
+ setTimeout(() => {
+ console.log('โ
Continuous bidirectional sync started');
+ resolve();
+ }, 2000);
+
+ } catch (error) {
+ console.error('โ Failed to start continuous sync:', error);
+ resolve(); // Don't fail the sync for this
+ }
+ });
+ }
+
+ /**
+ * Trigger an immediate sync to propagate local changes (including deletions)
+ */
+ async triggerImmediateSync(): Promise {
+ if (!this.isRunning) {
+ console.log('โ ๏ธ Cannot trigger immediate sync - service not running');
+ return;
+ }
+
+ try {
+ console.log('๐ Triggering immediate sync to propagate local changes...');
+
+ // Step 1: Upload local changes to S3 (including deletions)
+ console.log('๐ค Immediate sync: Uploading local changes and propagating deletions to S3...');
+ await this.runAwsCommand([
+ 's3', 'sync',
+ this.config.localPath,
+ `s3://${this.config.bucketName}/`,
+ '--delete', // Remove S3 files that don't exist locally (this propagates local deletions)
+ '--exclude', '*.tmp',
+ '--exclude', '*.temp',
+ '--exclude', '*.part',
+ '--exclude', '.DS_Store',
+ '--exclude', '*.crdownload'
+ ]);
+
+ console.log('โ
Immediate sync completed');
+
+ } catch (error) {
+ console.error('โ Immediate sync failed:', error);
+ this.emit('syncError', error);
+ }
+ }
+
+ /**
+ * Clean up temporary files before syncing
+ */
+ private async cleanupTemporaryFiles(): Promise {
+ return new Promise((resolve) => {
+ try {
+ console.log('๐งน Cleaning up temporary files in:', this.config.localPath);
+
+ const cleanupDir = (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()) {
+ cleanupDir(fullPath);
+ } else if (stat.isFile()) {
+ const ext = path.extname(item).toLowerCase();
+ const name = item.toLowerCase();
+
+ // Check if it's a temporary file
+ if (ext === '.tmp' || ext === '.temp' || ext === '.part' ||
+ ext === '.crdownload' || name === '.ds_store' ||
+ name.includes('.tmp') || name.includes('.temp') ||
+ name.includes('.part') || name.includes('.crdownload')) {
+ try {
+ fs.unlinkSync(fullPath);
+ console.log('๐๏ธ Removed temporary file:', fullPath);
+ } catch (error) {
+ console.warn('โ ๏ธ Could not remove temporary file:', fullPath, error);
+ }
+ }
+ }
+ }
+ } catch (error) {
+ console.warn('โ ๏ธ Error cleaning directory:', dirPath, error);
+ }
+ };
+
+ if (fs.existsSync(this.config.localPath)) {
+ cleanupDir(this.config.localPath);
+ }
+
+ console.log('โ
Temporary file cleanup completed');
+ resolve();
+ } catch (error) {
+ console.warn('โ ๏ธ Error during temporary file cleanup:', error);
+ resolve(); // Don't fail the sync for cleanup errors
+ }
+ });
+ }
+
+ /**
+ * Run AWS CLI command with retry logic
+ */
+ private async runAwsCommand(command: string[]): Promise {
+ return new Promise((resolve, reject) => {
+ const runCommand = () => {
+ // Add endpoint URL to the command if it's an S3 command
+ const commandWithEndpoint = command[0] === 's3' ?
+ [...command, '--endpoint-url', this.config.endpoint] :
+ command;
+
+ this.currentProcess = spawn('aws', commandWithEndpoint, {
+ stdio: 'pipe',
+ env: {
+ ...process.env,
+ AWS_ACCESS_KEY_ID: this.config.accessKeyId,
+ AWS_SECRET_ACCESS_KEY: this.config.secretAccessKey,
+ AWS_DEFAULT_REGION: this.config.region
+ }
+ });
+
+ this.currentProcess.stdout?.on('data', (data) => {
+ this.parseAwsOutput(data.toString());
+ });
+
+ this.currentProcess.stderr?.on('data', (data) => {
+ this.parseAwsOutput(data.toString());
+ });
+
+ this.currentProcess.on('close', (code) => {
+ if (code === 0) {
+ console.log('โ
AWS CLI command completed successfully');
+ this.retryCount = 0; // Reset retry count on success
+ resolve();
+ } else if (code === null) {
+ console.log('โ ๏ธ AWS CLI command was interrupted (SIGINT/SIGTERM)');
+ resolve(); // Don't treat interruption as an error
+ } else {
+ console.error('โ AWS CLI command failed with code:', code);
+
+ // Retry logic
+ if (this.retryCount < this.maxRetries) {
+ this.retryCount++;
+ console.log(`๐ Retrying command (attempt ${this.retryCount}/${this.maxRetries})...`);
+ setTimeout(() => runCommand(), 2000); // Wait 2 seconds before retry
+ } else {
+ this.retryCount = 0;
+ reject(new Error(`AWS CLI command failed with exit code ${code} after ${this.maxRetries} retries`));
+ }
+ }
+ });
+
+ this.currentProcess.on('error', (error) => {
+ console.error('โ Failed to start AWS CLI command:', error);
+ reject(error);
+ });
+ };
+
+ runCommand();
+ });
+ }
+
+ /**
+ * Parse AWS CLI output for progress and status
+ */
+ private parseAwsOutput(output: string): void {
+ const lines = output.split('\n');
+
+ for (const line of lines) {
+ if (line.trim()) {
+
+
+ // Emit AWS output to renderer for activity log
+ this.emit('awsOutput', {
+ direction: this.getCurrentDirection(),
+ output: line.trim(),
+ timestamp: new Date().toISOString()
+ });
+ }
+
+ // Parse file transfers
+ const transferMatch = line.match(/(\d+)\/(\d+)/);
+ if (transferMatch) {
+ const current = parseInt(transferMatch[1]);
+ const total = parseInt(transferMatch[2]);
+ this.filesSynced = current;
+ this.progressPercent = Math.round((current / total) * 100);
+ this.progressMessage = `${this.getCurrentDirection()}: ${current}/${total} files`;
+ this.emitStatusUpdate();
+ }
+
+ // Parse progress messages
+ if (line.includes('Completed') || line.includes('Done')) {
+ this.progressMessage = `${this.getCurrentDirection()} completed`;
+ this.emitStatusUpdate();
+ }
+
+ // Parse deletion messages (important for tracking local deletions)
+ if (line.includes('delete:') || line.includes('removing') || line.includes('delete')) {
+ console.log('๐๏ธ AWS CLI deletion detected:', line);
+ this.emit('awsOutput', {
+ direction: this.getCurrentDirection(),
+ output: `๐๏ธ Deletion: ${line.trim()}`,
+ timestamp: new Date().toISOString()
+ });
+ }
+
+ // Log important messages
+ if (line.includes('ERROR') || line.includes('Failed') || line.includes('error')) {
+ console.error('โ AWS CLI error:', line);
+ this.emit('syncError', line);
+ } else if (line.includes('Completed') || line.includes('Transfer')) {
+ console.log('๐ AWS CLI status:', line);
+ }
+ }
+ }
+
+ /**
+ * Get the current sync direction based on the current phase
+ */
+ private getCurrentDirection(): string {
+ switch (this.currentPhase) {
+ case 'downloading':
+ return 'download';
+ case 'uploading':
+ return 'upload';
+ case 'watching':
+ return 'watch';
+ case 'cleaning':
+ return 'cleanup';
+ case 'force-downloading':
+ return 'force-download';
+ case 'force-uploading':
+ return 'force-upload';
+ default:
+ return 'sync';
+ }
+ }
+
+ /**
+ * Get the last sync time
+ */
+ private getLastSyncTime(): Date | null {
+ // Try to get from ConfigManager first (persistent), fall back to instance variable
+ if (this.config.configManager && this.config.configManager.getLastSyncTime) {
+ const persistentTime = this.config.configManager.getLastSyncTime();
+ if (persistentTime) {
+ // Update instance variable for consistency
+ this.lastSyncTime = persistentTime;
+ return persistentTime;
+ }
+ }
+ return this.lastSyncTime;
+ }
+
+ /**
+ * Emit status update
+ */
+ private emitStatusUpdate(): void {
+ const status = this.getStatus();
+ this.emit('statusChanged', status);
+ }
+
+ /**
+ * Check if AWS CLI is available on the system
+ */
+ static async checkAwsCliAvailable(): Promise {
+ return new Promise((resolve) => {
+ const process = spawn('aws', ['--version'], { stdio: 'pipe' });
+
+ process.on('close', (code) => {
+ resolve(code === 0);
+ });
+
+ process.on('error', () => {
+ resolve(false);
+ });
+ });
+ }
+}
diff --git a/packages/desktop-sync/src/services/configManager.ts b/packages/desktop-sync/src/services/configManager.ts
new file mode 100644
index 0000000..b97f590
--- /dev/null
+++ b/packages/desktop-sync/src/services/configManager.ts
@@ -0,0 +1,450 @@
+import Store from 'electron-store';
+import * as path from 'path';
+import * as fs from 'fs/promises';
+import * as fsSync from 'fs';
+import * as dotenv from 'dotenv';
+
+// Simplified sync configuration interface
+export interface SyncConfig {
+ s3: {
+ endpoint: string;
+ region: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ bucketName: string;
+ useSSL: boolean;
+ };
+ sync: {
+ localPath: string;
+ interval: number;
+ autoStart: boolean;
+ conflictResolution: 'local-wins' | 'remote-wins' | 'newer-wins';
+ };
+}
+
+export interface AppConfig {
+ s3: {
+ endpoint: string;
+ region: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ bucketName: string;
+ useSSL: boolean;
+ };
+ sync: {
+ localPath: string;
+ syncInterval: number; // milliseconds
+ autoStart: boolean;
+ conflictResolution: 'local-wins' | 'remote-wins' | 'newer-wins';
+ lastSyncTime?: string; // ISO string of last successful sync
+ };
+ ui: {
+ theme: 'light' | 'dark' | 'system';
+ language: string;
+ notifications: boolean;
+ minimizeToTray: boolean;
+ };
+}
+
+export class ConfigManager {
+ private store: Store;
+ private configPath: string;
+
+ constructor() {
+ // Load environment variables from .env file
+ this.loadEnvironmentVariables();
+
+ this.store = new Store({
+ defaults: this.getDefaultConfig(),
+ schema: this.getConfigSchema(),
+ });
+
+ this.configPath = this.store.path;
+ }
+
+ /**
+ * Load environment variables from .env file
+ */
+ private loadEnvironmentVariables(): void {
+ try {
+ const envPath = path.join(process.cwd(), '.env');
+ if (fsSync.existsSync(envPath)) {
+ dotenv.config({ path: envPath });
+ console.log('โ
Environment variables loaded from .env file');
+ } else {
+ console.log('โน๏ธ No .env file found, using default configuration');
+ }
+ } catch (error) {
+ console.warn('โ ๏ธ Failed to load .env file:', error);
+ }
+ }
+
+ /**
+ * Force reload environment variables from .env file
+ */
+ public reloadEnvironmentVariables(): void {
+ try {
+ const envPath = path.join(process.cwd(), '.env');
+ if (fsSync.existsSync(envPath)) {
+ // Clear any cached env vars
+ Object.keys(process.env).forEach(key => {
+ if (key.startsWith('S3_') || key.startsWith('SYNC_') || key.startsWith('UI_')) {
+ delete process.env[key];
+ }
+ });
+
+ // Reload from .env
+ dotenv.config({ path: envPath, override: true });
+
+ // Clear the electron-store cache to force reload
+ this.store.clear();
+
+ console.log('โ
Environment variables reloaded from .env file');
+ console.log('โ
Configuration cache cleared');
+ }
+ } catch (error) {
+ console.warn('โ ๏ธ Failed to reload .env file:', error);
+ }
+ }
+
+ /**
+ * Get default configuration
+ */
+ private getDefaultConfig(): AppConfig {
+ return {
+ s3: {
+ endpoint: process.env.S3_ENDPOINT || 'https://garage.geertrademakers.nl',
+ region: process.env.S3_REGION || 'garage',
+ accessKeyId: process.env.S3_ACCESS_KEY_ID || '',
+ secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || '',
+ bucketName: process.env.S3_BUCKET_NAME || 'music',
+ useSSL: process.env.S3_USE_SSL !== 'false',
+ },
+ sync: {
+ localPath: process.env.SYNC_LOCAL_PATH || this.getDefaultMusicPath(),
+ syncInterval: parseInt(process.env.SYNC_INTERVAL || '30000'),
+ autoStart: process.env.SYNC_AUTO_START === 'true',
+ conflictResolution: (process.env.SYNC_CONFLICT_RESOLUTION as any) || 'newer-wins',
+ },
+ ui: {
+ theme: (process.env.UI_THEME as any) || 'system',
+ language: process.env.UI_LANGUAGE || 'en',
+ notifications: process.env.UI_NOTIFICATIONS !== 'false',
+ minimizeToTray: process.env.UI_MINIMIZE_TO_TRAY !== 'false',
+ },
+ };
+ }
+
+ /**
+ * Get configuration schema for validation
+ */
+ private getConfigSchema(): any {
+ return {
+ s3: {
+ type: 'object',
+ properties: {
+ endpoint: { type: 'string', format: 'uri' },
+ region: { type: 'string', minLength: 1 },
+ accessKeyId: { type: 'string', minLength: 1 },
+ secretAccessKey: { type: 'string', minLength: 1 },
+ bucketName: { type: 'string', minLength: 1 },
+ useSSL: { type: 'boolean' },
+ },
+ required: ['endpoint', 'region', 'accessKeyId', 'secretAccessKey', 'bucketName'],
+ },
+ sync: {
+ type: 'object',
+ properties: {
+ localPath: { type: 'string', minLength: 1 },
+ syncInterval: { type: 'number', minimum: 5000, maximum: 300000 },
+ autoStart: { type: 'boolean' },
+ conflictResolution: {
+ type: 'string',
+ enum: ['local-wins', 'remote-wins', 'newer-wins']
+ },
+ },
+ required: ['localPath', 'syncInterval', 'autoStart', 'conflictResolution'],
+ },
+ ui: {
+ type: 'object',
+ properties: {
+ theme: { type: 'string', enum: ['light', 'dark', 'system'] },
+ language: { type: 'string', minLength: 2 },
+ notifications: { type: 'boolean' },
+ minimizeToTray: { type: 'boolean' },
+ },
+ required: ['theme', 'language', 'notifications', 'minimizeToTray'],
+ },
+ };
+ }
+
+ /**
+ * Get default music path based on platform
+ */
+ private getDefaultMusicPath(): string {
+ const os = process.platform;
+ const homeDir = process.env.HOME || process.env.USERPROFILE || '';
+
+ switch (os) {
+ case 'darwin': // macOS
+ return path.join(homeDir, 'Music');
+ case 'win32': // Windows
+ return path.join(homeDir, 'Music');
+ case 'linux': // Linux
+ return path.join(homeDir, 'Music');
+ default:
+ return homeDir;
+ }
+ }
+
+ /**
+ * Get entire configuration
+ */
+ getConfig(): AppConfig {
+ return this.store.store;
+ }
+
+ /**
+ * Get S3 configuration
+ */
+ getS3Config() {
+ return this.store.get('s3');
+ }
+
+ /**
+ * Get sync configuration
+ */
+ getSyncConfig() {
+ return this.store.get('sync');
+ }
+
+ /**
+ * Get UI configuration
+ */
+ getUIConfig() {
+ return this.store.get('ui');
+ }
+
+ /**
+ * Update S3 configuration
+ */
+ updateS3Config(s3Config: Partial): void {
+ const current = this.store.get('s3');
+ this.store.set('s3', { ...current, ...s3Config });
+ }
+
+ /**
+ * Update sync configuration
+ */
+ updateSyncConfig(syncConfig: Partial): void {
+ const current = this.store.get('sync');
+ this.store.set('sync', { ...current, ...syncConfig });
+ }
+
+ /**
+ * Update UI configuration
+ */
+ updateUIConfig(uiConfig: Partial): void {
+ const current = this.store.get('ui');
+ this.store.set('ui', { ...current, ...uiConfig });
+ }
+
+ /**
+ * Get sync configuration for the sync manager
+ */
+ getSyncManagerConfig(): SyncConfig {
+ const s3Config = this.getS3Config();
+ const syncConfig = this.getSyncConfig();
+
+ return {
+ s3: {
+ endpoint: s3Config.endpoint,
+ region: s3Config.region,
+ accessKeyId: s3Config.accessKeyId,
+ secretAccessKey: s3Config.secretAccessKey,
+ bucketName: s3Config.bucketName,
+ useSSL: s3Config.useSSL,
+ },
+ sync: {
+ localPath: syncConfig.localPath,
+ interval: syncConfig.syncInterval,
+ autoStart: syncConfig.autoStart,
+ conflictResolution: syncConfig.conflictResolution,
+ }
+ };
+ }
+
+ /**
+ * Validate local path exists and is accessible
+ */
+ async validateLocalPath(localPath: string): Promise<{ valid: boolean; error?: string }> {
+ try {
+ const stats = await fs.stat(localPath);
+
+ if (!stats.isDirectory()) {
+ return { valid: false, error: 'Path is not a directory' };
+ }
+
+ // Test write access
+ const testFile = path.join(localPath, '.sync-test');
+ await fs.writeFile(testFile, 'test');
+ await fs.unlink(testFile);
+
+ return { valid: true };
+ } catch (error) {
+ return {
+ valid: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+ }
+
+ /**
+ * Test S3 connection
+ */
+ async testS3Connection(): Promise<{ success: boolean; error?: string }> {
+ try {
+ const { S3Client, ListBucketsCommand } = await import('@aws-sdk/client-s3');
+ const s3Config = this.getS3Config();
+
+ const client = new S3Client({
+ endpoint: s3Config.endpoint,
+ region: s3Config.region,
+ credentials: {
+ accessKeyId: s3Config.accessKeyId,
+ secretAccessKey: s3Config.secretAccessKey,
+ },
+ forcePathStyle: true,
+ });
+
+ const command = new ListBucketsCommand({});
+ await client.send(command);
+
+ return { success: true };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+ }
+
+ /**
+ * Get the last sync time
+ */
+ getLastSyncTime(): Date | null {
+ const config = this.getConfig();
+ if (config.sync.lastSyncTime) {
+ try {
+ return new Date(config.sync.lastSyncTime);
+ } catch (error) {
+ console.warn('โ ๏ธ Invalid last sync time format:', config.sync.lastSyncTime);
+ return null;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Set the last sync time
+ */
+ setLastSyncTime(date: Date): void {
+ const config = this.getConfig();
+ config.sync.lastSyncTime = date.toISOString();
+ this.store.set('sync.lastSyncTime', config.sync.lastSyncTime);
+ console.log('๐พ Last sync time saved:', date.toISOString());
+ }
+
+ /**
+ * Reset configuration to defaults
+ */
+ resetToDefaults(): void {
+ this.store.clear();
+ this.store.store = this.getDefaultConfig();
+ }
+
+ /**
+ * Get configuration file path
+ */
+ getConfigPath(): string {
+ return this.configPath;
+ }
+
+ /**
+ * Export configuration to file
+ */
+ async exportConfig(exportPath: string): Promise {
+ const config = this.getConfig();
+ await fs.writeFile(exportPath, JSON.stringify(config, null, 2));
+ }
+
+ /**
+ * Export configuration to .env file
+ */
+ async exportToEnv(exportPath: string): Promise {
+ const config = this.getConfig();
+ const envContent = [
+ '# Rekordbox Sync Desktop Application Configuration',
+ '# Generated on ' + new Date().toISOString(),
+ '',
+ '# S3 Configuration',
+ `S3_ENDPOINT=${config.s3.endpoint}`,
+ `S3_REGION=${config.s3.region}`,
+ `S3_ACCESS_KEY_ID=${config.s3.accessKeyId}`,
+ `S3_SECRET_ACCESS_KEY=${config.s3.secretAccessKey}`,
+ `S3_BUCKET_NAME=${config.s3.bucketName}`,
+ `S3_USE_SSL=${config.s3.useSSL}`,
+ '',
+ '# Sync Configuration',
+ `SYNC_LOCAL_PATH=${config.sync.localPath}`,
+ `SYNC_INTERVAL=${config.sync.syncInterval}`,
+ `SYNC_AUTO_START=${config.sync.autoStart}`,
+ `SYNC_CONFLICT_RESOLUTION=${config.sync.conflictResolution}`,
+ '',
+ '# UI Configuration',
+ `UI_THEME=${config.ui.theme}`,
+ `UI_LANGUAGE=${config.ui.language}`,
+ `UI_NOTIFICATIONS=${config.ui.notifications}`,
+ `UI_MINIMIZE_TO_TRAY=${config.ui.minimizeToTray}`,
+ ].join('\n');
+
+ await fs.writeFile(exportPath, envContent);
+ }
+
+ /**
+ * Import configuration from file
+ */
+ async importConfig(importPath: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ const configData = await fs.readFile(importPath, 'utf-8');
+ const config = JSON.parse(configData);
+
+ // Validate imported config
+ if (this.validateConfig(config)) {
+ this.store.store = config;
+ return { success: true };
+ } else {
+ return { success: false, error: 'Invalid configuration format' };
+ }
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error'
+ };
+ }
+ }
+
+ /**
+ * Validate configuration object
+ */
+ private validateConfig(config: any): config is AppConfig {
+ // Basic validation - in production you might want more thorough validation
+ return (
+ config &&
+ typeof config === 'object' &&
+ config.s3 &&
+ config.sync &&
+ config.ui
+ );
+ }
+}
diff --git a/packages/desktop-sync/src/services/fileWatcher.ts b/packages/desktop-sync/src/services/fileWatcher.ts
new file mode 100644
index 0000000..20fce63
--- /dev/null
+++ b/packages/desktop-sync/src/services/fileWatcher.ts
@@ -0,0 +1,234 @@
+import * as chokidar from 'chokidar';
+import * as path from 'path';
+import * as crypto from 'crypto';
+import { EventEmitter } from 'events';
+
+// Simple sync operation interface
+export interface SyncOperation {
+ type: 'upload' | 'download' | 'delete';
+ localPath?: string;
+ s3Key?: string;
+ error?: string;
+ action?: string;
+ id?: string;
+}
+
+export interface FileChangeEvent {
+ type: 'add' | 'change' | 'unlink' | 'unlinkDir';
+ path: string;
+ relativePath: string;
+ isDirectory: boolean;
+}
+
+export class FileWatcher extends EventEmitter {
+ private watcher: chokidar.FSWatcher | null = null;
+ private localPath: string;
+ private isWatching: boolean = false;
+ private debounceTimers: Map = new Map();
+ private readonly DEBOUNCE_DELAY = 1000; // 1 second debounce
+
+ constructor(localPath: string) {
+ super();
+ this.localPath = localPath;
+ }
+
+ /**
+ * Start watching the local directory
+ */
+ start(): Promise {
+ if (this.isWatching) {
+ return Promise.resolve();
+ }
+
+ return new Promise((resolve, reject) => {
+ try {
+ this.watcher = chokidar.watch(this.localPath, {
+ persistent: true,
+ ignoreInitial: true,
+ ignored: [
+ /(^|[\/\\])\../, // Ignore hidden files
+ /node_modules/, // Ignore node_modules
+ /\.DS_Store$/, // Ignore macOS system files
+ /Thumbs\.db$/, // Ignore Windows system files
+ /\.tmp$/, // Ignore temporary files
+ /\.temp$/, // Ignore temporary files
+ /\.log$/, // Ignore log files
+ ],
+ awaitWriteFinish: {
+ stabilityThreshold: 2000,
+ pollInterval: 100,
+ },
+ });
+
+ this.setupEventHandlers();
+ this.isWatching = true;
+
+ this.emit('started');
+ resolve();
+ } catch (error) {
+ reject(error);
+ }
+ });
+ }
+
+ /**
+ * Stop watching the local directory
+ */
+ stop(): void {
+ if (!this.isWatching || !this.watcher) {
+ return;
+ }
+
+ this.watcher.close();
+ this.watcher = null;
+ this.isWatching = false;
+
+ // Clear all debounce timers
+ for (const timer of this.debounceTimers.values()) {
+ clearTimeout(timer);
+ }
+ this.debounceTimers.clear();
+
+ this.emit('stopped');
+ }
+
+ /**
+ * Setup event handlers for file system changes
+ */
+ private setupEventHandlers(): void {
+ if (!this.watcher) return;
+
+ // File added
+ this.watcher.on('add', (filePath: string) => {
+ this.handleFileChange('add', filePath);
+ });
+
+ // File changed
+ this.watcher.on('change', (filePath: string) => {
+ this.handleFileChange('change', filePath);
+ });
+
+ // File removed
+ this.watcher.on('unlink', (filePath: string) => {
+ this.handleFileChange('unlink', filePath);
+ });
+
+ // Directory removed
+ this.watcher.on('unlinkDir', (dirPath: string) => {
+ this.handleFileChange('unlinkDir', dirPath);
+ });
+
+ // Error handling
+ this.watcher.on('error', (error: Error) => {
+ this.emit('error', error);
+ });
+
+ // Ready event
+ this.watcher.on('ready', () => {
+ this.emit('ready');
+ });
+ }
+
+ /**
+ * Handle file change events with debouncing
+ */
+ private handleFileChange(type: 'add' | 'change' | 'unlink' | 'unlinkDir', filePath: string): void {
+ const relativePath = path.relative(this.localPath, filePath);
+ const key = `${type}:${relativePath}`;
+
+ // Clear existing timer for this change
+ if (this.debounceTimers.has(key)) {
+ clearTimeout(this.debounceTimers.get(key)!);
+ }
+
+ // Set new debounce timer
+ const timer = setTimeout(() => {
+ this.debounceTimers.delete(key);
+ this.processFileChange(type, filePath, relativePath);
+ }, this.DEBOUNCE_DELAY);
+
+ this.debounceTimers.set(key, timer);
+ }
+
+ /**
+ * Process the file change after debouncing
+ */
+ private processFileChange(type: 'add' | 'change' | 'unlink' | 'unlinkDir', filePath: string, relativePath: string): void {
+ const isDirectory = type === 'unlinkDir';
+
+ const event: FileChangeEvent = {
+ type,
+ path: filePath,
+ relativePath,
+ isDirectory,
+ };
+
+ this.emit('fileChanged', event);
+
+ // Convert to sync operation
+ const syncOperation = this.createSyncOperation(event);
+ if (syncOperation) {
+ this.emit('syncOperation', syncOperation);
+ }
+ }
+
+ /**
+ * Create sync operation from file change event
+ */
+ private createSyncOperation(event: FileChangeEvent): SyncOperation | null {
+ switch (event.type) {
+ case 'add':
+ case 'change':
+ if (!event.isDirectory) {
+ return {
+ type: 'upload',
+ localPath: event.path,
+ s3Key: event.relativePath,
+ action: 'pending',
+ id: crypto.randomUUID()
+ };
+ }
+ break;
+
+ case 'unlink':
+ case 'unlinkDir':
+ return {
+ type: 'delete',
+ s3Key: event.relativePath,
+ action: 'pending',
+ id: crypto.randomUUID()
+ };
+ }
+
+ return null;
+ }
+
+ /**
+ * Check if the watcher is currently active
+ */
+ isActive(): boolean {
+ return this.isWatching && this.watcher !== null;
+ }
+
+ /**
+ * Get the current local path being watched
+ */
+ getLocalPath(): string {
+ return this.localPath;
+ }
+
+ /**
+ * Update the local path being watched
+ */
+ updateLocalPath(newPath: string): void {
+ if (this.isWatching) {
+ this.stop();
+ }
+
+ this.localPath = newPath;
+
+ if (this.isWatching) {
+ this.start();
+ }
+ }
+}
diff --git a/packages/desktop-sync/src/services/syncManager.ts b/packages/desktop-sync/src/services/syncManager.ts
new file mode 100644
index 0000000..1a74b0f
--- /dev/null
+++ b/packages/desktop-sync/src/services/syncManager.ts
@@ -0,0 +1,501 @@
+import { EventEmitter } from 'events';
+import { AwsS3Service, AwsS3Config } from './awsS3Service';
+import { FileWatcher } from './fileWatcher';
+
+export interface SyncState {
+ isRunning: boolean;
+ isPaused: boolean;
+ currentPhase: 'idle' | 'starting' | 'downloading' | 'uploading' | 'watching' | 'stopping' | 'error' | 'completed';
+ progress: {
+ percent: number;
+ message: string;
+ filesProcessed: number;
+ totalFiles: number;
+ transferSpeed: string;
+ eta: string;
+ };
+ lastSync: Date | null;
+ lastError: string | null;
+ stats: {
+ totalFilesSynced: number;
+ filesDownloaded: number;
+ filesUploaded: number;
+ bytesTransferred: number;
+ syncDuration: number;
+ };
+}
+
+export interface SyncConfig {
+ s3: {
+ endpoint: string;
+ region: string;
+ accessKeyId: string;
+ secretAccessKey: string;
+ bucketName: string;
+ useSSL: boolean;
+ };
+ sync: {
+ localPath: string;
+ interval: number;
+ autoStart: boolean;
+ conflictResolution: string;
+ };
+}
+
+export class SyncManager extends EventEmitter {
+ private awsS3Service: AwsS3Service | null = null;
+ private fileWatcher: FileWatcher | null = null;
+ private config: SyncConfig;
+ private state: SyncState;
+ private configManager: any; // Reference to ConfigManager for persistent storage
+
+ constructor(config: SyncConfig, configManager?: any) {
+ super();
+ this.config = config;
+ this.configManager = configManager;
+ this.state = this.getInitialState();
+ }
+
+ private getInitialState(): SyncState {
+ return {
+ isRunning: false,
+ isPaused: false,
+ currentPhase: 'idle',
+ progress: {
+ percent: 0,
+ message: 'Ready to sync',
+ filesProcessed: 0,
+ totalFiles: 0,
+ transferSpeed: '',
+ eta: ''
+ },
+ lastSync: null,
+ lastError: null,
+ stats: {
+ totalFilesSynced: 0,
+ filesDownloaded: 0,
+ filesUploaded: 0,
+ bytesTransferred: 0,
+ syncDuration: 0
+ }
+ };
+ }
+
+ async startSync(): Promise {
+ if (this.state.isRunning) {
+ throw new Error('Sync is already running');
+ }
+
+ try {
+ this.updateState({
+ isRunning: true,
+ currentPhase: 'starting',
+ progress: { ...this.state.progress, message: 'Initializing sync...', percent: 0 }
+ });
+
+ const awsS3Config: AwsS3Config = {
+ endpoint: this.config.s3.endpoint,
+ region: this.config.s3.region,
+ accessKeyId: this.config.s3.accessKeyId,
+ secretAccessKey: this.config.s3.secretAccessKey,
+ bucketName: this.config.s3.bucketName,
+ useSSL: this.config.s3.useSSL,
+ localPath: this.config.sync.localPath,
+ configManager: this.configManager
+ };
+
+ this.awsS3Service = new AwsS3Service(awsS3Config);
+ this.setupAwsS3EventHandlers();
+
+ this.fileWatcher = new FileWatcher(this.config.sync.localPath);
+ this.setupFileWatcherEventHandlers();
+
+ await this.awsS3Service.startSync();
+ this.fileWatcher.start();
+
+ // Check if AWS S3 service is actually running
+ if (this.awsS3Service && this.awsS3Service.getStatus().isRunning) {
+ this.updateState({
+ currentPhase: 'watching',
+ progress: { ...this.state.progress, message: 'Continuous sync active', percent: 100 }
+ });
+ this.emit('syncStarted');
+ } else {
+ throw new Error('MinIO service failed to start properly');
+ }
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+
+ // Clean up services on error
+ if (this.awsS3Service) {
+ try {
+ this.awsS3Service.stopSync();
+ } catch (cleanupError) {
+ console.warn('โ ๏ธ Error cleaning up AWS S3 service:', cleanupError);
+ }
+ }
+
+ if (this.fileWatcher) {
+ try {
+ this.fileWatcher.stop();
+ } catch (cleanupError) {
+ console.warn('โ ๏ธ Error cleaning up file watcher:', cleanupError);
+ }
+ }
+
+ this.updateState({
+ isRunning: false,
+ currentPhase: 'error',
+ lastError: errorMessage,
+ progress: { ...this.state.progress, message: `Failed to start: ${errorMessage}` }
+ });
+ this.emit('syncError', errorMessage);
+ throw error;
+ }
+ }
+
+ async stopSync(): Promise {
+ if (!this.state.isRunning) return;
+
+ try {
+ this.updateState({
+ currentPhase: 'stopping',
+ progress: { ...this.state.progress, message: 'Stopping sync...' }
+ });
+
+ if (this.awsS3Service) {
+ this.awsS3Service.stopSync();
+ }
+ if (this.fileWatcher) {
+ this.fileWatcher.stop();
+ }
+
+ this.updateState({
+ isRunning: false,
+ currentPhase: 'idle',
+ progress: { ...this.state.progress, message: 'Sync stopped', percent: 0 }
+ });
+
+ this.emit('syncStopped');
+
+ } catch (error) {
+ console.error('โ Error stopping sync:', error);
+ this.updateState({
+ currentPhase: 'error',
+ lastError: error instanceof Error ? error.message : 'Unknown error'
+ });
+ }
+ }
+
+ /**
+ * Trigger an immediate sync to propagate local changes (including deletions)
+ */
+ async triggerImmediateSync(): Promise {
+ if (!this.state.isRunning) {
+ return;
+ }
+
+ try {
+ if (this.awsS3Service) {
+ await this.awsS3Service.triggerImmediateSync();
+ }
+ } catch (error) {
+ console.error('โ Immediate sync failed:', error);
+ this.emit('syncError', error);
+ }
+ }
+
+ async forceFullSync(): Promise {
+ if (this.state.isRunning) {
+ await this.stopSync();
+ await new Promise(resolve => setTimeout(resolve, 2000));
+ }
+
+ try {
+ this.updateState({
+ currentPhase: 'starting',
+ progress: { ...this.state.progress, message: 'Preparing force sync...', percent: 0 }
+ });
+
+ const awsS3Config: AwsS3Config = {
+ endpoint: this.config.s3.endpoint,
+ region: this.config.s3.region,
+ accessKeyId: this.config.s3.accessKeyId,
+ secretAccessKey: this.config.s3.secretAccessKey,
+ bucketName: this.config.s3.bucketName,
+ useSSL: this.config.s3.useSSL,
+ localPath: this.config.sync.localPath,
+ configManager: this.configManager
+ };
+
+ const forceSyncService = new AwsS3Service(awsS3Config);
+
+ forceSyncService.on('statusChanged', (status: any) => {
+ this.handleAwsS3StatusChange(status, 'force-sync');
+ });
+
+ await forceSyncService.forceFullSync();
+
+ this.updateState({
+ currentPhase: 'completed',
+ progress: { ...this.state.progress, message: 'Force sync completed!', percent: 100 },
+ lastSync: new Date()
+ });
+
+ this.emit('forceSyncCompleted');
+
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ this.updateState({
+ currentPhase: 'error',
+ lastError: errorMessage,
+ progress: { ...this.state.progress, message: `Force sync failed: ${errorMessage}` }
+ });
+ this.emit('syncError', errorMessage);
+ throw error;
+ }
+ }
+
+ getState(): SyncState {
+ return { ...this.state };
+ }
+
+ private updateState(updates: Partial): void {
+ this.state = { ...this.state, ...updates };
+ this.emit('stateChanged', this.state);
+ }
+
+ private setupAwsS3EventHandlers(): void {
+ if (!this.awsS3Service) return;
+
+ this.awsS3Service.on('statusChanged', (status: any) => {
+ this.handleAwsS3StatusChange(status, 'normal');
+ });
+
+ this.awsS3Service.on('syncError', (error: any) => {
+ this.handleAwsS3Error(error);
+ });
+
+ this.awsS3Service.on('awsOutput', (output: any) => {
+ this.emit('awsOutput', output);
+ });
+ }
+
+ private setupFileWatcherEventHandlers(): void {
+ if (!this.fileWatcher) return;
+
+ this.fileWatcher.on('fileChanged', (event: any) => {
+ // Check if this is a deletion event
+ if (event && event.type === 'unlink') {
+ console.log('๐๏ธ File deletion detected in fileChanged event:', event);
+ // Add a small delay to ensure file system has processed the deletion
+ setTimeout(() => {
+ console.log('๐ Triggering immediate sync after file deletion...');
+ this.triggerImmediateSync();
+ }, 1000); // 1 second delay
+ }
+
+ this.emit('fileChanged', event);
+ });
+
+ this.fileWatcher.on('fileAdded', (filePath: string) => {
+ this.emit('fileAdded', filePath);
+ });
+
+ this.fileWatcher.on('fileRemoved', (filePath: string) => {
+ this.emit('fileRemoved', filePath);
+ });
+ }
+
+ private handleAwsS3StatusChange(status: any, syncType: 'normal' | 'force-sync'): void {
+ let phase: SyncState['currentPhase'] = 'idle';
+ let message = 'Ready';
+ let percent = 0;
+
+ switch (status.currentPhase) {
+ case 'starting':
+ phase = 'starting';
+ message = 'Initializing sync...';
+ percent = 10;
+ break;
+ case 'downloading':
+ phase = 'downloading';
+ const localCount = this.getActualLocalFileCount();
+ message = `Downloading files from S3 (Local: ${localCount} files)`;
+ percent = 30;
+ break;
+ case 'uploading':
+ phase = 'uploading';
+ const localCount2 = this.getActualLocalFileCount();
+ message = `Uploading files to S3 (Local: ${localCount2} files)`;
+ percent = 60;
+ break;
+ case 'watching':
+ phase = 'watching';
+ message = 'Continuous bidirectional sync active';
+ percent = 100;
+ break;
+ case 'completed':
+ phase = 'completed';
+ message = 'Sync completed successfully!';
+ percent = 100;
+ break;
+ case 'error':
+ phase = 'error';
+ message = `Sync error: ${status.progressMessage || 'Unknown error'}`;
+ break;
+ }
+
+ const progress = {
+ percent,
+ message,
+ filesProcessed: status.filesSynced || 0,
+ totalFiles: status.filesSynced || 0,
+ transferSpeed: status.transferSpeed || '',
+ eta: status.eta || ''
+ };
+
+ if (syncType === 'normal') {
+ // Get actual file count from local folder instead of inflating the counter
+ const actualFileCount = this.getActualLocalFileCount();
+
+ const stats = {
+ totalFilesSynced: actualFileCount,
+ filesDownloaded: actualFileCount, // For now, assume all files are downloaded
+ filesUploaded: 0,
+ bytesTransferred: this.state.stats.bytesTransferred,
+ syncDuration: this.state.stats.syncDuration
+ };
+
+ this.updateState({
+ currentPhase: phase,
+ progress,
+ stats,
+ lastError: phase === 'error' ? status.progressMessage : null
+ });
+ } else {
+ this.updateState({
+ currentPhase: phase,
+ progress,
+ lastError: phase === 'error' ? status.progressMessage : null
+ });
+ }
+ }
+
+ private handleAwsS3Error(error: any): void {
+ const errorMessage = typeof error === 'string' ? error : 'Unknown MinIO error';
+ this.updateState({
+ currentPhase: 'error',
+ lastError: errorMessage,
+ progress: { ...this.state.progress, message: `Error: ${errorMessage}` }
+ });
+ this.emit('syncError', errorMessage);
+ }
+
+ updateConfig(newConfig: SyncConfig): void {
+ this.config = newConfig;
+ if (this.state.isRunning) {
+ this.stopSync().then(() => {
+ setTimeout(() => this.startSync(), 1000);
+ });
+ }
+ }
+
+ /**
+ * Get actual file count from local sync folder
+ */
+ private getActualLocalFileCount(): number {
+ try {
+ const fs = require('fs');
+ const path = require('path');
+
+ let fileCount = 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()) {
+ // Only count music files
+ 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);
+ }
+ };
+
+ if (fs.existsSync(this.config.sync.localPath)) {
+ countFiles(this.config.sync.localPath);
+ }
+
+ return fileCount;
+ } catch (error) {
+ console.error('โ Error getting actual file count:', error);
+ return 0;
+ }
+ }
+
+ /**
+ * Get S3 bucket file count for comparison
+ */
+ private async getS3FileCount(): Promise {
+ try {
+ const { spawn } = require('child_process');
+
+ return new Promise((resolve) => {
+ // Use AWS CLI to list objects in the bucket
+ const awsProcess = spawn('aws', [
+ 's3', 'ls',
+ `s3://${this.config.s3.bucketName}/`,
+ '--recursive',
+ '--endpoint-url', this.config.s3.endpoint
+ ], {
+ stdio: 'pipe',
+ env: {
+ ...process.env,
+ AWS_ACCESS_KEY_ID: this.config.s3.accessKeyId,
+ AWS_SECRET_ACCESS_KEY: this.config.s3.secretAccessKey,
+ AWS_DEFAULT_REGION: this.config.s3.region
+ }
+ });
+
+ let output = '';
+ let fileCount = 0;
+
+ awsProcess.stdout?.on('data', (data: Buffer) => {
+ output += data.toString();
+ });
+
+ awsProcess.on('close', () => {
+ // Count lines that represent files (not directories)
+ const lines = output.split('\n').filter(line => line.trim());
+ fileCount = lines.length;
+ resolve(fileCount);
+ });
+
+ awsProcess.on('error', () => {
+ resolve(0);
+ });
+ });
+ } catch (error) {
+ console.error('โ Error getting S3 file count:', error);
+ return 0;
+ }
+ }
+
+ destroy(): void {
+ this.stopSync();
+ this.removeAllListeners();
+ }
+}
diff --git a/packages/desktop-sync/tsconfig.json b/packages/desktop-sync/tsconfig.json
new file mode 100644
index 0000000..5e018c4
--- /dev/null
+++ b/packages/desktop-sync/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2020"],
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "resolveJsonModule": true,
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true
+ },
+ "include": [
+ "src/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist"
+ ]
+}