From b937351556015b8beb4e85a548043fae45d86cfd Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Sun, 12 Apr 2026 01:44:40 +0200 Subject: [PATCH] feat: Auto-Unlock wenn Datei geschlossen wird Problem: Nach Oeffnen einer .cloud-Datei blieb die Sperre auf dem Server bestehen, auch wenn Word/Excel geschlossen wurde. Loesung: Hintergrund-Thread prueft alle 10 Sekunden ob geoeffnete Dateien noch von einem Prozess benutzt werden: Windows: Versucht exklusiven Schreibzugriff - wenn erfolgreich ist die Datei nicht mehr in Benutzung (Office gibt den Lock frei) Linux/Mac: lsof prueft ob ein Prozess die Datei offen hat Wenn Datei geschlossen: 1. Aenderungen werden zum Server hochgeladen 2. Server-Lock wird aufgehoben 3. .cloud Platzhalter wird neu erstellt (mit aktuellem Checksum) 4. Lokale Kopie wird geloescht 5. UI zeigt "Geschlossen + entsperrt: datei.cloud" Tracking: opened_files HashMap speichert file_id -> Pfad + Cloud-Name fuer alle via .cloud geoeffneten Dateien. Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/desktop/src-tauri/src/lib.rs | 109 +++++++++++++++++++++++++-- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index bf9d030..f110cee 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -14,6 +14,16 @@ use sync::config::AppConfig; use sync::engine::{SyncEngine, SyncMode, SyncPath}; use sync::watcher::{FileWatcher, ChangeKind}; +use std::collections::HashMap; + +/// Tracks a file opened from a .cloud placeholder +#[derive(Clone, Debug)] +struct OpenedFile { + file_id: i64, + real_path: PathBuf, + cloud_name: String, // original .cloud filename +} + struct AppState { api: Mutex>, sync_engine: Mutex>, @@ -21,6 +31,7 @@ struct AppState { watchers: Mutex>, sync_running: Arc>, locked_files: Mutex>, + opened_files: Mutex>, // file_id -> opened file info sync_paths: Mutex>, } @@ -225,10 +236,17 @@ async fn open_cloud_file(state: State<'_, AppState>, cloud_path: String) -> Resu // Remove placeholder std::fs::remove_file(&path).ok(); - // Lock + // Lock on server let _ = engine.lock_file(file_id, "Desktop Sync Client").await; state.locked_files.lock().unwrap().push(file_id); + // Track opened file for auto-close detection + state.opened_files.lock().unwrap().insert(file_id, OpenedFile { + file_id, + real_path: real_path.clone(), + cloud_name: path.file_name().unwrap().to_string_lossy().to_string(), + }); + // Open with default application let _ = open::that(&real_path); @@ -463,17 +481,71 @@ fn start_background_sync( } }); - // Heartbeat every 60 seconds + // Heartbeat every 60s + check if opened files are still in use 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)); + std::thread::sleep(Duration::from_secs(10)); let state = app_hb.state::(); + + // Heartbeat for locked files let locked = state.locked_files.lock().unwrap().clone(); - for file_id in locked { - let _ = rt.block_on(api_hb.heartbeat(file_id)); + for file_id in &locked { + let _ = rt.block_on(api_hb.heartbeat(*file_id)); + } + + // Check if opened files are still in use by another process + let opened = state.opened_files.lock().unwrap().clone(); + for (file_id, info) in &opened { + if !info.real_path.exists() { + // File was deleted/moved - clean up + state.opened_files.lock().unwrap().remove(file_id); + state.locked_files.lock().unwrap().retain(|&id| id != *file_id); + let _ = rt.block_on(api_hb.unlock_file(*file_id)); + let _ = app_hb.emit("file-change", + format!("Geschlossen + entsperrt: {}", info.cloud_name)); + continue; + } + + // Check if file is still locked by another process + let still_open = is_file_in_use(&info.real_path); + if !still_open { + // File closed! Sync back, recreate .cloud, unlock + let _ = app_hb.emit("file-change", + format!("Datei geschlossen, synchronisiere: {}", info.real_path.file_name().unwrap().to_string_lossy())); + + // Upload changes + let _ = rt.block_on(api_hb.upload_file(&info.real_path, None)); + + // Unlock on server + let _ = rt.block_on(api_hb.unlock_file(*file_id)); + + // Recreate .cloud placeholder + let cloud_path = info.real_path.parent().unwrap().join(&info.cloud_name); + let size = std::fs::metadata(&info.real_path).map(|m| m.len() as i64).unwrap_or(0); + let checksum = sync::engine::compute_file_hash(&info.real_path); + let placeholder = serde_json::json!({ + "id": file_id, + "name": info.real_path.file_name().unwrap().to_string_lossy(), + "size": size, + "checksum": checksum, + "updated_at": chrono::Utc::now().to_rfc3339(), + "server_path": "", + }); + std::fs::write(&cloud_path, serde_json::to_string_pretty(&placeholder).unwrap()).ok(); + + // Remove local copy + std::fs::remove_file(&info.real_path).ok(); + + // Clean up tracking + state.opened_files.lock().unwrap().remove(file_id); + state.locked_files.lock().unwrap().retain(|&id| id != *file_id); + + let _ = app_hb.emit("file-change", + format!("Entsperrt + .cloud: {}", info.cloud_name)); + } } } }); @@ -526,6 +598,32 @@ fn start_background_sync( // --- App Setup --- /// Check if another instance is running. If yes, pass the .cloud file to it and exit. +/// Check if a file is still being used by another process +fn is_file_in_use(path: &Path) -> bool { + // Try to open the file with exclusive access + // If it fails, another process has it open + #[cfg(target_os = "windows")] + { + use std::fs::OpenOptions; + // On Windows, try to open with write access - fails if file is locked by Office etc. + match OpenOptions::new().write(true).open(path) { + Ok(_) => false, // We could open it -> not in use + Err(_) => true, // Can't open -> still in use + } + } + #[cfg(not(target_os = "windows"))] + { + // On Linux/Mac, check /proc or lsof + let output = std::process::Command::new("lsof") + .arg(path.to_string_lossy().as_ref()) + .output(); + match output { + Ok(o) => !o.stdout.is_empty(), // lsof found processes -> in use + Err(_) => false, // lsof not available -> assume not in use + } + } +} + /// Single instance per user. On terminal servers each user gets their own /// instance because the lock file is in %APPDATA% (user-specific). fn handle_single_instance() { @@ -594,6 +692,7 @@ pub fn run() { watchers: Mutex::new(Vec::new()), sync_running: Arc::new(Mutex::new(false)), locked_files: Mutex::new(Vec::new()), + opened_files: Mutex::new(HashMap::new()), sync_paths: Mutex::new(Vec::new()), }) .on_window_event(|window, event| {