feat: Complete cleanup of desktop sync tool - Remove unused services (minio, syncEngine, rclone) - Clean up excessive debugging and logging - Remove sync queue functionality (not needed for AWS CLI) - Update SyncConfig interface and fix type conflicts - Replace mc commands with AWS CLI equivalents - Improve code organization and readability
This commit is contained in:
parent
dc8254772c
commit
73d9a41ca8
56
packages/desktop-sync/package.json
Normal file
56
packages/desktop-sync/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
225
packages/desktop-sync/renderer/index.html
Normal file
225
packages/desktop-sync/renderer/index.html
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Rekordbox Sync</title>
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1><i class="fas fa-sync-alt"></i> Rekordbox Sync</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<button id="settingsBtn" class="btn btn-secondary" title="Settings">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</button>
|
||||||
|
<button id="minimizeBtn" class="btn btn-secondary" title="Minimize">
|
||||||
|
<i class="fas fa-window-minimize"></i>
|
||||||
|
</button>
|
||||||
|
<button id="closeBtn" class="btn btn-secondary" title="Close">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="main-content">
|
||||||
|
<!-- Status Panel -->
|
||||||
|
<div class="status-panel">
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Sync Status:</span>
|
||||||
|
<span id="syncStatus" class="status-value">Stopped</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Last Sync:</span>
|
||||||
|
<span id="lastSync" class="status-value">Never</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Local Files:</span>
|
||||||
|
<span id="filesSynced" class="status-value">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="status-item">
|
||||||
|
<span class="status-label">Progress:</span>
|
||||||
|
<div class="progress-container">
|
||||||
|
<div id="progressBar" class="progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="syncDetails" class="sync-details"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<div class="control-panel">
|
||||||
|
<button id="startSyncBtn" class="btn btn-primary">
|
||||||
|
<i class="fas fa-play"></i> Start Sync
|
||||||
|
</button>
|
||||||
|
<button id="stopSyncBtn" class="btn btn-danger" disabled>
|
||||||
|
<i class="fas fa-stop"></i> Stop Sync
|
||||||
|
</button>
|
||||||
|
<button id="forceSyncBtn" class="btn btn-secondary">
|
||||||
|
<i class="fas fa-sync"></i> Force Full Sync
|
||||||
|
</button>
|
||||||
|
<button id="immediateSyncBtn" class="btn btn-info">
|
||||||
|
<i class="fas fa-bolt"></i> Immediate Sync
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="activity-panel">
|
||||||
|
<h3><i class="fas fa-history"></i> Recent Activity</h3>
|
||||||
|
<div id="activityLog" class="activity-log">
|
||||||
|
<div class="empty-activity">No recent activity</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Settings Modal -->
|
||||||
|
<div id="settingsModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2><i class="fas fa-cog"></i> Settings</h2>
|
||||||
|
<button id="closeSettingsBtn" class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<!-- S3 Configuration -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-cloud"></i> S3 Configuration</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s3Endpoint">Endpoint:</label>
|
||||||
|
<input type="url" id="s3Endpoint" placeholder="https://garage.geertrademakers.nl">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s3Region">Region:</label>
|
||||||
|
<input type="text" id="s3Region" placeholder="garage">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s3AccessKey">Access Key ID:</label>
|
||||||
|
<input type="text" id="s3AccessKey" placeholder="Your access key">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s3SecretKey">Secret Access Key:</label>
|
||||||
|
<input type="password" id="s3SecretKey" placeholder="Your secret key">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="s3Bucket">Bucket Name:</label>
|
||||||
|
<input type="text" id="s3Bucket" placeholder="music">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="s3UseSSL" checked>
|
||||||
|
Use SSL
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button id="testS3Btn" class="btn btn-secondary">Test Connection</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Configuration -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-sync"></i> Sync Configuration</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="localPath">Local Music Folder:</label>
|
||||||
|
<div class="path-input-group">
|
||||||
|
<input type="text" id="localPath" placeholder="Select music folder" readonly>
|
||||||
|
<button id="selectFolderBtn" class="btn btn-secondary">Browse</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="syncInterval">Sync Interval (seconds):</label>
|
||||||
|
<input type="number" id="syncInterval" min="5" max="300" value="30">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="autoStart">
|
||||||
|
Auto-start sync on app launch
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="conflictResolution">Conflict Resolution:</label>
|
||||||
|
<select id="conflictResolution">
|
||||||
|
<option value="newer-wins">Newer file wins</option>
|
||||||
|
<option value="local-wins">Local file wins</option>
|
||||||
|
<option value="remote-wins">Remote file wins</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- UI Configuration -->
|
||||||
|
<div class="settings-section">
|
||||||
|
<h3><i class="fas fa-palette"></i> Interface</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="theme">Theme:</label>
|
||||||
|
<select id="theme">
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="light">Light</option>
|
||||||
|
<option value="dark">Dark</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="notifications" checked>
|
||||||
|
Show notifications
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="minimizeToTray" checked>
|
||||||
|
Minimize to system tray
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button id="exportEnvBtn" class="btn btn-secondary">Export to .env</button>
|
||||||
|
<button id="saveSettingsBtn" class="btn btn-primary">Save Settings</button>
|
||||||
|
<button id="cancelSettingsBtn" class="btn btn-secondary">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Container -->
|
||||||
|
<div id="notificationContainer" class="notification-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
console.log('🚀 HTML script tag executed!');
|
||||||
|
console.log('🔍 Testing basic JavaScript execution...');
|
||||||
|
|
||||||
|
// Test if we can access the DOM
|
||||||
|
if (document.getElementById('syncStatus')) {
|
||||||
|
console.log('✅ DOM element syncStatus found!');
|
||||||
|
} else {
|
||||||
|
console.log('❌ DOM element syncStatus NOT found!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test if we can access window
|
||||||
|
console.log('🔍 Window object:', typeof window);
|
||||||
|
console.log('🔍 Document object:', typeof document);
|
||||||
|
|
||||||
|
// Test if we can access the electronAPI
|
||||||
|
console.log('🔍 Electron API available:', typeof window.electronAPI);
|
||||||
|
|
||||||
|
// Test if we can access the DOM elements
|
||||||
|
const elements = ['syncStatus', 'filesSynced', 'lastSync', 'startSyncBtn', 'stopSyncBtn'];
|
||||||
|
elements.forEach(id => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
console.log(`🔍 Element ${id}:`, element ? 'Found' : 'NOT Found');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test if we can modify DOM elements
|
||||||
|
const testElement = document.getElementById('syncStatus');
|
||||||
|
if (testElement) {
|
||||||
|
testElement.textContent = 'TEST - JavaScript is working!';
|
||||||
|
console.log('✅ Successfully modified DOM element!');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="renderer.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
584
packages/desktop-sync/renderer/renderer.js
Normal file
584
packages/desktop-sync/renderer/renderer.js
Normal file
@ -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 += `<div class="status-detail pending">📋 ${status.pendingCount} pending</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.inProgressCount > 0) {
|
||||||
|
detailsHTML += `<div class="status-detail in-progress">🔄 ${status.inProgressCount} in progress</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.completedCount > 0) {
|
||||||
|
detailsHTML += `<div class="status-detail completed">✅ ${status.completedCount} completed</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.failedCount > 0) {
|
||||||
|
detailsHTML += `<div class="status-detail failed">❌ ${status.failedCount} failed</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.errors && status.errors.length > 0) {
|
||||||
|
detailsHTML += `<div class="status-detail errors">⚠️ ${status.errors.length} errors</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<span class="log-timestamp">[${timestamp}]</span>
|
||||||
|
<span class="log-message">${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
620
packages/desktop-sync/renderer/styles.css
Normal file
620
packages/desktop-sync/renderer/styles.css
Normal file
@ -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;
|
||||||
|
}
|
||||||
689
packages/desktop-sync/src/main.ts
Normal file
689
packages/desktop-sync/src/main.ts
Normal file
@ -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<void> {
|
||||||
|
await this.createMainWindow();
|
||||||
|
this.createTray();
|
||||||
|
this.setupMenu();
|
||||||
|
|
||||||
|
// Auto-start sync if configured
|
||||||
|
const syncConfig = this.configManager.getSyncConfig();
|
||||||
|
if (syncConfig.autoStart) {
|
||||||
|
this.startSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start periodic status updates to keep UI informed
|
||||||
|
this.startPeriodicStatusUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the main application window
|
||||||
|
*/
|
||||||
|
private async createMainWindow(): Promise<void> {
|
||||||
|
this.mainWindow = new BrowserWindow({
|
||||||
|
width: 1200,
|
||||||
|
height: 800,
|
||||||
|
minWidth: 800,
|
||||||
|
minHeight: 600,
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: false,
|
||||||
|
contextIsolation: true,
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, '../assets/icon.png'),
|
||||||
|
show: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load the main HTML file
|
||||||
|
await this.mainWindow.loadFile(path.join(__dirname, '../renderer/index.html'));
|
||||||
|
|
||||||
|
// Show window when ready
|
||||||
|
this.mainWindow.once('ready-to-show', () => {
|
||||||
|
this.mainWindow?.show();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window close
|
||||||
|
this.mainWindow.on('close', (event) => {
|
||||||
|
if (!this.isQuitting) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.mainWindow?.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle window closed
|
||||||
|
this.mainWindow.on('closed', () => {
|
||||||
|
this.mainWindow = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add error handling for the renderer process
|
||||||
|
this.mainWindow.webContents.on('crashed', (event, killed) => {
|
||||||
|
console.error('❌ Renderer process crashed:', { event, killed });
|
||||||
|
this.handleRendererCrash();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.mainWindow.webContents.on('did-fail-load', (event, errorCode, errorDescription) => {
|
||||||
|
console.error('❌ Renderer failed to load:', { errorCode, errorDescription });
|
||||||
|
this.handleRendererFailure();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create system tray icon
|
||||||
|
*/
|
||||||
|
private createTray(): void {
|
||||||
|
const iconPath = path.join(__dirname, '../assets/icon.png');
|
||||||
|
const icon = nativeImage.createFromPath(iconPath);
|
||||||
|
|
||||||
|
this.tray = new Tray(icon);
|
||||||
|
this.tray.setToolTip('Rekordbox Sync');
|
||||||
|
|
||||||
|
this.tray.on('click', () => {
|
||||||
|
if (this.mainWindow?.isVisible()) {
|
||||||
|
this.mainWindow.hide();
|
||||||
|
} else {
|
||||||
|
this.mainWindow?.show();
|
||||||
|
this.mainWindow?.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.tray.on('right-click', () => {
|
||||||
|
this.showTrayContextMenu();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show tray context menu
|
||||||
|
*/
|
||||||
|
private showTrayContextMenu(): void {
|
||||||
|
if (!this.tray) return;
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Show App',
|
||||||
|
click: () => {
|
||||||
|
this.mainWindow?.show();
|
||||||
|
this.mainWindow?.focus();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Start Sync',
|
||||||
|
click: () => this.startSync(),
|
||||||
|
enabled: !this.syncManager?.getState().isRunning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stop Sync',
|
||||||
|
click: () => this.stopSync(),
|
||||||
|
enabled: this.syncManager?.getState().isRunning || false,
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
click: () => {
|
||||||
|
this.isQuitting = true;
|
||||||
|
app.quit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
this.tray.popUpContextMenu(contextMenu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup application menu
|
||||||
|
*/
|
||||||
|
private setupMenu(): void {
|
||||||
|
const template: Electron.MenuItemConstructorOptions[] = [
|
||||||
|
{
|
||||||
|
label: 'File',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
accelerator: 'CmdOrCtrl+,',
|
||||||
|
click: () => this.showSettings(),
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Quit',
|
||||||
|
accelerator: process.platform === 'darwin' ? 'Cmd+Q' : 'Ctrl+Q',
|
||||||
|
click: () => {
|
||||||
|
this.isQuitting = true;
|
||||||
|
app.quit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sync',
|
||||||
|
submenu: [
|
||||||
|
{
|
||||||
|
label: 'Start Sync',
|
||||||
|
click: () => this.startSync(),
|
||||||
|
enabled: !this.syncManager?.getState().isRunning,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Stop Sync',
|
||||||
|
click: () => this.stopSync(),
|
||||||
|
enabled: this.syncManager?.getState().isRunning || false,
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Force Full Sync',
|
||||||
|
click: () => this.forceFullSync(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'View',
|
||||||
|
submenu: [
|
||||||
|
{ role: 'reload' },
|
||||||
|
{ role: 'forceReload' },
|
||||||
|
{ role: 'toggleDevTools' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'resetZoom' },
|
||||||
|
{ role: 'zoomIn' },
|
||||||
|
{ role: 'zoomOut' },
|
||||||
|
{ type: 'separator' },
|
||||||
|
{ role: 'togglefullscreen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const menu = Menu.buildFromTemplate(template);
|
||||||
|
Menu.setApplicationMenu(menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup IPC handlers
|
||||||
|
*/
|
||||||
|
private setupIpcHandlers(): void {
|
||||||
|
console.log('🔌 Setting up IPC handlers...');
|
||||||
|
|
||||||
|
// Sync control
|
||||||
|
ipcMain.handle('sync:start', () => {
|
||||||
|
return this.startSync();
|
||||||
|
});
|
||||||
|
ipcMain.handle('sync:stop', () => {
|
||||||
|
return this.stopSync();
|
||||||
|
});
|
||||||
|
ipcMain.handle('sync:force-full', () => {
|
||||||
|
return this.forceFullSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle('sync:trigger-immediate', () => {
|
||||||
|
return this.triggerImmediateSync();
|
||||||
|
});
|
||||||
|
ipcMain.handle('sync:get-status', () => {
|
||||||
|
return this.syncManager?.getState() || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
ipcMain.handle('config:get', () => this.configManager.getConfig());
|
||||||
|
ipcMain.handle('config:update-s3', (event, config) => this.configManager.updateS3Config(config));
|
||||||
|
ipcMain.handle('config:update-sync', (event, config) => this.configManager.updateSyncConfig(config));
|
||||||
|
ipcMain.handle('config:update-ui', (event, config) => this.configManager.updateUIConfig(config));
|
||||||
|
ipcMain.handle('config:test-s3', () => this.configManager.testS3Connection());
|
||||||
|
ipcMain.handle('config:export-env', async (event, exportPath) => {
|
||||||
|
try {
|
||||||
|
await this.configManager.exportToEnv(exportPath);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File operations
|
||||||
|
ipcMain.handle('dialog:select-folder', async () => {
|
||||||
|
// This would need to be implemented with electron dialog
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start sync
|
||||||
|
*/
|
||||||
|
private async startSync(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Force reload .env file to ensure latest changes are picked up
|
||||||
|
this.configManager.reloadEnvironmentVariables();
|
||||||
|
|
||||||
|
// Always get fresh config and recreate service to ensure .env changes are picked up
|
||||||
|
const appConfig = this.configManager.getConfig();
|
||||||
|
|
||||||
|
const syncConfig: SyncConfig = {
|
||||||
|
s3: {
|
||||||
|
endpoint: appConfig.s3.endpoint,
|
||||||
|
region: appConfig.s3.region,
|
||||||
|
accessKeyId: appConfig.s3.accessKeyId,
|
||||||
|
secretAccessKey: appConfig.s3.secretAccessKey,
|
||||||
|
bucketName: appConfig.s3.bucketName,
|
||||||
|
useSSL: appConfig.s3.useSSL
|
||||||
|
},
|
||||||
|
sync: {
|
||||||
|
localPath: appConfig.sync.localPath,
|
||||||
|
interval: appConfig.sync.syncInterval,
|
||||||
|
autoStart: appConfig.sync.autoStart,
|
||||||
|
conflictResolution: appConfig.sync.conflictResolution
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Recreate sync manager to ensure new config is used
|
||||||
|
if (this.syncManager) {
|
||||||
|
this.syncManager.destroy();
|
||||||
|
}
|
||||||
|
this.syncManager = new SyncManager(syncConfig);
|
||||||
|
this.setupSyncManagerEventHandlers();
|
||||||
|
|
||||||
|
await this.syncManager.startSync();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to start sync:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop sync
|
||||||
|
*/
|
||||||
|
private stopSync(): void {
|
||||||
|
try {
|
||||||
|
if (this.syncManager) {
|
||||||
|
this.syncManager.stopSync();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to stop sync:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger an immediate sync to propagate local changes (including deletions)
|
||||||
|
*/
|
||||||
|
private async triggerImmediateSync(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.syncManager) {
|
||||||
|
await this.syncManager.triggerImmediateSync();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Immediate sync failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Force full sync
|
||||||
|
*/
|
||||||
|
private async forceFullSync(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (this.syncManager) {
|
||||||
|
await this.syncManager.forceFullSync();
|
||||||
|
console.log('✅ Force full sync completed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Force full sync failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup sync manager event handlers
|
||||||
|
*/
|
||||||
|
private setupSyncManagerEventHandlers(): void {
|
||||||
|
if (!this.syncManager) return;
|
||||||
|
|
||||||
|
// Helper function to safely send messages
|
||||||
|
const safeSend = (channel: string, data: any) => {
|
||||||
|
try {
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
this.mainWindow.webContents.send(channel, data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Failed to send message to renderer (${channel}):`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sync manager events
|
||||||
|
this.syncManager.on('stateChanged', (state: any) => {
|
||||||
|
console.log('📊 Sync state changed:', state);
|
||||||
|
|
||||||
|
// Ensure the state is properly formatted for the UI
|
||||||
|
const uiState = {
|
||||||
|
...state,
|
||||||
|
// Add actual file counts from the file system
|
||||||
|
actualFileCount: this.getActualLocalFileCount(),
|
||||||
|
lastUpdate: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
safeSend('sync-status-changed', uiState);
|
||||||
|
this.updateTrayTooltip(uiState);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add more detailed sync events
|
||||||
|
this.syncManager.on('fileChanged', (event: any) => {
|
||||||
|
safeSend('file-changed', {
|
||||||
|
path: event.path || event,
|
||||||
|
type: event.type,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('fileAdded', (filePath: string) => {
|
||||||
|
safeSend('file-added', { path: filePath, timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('fileRemoved', (filePath: string) => {
|
||||||
|
safeSend('file-removed', { path: filePath, timestamp: new Date().toISOString() });
|
||||||
|
|
||||||
|
// Trigger immediate sync to propagate deletion to S3
|
||||||
|
this.triggerImmediateSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('syncError', (error: any) => {
|
||||||
|
console.log('💥 Sync error:', error);
|
||||||
|
safeSend('sync-error', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('syncStarted', () => {
|
||||||
|
console.log('🚀 Sync started');
|
||||||
|
safeSend('sync-started', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('syncStopped', () => {
|
||||||
|
console.log('⏹️ Sync stopped');
|
||||||
|
safeSend('sync-stopped', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('forceSyncCompleted', () => {
|
||||||
|
console.log('🔄 Force sync completed');
|
||||||
|
safeSend('force-sync-completed', {});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncManager.on('awsOutput', (output: any) => {
|
||||||
|
console.log('🔍 AWS S3 output:', output);
|
||||||
|
safeSend('aws-output', output);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update tray tooltip with sync status
|
||||||
|
*/
|
||||||
|
private updateTrayTooltip(status: any): void {
|
||||||
|
if (!this.tray) return;
|
||||||
|
|
||||||
|
const statusText = status.isRunning ? 'Running' : 'Stopped';
|
||||||
|
const phaseText = status.currentPhase ? ` - ${status.currentPhase}` : '';
|
||||||
|
this.tray.setToolTip(`Rekordbox Sync - ${statusText}${phaseText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show settings dialog
|
||||||
|
*/
|
||||||
|
private showSettings(): void {
|
||||||
|
if (this.mainWindow) {
|
||||||
|
this.mainWindow.webContents.send('show-settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle window all closed event
|
||||||
|
*/
|
||||||
|
private handleWindowAllClosed(): void {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle before quit event
|
||||||
|
*/
|
||||||
|
private handleBeforeQuit(): void {
|
||||||
|
this.isQuitting = true;
|
||||||
|
this.stopSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle activate event (macOS)
|
||||||
|
*/
|
||||||
|
private handleActivate(): void {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
this.createMainWindow();
|
||||||
|
} else {
|
||||||
|
this.mainWindow?.show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle renderer process crash
|
||||||
|
*/
|
||||||
|
private handleRendererCrash(): void {
|
||||||
|
console.log('🔄 Attempting to recover from renderer crash...');
|
||||||
|
|
||||||
|
// Stop sync to prevent further issues
|
||||||
|
if (this.syncManager?.getState().isRunning) {
|
||||||
|
console.log('⏹️ Stopping sync due to renderer crash');
|
||||||
|
this.stopSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to reload the renderer
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
console.log('🔄 Reloading renderer...');
|
||||||
|
this.mainWindow.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to reload renderer:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle renderer process failure
|
||||||
|
*/
|
||||||
|
private handleRendererFailure(): void {
|
||||||
|
console.log('🔄 Attempting to recover from renderer failure...');
|
||||||
|
|
||||||
|
// Try to reload the renderer
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
console.log('🔄 Reloading renderer...');
|
||||||
|
this.mainWindow.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Failed to reload renderer:', error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle uncaught exceptions to prevent EPIPE popups
|
||||||
|
*/
|
||||||
|
private handleUncaughtException(error: Error): void {
|
||||||
|
console.error('🚨 Handling uncaught exception:', error);
|
||||||
|
|
||||||
|
// Don't show error popup for EPIPE errors
|
||||||
|
if (error.message.includes('EPIPE') || error.message.includes('write EPIPE')) {
|
||||||
|
console.log('🔇 Suppressing EPIPE error popup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other errors, try to recover gracefully
|
||||||
|
console.log('🔄 Attempting to recover from uncaught exception...');
|
||||||
|
|
||||||
|
// Stop sync to prevent further issues
|
||||||
|
if (this.syncManager?.getState().isRunning) {
|
||||||
|
console.log('⏹️ Stopping sync due to uncaught exception');
|
||||||
|
this.stopSync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start periodic status updates to keep UI informed
|
||||||
|
*/
|
||||||
|
private startPeriodicStatusUpdates(): void {
|
||||||
|
// Send status updates every 2 seconds to keep UI responsive
|
||||||
|
setInterval(() => {
|
||||||
|
if (this.syncManager) {
|
||||||
|
const status = this.syncManager.getState();
|
||||||
|
|
||||||
|
// Add actual file counts to the status
|
||||||
|
if (status.stats) {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let localFileCount = 0;
|
||||||
|
const countFiles = (dirPath: string) => {
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(dirPath);
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dirPath, item);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
countFiles(fullPath);
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
const ext = path.extname(item).toLowerCase();
|
||||||
|
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
|
||||||
|
localFileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Ignore errors for individual files
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncPath = this.configManager.getSyncConfig().localPath;
|
||||||
|
if (fs.existsSync(syncPath)) {
|
||||||
|
countFiles(syncPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the stats with actual file count
|
||||||
|
status.stats.totalFilesSynced = localFileCount;
|
||||||
|
status.stats.filesDownloaded = localFileCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('⚠️ Error updating file count:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.safeSendToRenderer('sync-status-changed', status);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get actual file count from local sync folder
|
||||||
|
*/
|
||||||
|
private getActualLocalFileCount(): number {
|
||||||
|
try {
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
let fileCount = 0;
|
||||||
|
const syncPath = this.configManager.getSyncConfig().localPath;
|
||||||
|
|
||||||
|
if (!fs.existsSync(syncPath)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const countFiles = (dirPath: string) => {
|
||||||
|
try {
|
||||||
|
const items = fs.readdirSync(dirPath);
|
||||||
|
for (const item of items) {
|
||||||
|
const fullPath = path.join(dirPath, item);
|
||||||
|
const stat = fs.statSync(fullPath);
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
countFiles(fullPath);
|
||||||
|
} else if (stat.isFile()) {
|
||||||
|
const ext = path.extname(item).toLowerCase();
|
||||||
|
if (['.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a', '.wma', '.aiff', '.alac', '.opus'].includes(ext)) {
|
||||||
|
fileCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Error counting files in ${dirPath}:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
countFiles(syncPath);
|
||||||
|
return fileCount;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error getting actual file count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safe send to renderer with better error handling
|
||||||
|
*/
|
||||||
|
private safeSendToRenderer(channel: string, data: any): void {
|
||||||
|
try {
|
||||||
|
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
||||||
|
console.log(`📤 Periodic update to renderer (${channel}):`, data);
|
||||||
|
this.mainWindow.webContents.send(channel, data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Failed to send periodic update to renderer (${channel}):`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle unhandled promise rejections
|
||||||
|
*/
|
||||||
|
private handleUnhandledRejection(reason: any, promise: Promise<any>): void {
|
||||||
|
console.error('🚨 Handling unhandled rejection:', { reason, promise });
|
||||||
|
|
||||||
|
// Don't show error popup for EPIPE-related rejections
|
||||||
|
if (reason && reason.message && reason.message.includes('EPIPE')) {
|
||||||
|
console.log('🔇 Suppressing EPIPE rejection popup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other rejections, log and continue
|
||||||
|
console.log('🔄 Continuing after unhandled rejection...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and run the application
|
||||||
|
new RekordboxSyncApp();
|
||||||
81
packages/desktop-sync/src/preload.ts
Normal file
81
packages/desktop-sync/src/preload.ts
Normal file
@ -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
|
||||||
715
packages/desktop-sync/src/services/awsS3Service.ts
Normal file
715
packages/desktop-sync/src/services/awsS3Service.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const process = spawn('aws', ['--version'], { stdio: 'pipe' });
|
||||||
|
|
||||||
|
process.on('close', (code) => {
|
||||||
|
resolve(code === 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('error', () => {
|
||||||
|
resolve(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
450
packages/desktop-sync/src/services/configManager.ts
Normal file
450
packages/desktop-sync/src/services/configManager.ts
Normal file
@ -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<AppConfig>;
|
||||||
|
private configPath: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Load environment variables from .env file
|
||||||
|
this.loadEnvironmentVariables();
|
||||||
|
|
||||||
|
this.store = new Store<AppConfig>({
|
||||||
|
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<AppConfig['s3']>): void {
|
||||||
|
const current = this.store.get('s3');
|
||||||
|
this.store.set('s3', { ...current, ...s3Config });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update sync configuration
|
||||||
|
*/
|
||||||
|
updateSyncConfig(syncConfig: Partial<AppConfig['sync']>): void {
|
||||||
|
const current = this.store.get('sync');
|
||||||
|
this.store.set('sync', { ...current, ...syncConfig });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update UI configuration
|
||||||
|
*/
|
||||||
|
updateUIConfig(uiConfig: Partial<AppConfig['ui']>): 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<void> {
|
||||||
|
const config = this.getConfig();
|
||||||
|
await fs.writeFile(exportPath, JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export configuration to .env file
|
||||||
|
*/
|
||||||
|
async exportToEnv(exportPath: string): Promise<void> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
234
packages/desktop-sync/src/services/fileWatcher.ts
Normal file
234
packages/desktop-sync/src/services/fileWatcher.ts
Normal file
@ -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<string, NodeJS.Timeout> = new Map();
|
||||||
|
private readonly DEBOUNCE_DELAY = 1000; // 1 second debounce
|
||||||
|
|
||||||
|
constructor(localPath: string) {
|
||||||
|
super();
|
||||||
|
this.localPath = localPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching the local directory
|
||||||
|
*/
|
||||||
|
start(): Promise<void> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
501
packages/desktop-sync/src/services/syncManager.ts
Normal file
501
packages/desktop-sync/src/services/syncManager.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<SyncState>): 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<number> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/desktop-sync/tsconfig.json
Normal file
24
packages/desktop-sync/tsconfig.json
Normal file
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user