diff --git a/build-output/minicloud-sync.exe b/build-output/minicloud-sync.exe new file mode 100755 index 0000000..ea44653 Binary files /dev/null and b/build-output/minicloud-sync.exe differ diff --git a/build-output/nsis/MiniCloud Sync_0.1.0_x64-setup.exe b/build-output/nsis/MiniCloud Sync_0.1.0_x64-setup.exe new file mode 100644 index 0000000..8d7f41b Binary files /dev/null and b/build-output/nsis/MiniCloud Sync_0.1.0_x64-setup.exe differ diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 0515f1e..be19031 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -1,17 +1,26 @@ mod sync; use std::path::PathBuf; -use std::sync::Mutex; -use tauri::State; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tauri::{ + menu::{Menu, MenuItem}, + tray::TrayIconBuilder, + AppHandle, Emitter, Manager, State, +}; use sync::api::MiniCloudApi; use sync::engine::SyncEngine; +use sync::watcher::{FileWatcher, ChangeKind}; struct AppState { api: Mutex>, sync_engine: Mutex>, sync_dir: Mutex>, username: Mutex>, + watcher: Mutex>, + sync_running: Arc>, + locked_files: Mutex>, // file IDs we have locked } #[tauri::command] @@ -44,28 +53,36 @@ fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result) -> Result, String> { +async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result, String> { let api = state.api.lock().unwrap().clone() .ok_or("Nicht eingeloggt")?; let sync_dir = state.sync_dir.lock().unwrap().clone() .ok_or("Kein Sync-Ordner gesetzt")?; - let mut engine = SyncEngine::new(sync_dir, api); + // Full sync + let mut engine = SyncEngine::new(sync_dir.clone(), api.clone()); let log = engine.full_sync().await?; *state.sync_engine.lock().unwrap() = Some(engine); + + // Start file watcher + let watcher = FileWatcher::new(&sync_dir)?; + *state.watcher.lock().unwrap() = Some(watcher); + + // Start background auto-sync + watcher processing + start_background_sync(app, state.sync_running.clone(), api, sync_dir); + + let _ = app.emit("sync-status", "synced"); Ok(log) } #[tauri::command] async fn delta_sync(state: State<'_, AppState>) -> Result, String> { - // Extract engine from state, dropping the MutexGuard before .await let mut engine = { let mut guard = state.sync_engine.lock().unwrap(); guard.take().ok_or("Sync nicht gestartet")? }; let result = engine.delta_sync().await; - // Put engine back *state.sync_engine.lock().unwrap() = Some(engine); result } @@ -75,11 +92,15 @@ async fn get_status(state: State<'_, AppState>) -> Result) -> Result, file_id: i64) -> Result { + let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; + api.lock_file(file_id, "Desktop Sync Client").await?; + state.locked_files.lock().unwrap().push(file_id); + Ok("Datei gesperrt".to_string()) +} + +#[tauri::command] +async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result { + let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; + api.unlock_file(file_id).await?; + state.locked_files.lock().unwrap().retain(|&id| id != file_id); + Ok("Datei entsperrt".to_string()) +} + +/// Background sync: auto-sync every 30s + process file watcher events + heartbeat for locks +fn start_background_sync( + app: AppHandle, + sync_running: Arc>, + api: MiniCloudApi, + sync_dir: PathBuf, +) { + // Auto-sync thread (delta sync every 30 seconds) + let app_sync = app.clone(); + let api_sync = api.clone(); + let sync_dir_clone = sync_dir.clone(); + let running = sync_running.clone(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut engine = SyncEngine::new(sync_dir_clone, api_sync); + + loop { + std::thread::sleep(Duration::from_secs(30)); + + *running.lock().unwrap() = true; + let _ = app_sync.emit("sync-status", "syncing"); + + match rt.block_on(engine.delta_sync()) { + Ok(log) => { + if !log.is_empty() { + let _ = app_sync.emit("sync-log", log); + } + } + Err(e) => { + let _ = app_sync.emit("sync-error", e); + } + } + + *running.lock().unwrap() = false; + let _ = app_sync.emit("sync-status", "synced"); + } + }); + + // Heartbeat thread (every 60 seconds for locked files) + let app_hb = app.clone(); + let api_hb = api.clone(); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + loop { + std::thread::sleep(Duration::from_secs(60)); + + let state = app_hb.state::(); + let locked = state.locked_files.lock().unwrap().clone(); + + for file_id in locked { + let _ = rt.block_on(api_hb.heartbeat(file_id)); + } + } + }); + + // File watcher processing thread + let app_watcher = app.clone(); + std::thread::spawn(move || { + loop { + std::thread::sleep(Duration::from_secs(2)); + + let state = app_watcher.state::(); + let watcher_guard = state.watcher.lock().unwrap(); + if let Some(watcher) = watcher_guard.as_ref() { + while let Ok(change) = watcher.receiver.try_recv() { + let rel_path = change.path.strip_prefix(&sync_dir) + .unwrap_or(&change.path) + .to_string_lossy() + .to_string(); + + let msg = match change.kind { + ChangeKind::Created => format!("Neu: {}", rel_path), + ChangeKind::Modified => format!("Geaendert: {}", rel_path), + ChangeKind::Deleted => format!("Geloescht: {}", rel_path), + }; + let _ = app_watcher.emit("file-change", msg); + } + } + } + }); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_notification::init()) .manage(AppState { api: Mutex::new(None), sync_engine: Mutex::new(None), sync_dir: Mutex::new(None), username: Mutex::new(None), + watcher: Mutex::new(None), + sync_running: Arc::new(Mutex::new(false)), + locked_files: Mutex::new(Vec::new()), + }) + .setup(|app| { + // System Tray + let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; + let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?; + let menu = Menu::with_items(app, &[&show, &quit])?; + + TrayIconBuilder::new() + .tooltip("Mini-Cloud Sync") + .menu(&menu) + .on_menu_event(|app, event| { + match event.id.as_ref() { + "quit" => std::process::exit(0), + "show" => { + if let Some(window) = app.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } + _ => {} + } + }) + .build(app)?; + + Ok(()) }) .invoke_handler(tauri::generate_handler![ login, @@ -108,6 +257,8 @@ pub fn run() { delta_sync, get_status, get_file_tree, + lock_file_cmd, + unlock_file_cmd, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/clients/desktop/src/App.vue b/clients/desktop/src/App.vue index 9bb2812..3176735 100644 --- a/clients/desktop/src/App.vue +++ b/clients/desktop/src/App.vue @@ -1,6 +1,7 @@ @@ -127,8 +159,11 @@ function formatSize(bytes) { ☁ - Mini-Cloud - {{ statusMsg }} + Mini-Cloud Sync + + ⟳ + {{ syncStatus }} + {{ userInfo?.username }} @@ -140,29 +175,49 @@ function formatSize(bytes) { Sync-Ordner - + - {{ syncing ? "Synchronisiere..." : "Jetzt synchronisieren" }} + {{ autoSyncActive ? "⟳ Auto-Sync aktiv" : (syncing ? "Synchronisiere..." : "Sync starten") }} + + Auto-Sync alle 30 Sekunden aktiv. Datei-Watcher ueberwacht lokale Aenderungen. + - Server-Dateien + + Server-Dateien + Aktualisieren + - - {{ entry.is_folder ? "📁" : "📄" }} - {{ entry.name }} - - 🔒 {{ entry.locked_by }} - - {{ formatSize(entry.size) }} - + + + {{ entry.is_folder ? "📁" : "📄" }} + {{ entry.name }} + 🔒 {{ entry.locked_by }} + {{ formatSize(entry.size) }} + + + {{ child.is_folder ? "📁" : "📄" }} + {{ child.name }} + 🔒 {{ child.locked_by }} + {{ formatSize(child.size) }} + + Keine Dateien + + + Lokale Aenderungen + + {{ msg }} + + + Sync-Protokoll @@ -180,9 +235,7 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; color: #1a1a1a; background: #f0f2f5; } -.login-screen { - height: 100vh; display: flex; align-items: center; justify-content: center; -} +.login-screen { height: 100vh; display: flex; align-items: center; justify-content: center; } .login-card { background: white; border-radius: 12px; padding: 2rem; width: 360px; box-shadow: 0 2px 12px rgba(0,0,0,0.1); @@ -200,11 +253,17 @@ body { .field input:focus { border-color: #4a90d9; outline: none; background: white; } .error { color: #e53e3e; font-size: 0.85rem; margin-bottom: 0.75rem; } .btn-primary { - width: 100%; padding: 0.6rem; background: #4a90d9; color: white; + padding: 0.6rem 1rem; background: #4a90d9; color: white; border: none; border-radius: 6px; font-size: 0.9rem; cursor: pointer; font-weight: 500; + white-space: nowrap; } .btn-primary:hover { background: #3a7bc8; } .btn-primary:disabled { opacity: 0.6; cursor: not-allowed; } +.btn-small { + padding: 0.25rem 0.5rem; background: #e8e8e8; border: none; border-radius: 4px; + font-size: 0.8rem; cursor: pointer; +} +.btn-small:hover { background: #ddd; } .main-screen { height: 100vh; display: flex; flex-direction: column; } .toolbar { @@ -213,32 +272,50 @@ body { } .toolbar-left { display: flex; align-items: center; gap: 0.5rem; } .logo-small { font-size: 1.2rem; } -.status-badge { font-size: 0.8rem; color: #666; background: #f0f0f0; padding: 0.2rem 0.5rem; border-radius: 4px; } +.status-badge { + font-size: 0.8rem; padding: 0.2rem 0.5rem; border-radius: 4px; + background: #e8f5e9; color: #2e7d32; +} +.status-badge.syncing { background: #fff3e0; color: #e65100; } +.status-badge.error { background: #ffebee; color: #c62828; } +.spin { display: inline-block; animation: spin 1s linear infinite; } +@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .user-info { font-size: 0.85rem; color: #666; } .content { flex: 1; overflow-y: auto; padding: 1rem; } -.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; } -.section h3 { margin-bottom: 0.75rem; font-size: 0.95rem; } +.section { background: white; border-radius: 8px; padding: 1rem; margin-bottom: 0.75rem; } +.section h3 { margin-bottom: 0.5rem; font-size: 0.95rem; } +.section-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; } +.section-header h3 { margin: 0; } .sync-row { display: flex; gap: 0.5rem; } -.sync-input { flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.85rem; } -.file-tree { max-height: 300px; overflow-y: auto; } +.sync-input { + flex: 1; padding: 0.5rem; border: 1px solid #ddd; border-radius: 6px; font-size: 0.85rem; +} +.sync-input:disabled { background: #f5f5f5; color: #999; } +.auto-sync-info { margin-top: 0.5rem; font-size: 0.8rem; color: #2e7d32; } +.file-tree { max-height: 250px; overflow-y: auto; } .tree-item { display: flex; align-items: center; gap: 0.5rem; - padding: 0.375rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem; + padding: 0.3rem 0; border-bottom: 1px solid #f5f5f5; font-size: 0.85rem; } +.tree-item.indent { padding-left: 1.5rem; } .tree-icon { flex-shrink: 0; } -.tree-name { flex: 1; } -.tree-lock { font-size: 0.75rem; color: #e67e22; } -.tree-size { font-size: 0.75rem; color: #999; } +.tree-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.tree-lock { font-size: 0.75rem; color: #e67e22; flex-shrink: 0; } +.tree-size { font-size: 0.75rem; color: #999; flex-shrink: 0; } .empty { text-align: center; color: #999; padding: 1rem; } -.log-list { max-height: 200px; overflow-y: auto; font-family: monospace; font-size: 0.8rem; } -.log-item { padding: 0.25rem 0; border-bottom: 1px solid #f5f5f5; } +.log-list { max-height: 150px; overflow-y: auto; font-family: monospace; font-size: 0.78rem; } +.log-item { padding: 0.2rem 0; border-bottom: 1px solid #f8f8f8; color: #555; } +.log-item.change { color: #1565c0; } @media (prefers-color-scheme: dark) { body { color: #e0e0e0; background: #1a1a1a; } .login-card, .section { background: #2a2a2a; } .toolbar { background: #2a2a2a; border-color: #3a3a3a; } .field input, .sync-input { background: #333; border-color: #444; color: #e0e0e0; } - .status-badge { background: #3a3a3a; color: #aaa; } + .status-badge { background: #1b5e20; color: #a5d6a7; } + .status-badge.syncing { background: #e65100; color: #ffcc80; } .tree-item { border-color: #333; } + .log-item { border-color: #333; color: #aaa; } + .log-item.change { color: #64b5f6; } }