Compare commits

..

2 Commits

Author SHA1 Message Date
Stefan Hacker 2bd8a2e1b5 feat: Heartbeat fuer Locks - vergessene Locks laufen nach 15 Min ab
Wenn jemand vergisst zu entsperren:
- Client laeuft -> Heartbeat alle 60s -> Lock bleibt aktiv
- Client geschlossen -> kein Heartbeat -> Lock laeuft nach 15 Min ab
- Laptop zugeklappt -> gleicher Effekt -> 15 Min -> frei

Tracking: locked_files Vec merkt sich welche Dateien wir gesperrt haben.
Heartbeat laeuft im Token-Refresh Thread mit (alle 60s Heartbeat,
alle 10 Min Token-Refresh).

Lock wird beim Oeffnen getrackt, beim Entsperren/Unmark-Offline entfernt.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:04:28 +02:00
Stefan Hacker 597dafc461 feat: File Lock beim Oeffnen + Entsperren per Rechtsklick
Beim Oeffnen einer .cloud-Datei:
- Download + Datei bleibt lokal (wie bisher)
- Lock wird auf dem Server gesetzt (andere sehen "gesperrt von X")
- Kein Auto-Unlock - Datei bleibt gesperrt bis manuell entsperrt

Rechtsklick im Datei-Browser auf Offline-Dateien:
- "Entsperren (Freigeben fuer andere)" - hebt den Lock auf
- "Nicht mehr offline" - .cloud zurueck + automatisch unlock

So bleiben Dateien gesperrt solange man daran arbeitet.
Wenn fertig: Rechtsklick -> Entsperren. Einfach und explizit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 03:03:01 +02:00
2 changed files with 72 additions and 8 deletions
+42 -8
View File
@@ -19,6 +19,7 @@ struct AppState {
sync_engine: Mutex<Option<SyncEngine>>,
username: Mutex<Option<String>>,
watchers: Mutex<Vec<FileWatcher>>,
locked_files: Mutex<Vec<i64>>, // file IDs we have locked on server
sync_running: Arc<Mutex<bool>>,
sync_paths: Mutex<Vec<SyncPath>>,
}
@@ -256,11 +257,20 @@ async fn open_cloud_file(state: State<'_, AppState>, cloud_path: String) -> Resu
}
eprintln!("[OpenCloud] Downloaded {} bytes", std::fs::metadata(&real_path).map(|m| m.len()).unwrap_or(0));
// Remove .cloud placeholder - file stays as real file (like "offline markieren")
// Remove .cloud placeholder - file stays as real file
// Changes will be synced automatically by the file watcher
// User can manually "unmark offline" via right-click to get .cloud back
// User can "unmark offline" or "unlock" via right-click
std::fs::remove_file(&path).ok();
// Lock on server (prevents others from editing)
match api.lock_file(file_id, "Desktop Sync Client").await {
Ok(_) => {
eprintln!("[OpenCloud] Locked on server");
state.locked_files.lock().unwrap().push(file_id);
}
Err(e) => eprintln!("[OpenCloud] Lock failed (file may be locked by someone else): {}", e),
}
// Open with default application for this file type
eprintln!("[OpenCloud] Opening with default app: {}", real_path.display());
open::that(&real_path)
@@ -269,6 +279,14 @@ async fn open_cloud_file(state: State<'_, AppState>, cloud_path: String) -> Resu
Ok(real_path.to_string_lossy().to_string())
}
#[tauri::command]
async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
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())
}
#[tauri::command]
async fn get_file_tree(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
@@ -482,22 +500,36 @@ fn start_background_sync(
}
});
// Token refresh every 10 minutes
// Token refresh (every 10 min) + Heartbeat for locks (every 60s)
let app_hb = app.clone();
let api_hb = api.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut api_mut = api_hb.clone();
let mut tick = 0u32;
loop {
std::thread::sleep(Duration::from_secs(600));
std::thread::sleep(Duration::from_secs(10));
tick += 10;
let state = app_hb.state::<AppState>();
if let Ok(new_token) = rt.block_on(api_mut.refresh_token()) {
if let Some(ref mut api) = *state.api.lock().unwrap() {
api.access_token = new_token;
// Heartbeat every 60 seconds for locked files
if tick % 60 == 0 {
let locked = state.locked_files.lock().unwrap().clone();
for file_id in &locked {
let _ = rt.block_on(api_mut.heartbeat(*file_id));
}
}
// Token refresh every 10 minutes
if tick >= 600 {
tick = 0;
if let Ok(new_token) = rt.block_on(api_mut.refresh_token()) {
if let Some(ref mut api) = *state.api.lock().unwrap() {
api.access_token = new_token;
}
eprintln!("[Auth] Token refreshed");
}
eprintln!("[Auth] Token refreshed");
}
}
});
@@ -617,6 +649,7 @@ pub fn run() {
username: Mutex::new(None),
watchers: Mutex::new(Vec::new()),
sync_running: Arc::new(Mutex::new(false)),
locked_files: Mutex::new(Vec::new()),
sync_paths: Mutex::new(Vec::new()),
})
.on_window_event(|window, event| {
@@ -726,6 +759,7 @@ pub fn run() {
open_cloud_file,
get_file_tree,
get_status,
unlock_file_cmd,
browse_sync_folder,
mark_offline,
unmark_offline,
+30
View File
@@ -84,6 +84,33 @@ async function doMarkOffline(file) {
}
}
async function doUnlockFile(file) {
hideContextMenu();
// Find file ID from server tree
const serverFile = findFileInTree(fileTree.value, file.name);
if (!serverFile) {
syncLog.value = [`[${ts()}] Fehler: Datei nicht auf Server gefunden`, ...syncLog.value];
return;
}
try {
await invoke("unlock_file_cmd", { fileId: serverFile.id });
syncLog.value = [`[${ts()}] Entsperrt: ${file.name}`, ...syncLog.value].slice(0, 200);
} catch (err) {
syncLog.value = [`[${ts()}] Fehler: ${err}`, ...syncLog.value].slice(0, 200);
}
}
function findFileInTree(entries, name) {
for (const e of entries) {
if (e.name === name) return e;
if (e.children) {
const found = findFileInTree(e.children, name);
if (found) return found;
}
}
return null;
}
async function doUnmarkOffline(file) {
hideContextMenu();
try {
@@ -429,6 +456,9 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doUnmarkOffline(contextMenu.file)">
☁ Nicht mehr offline (Platzhalter)
</div>
<div v-if="contextMenu.file?.is_offline" class="cm-item" @click="doUnlockFile(contextMenu.file)">
🔓 Entsperren (Freigeben fuer andere)
</div>
<div class="cm-item" @click="hideContextMenu">Abbrechen</div>
</div>