Compare commits
2 Commits
b3da50e6ce
...
2428dabed7
| Author | SHA1 | Date | |
|---|---|---|---|
| 2428dabed7 | |||
| 9ede2d6bdb |
@@ -303,11 +303,15 @@ Der Desktop-Client (`clients/desktop/`) synchronisiert Dateien zwischen der Clou
|
|||||||
- **Virtual Files**: `.cloud`-Platzhalter (0 Bytes), Download erst bei Doppelklick. Kein Speicherverbrauch fuer nicht benoetigte Dateien
|
- **Virtual Files**: `.cloud`-Platzhalter (0 Bytes), Download erst bei Doppelklick. Kein Speicherverbrauch fuer nicht benoetigte Dateien
|
||||||
- **Full Sync**: Alternativ alle Dateien komplett lokal spiegeln (pro Pfad waehlbar)
|
- **Full Sync**: Alternativ alle Dateien komplett lokal spiegeln (pro Pfad waehlbar)
|
||||||
- **Offline-Markierung**: Einzelne Dateien als offline verfuegbar markieren (Rechtsklick im Datei-Browser)
|
- **Offline-Markierung**: Einzelne Dateien als offline verfuegbar markieren (Rechtsklick im Datei-Browser)
|
||||||
- **Sofort-Sync**: Filesystem-Watcher erkennt lokale Aenderungen sofort (3s Debounce), kein 30s-Polling
|
- **Sofort-Sync**: Filesystem-Watcher erkennt lokale Aenderungen sofort (3s Debounce), kein Polling
|
||||||
- **File Locking**: Automatisches Ein-/Auschecken mit Heartbeat
|
- **Intelligenter Sync**: Checksum-Tracking erkennt wer sich geaendert hat (Server oder Lokal)
|
||||||
|
- **Konflikt-Erkennung**: Bei gleichzeitiger Aenderung wird eine Konflikt-Kopie erstellt
|
||||||
|
- **File Locking**: Automatisches Ein-/Auschecken mit Heartbeat, Auto-Unlock bei Datei-Schliessung
|
||||||
- **System-Tray**: Minimiert in den Tray statt zu beenden, Doppelklick oeffnet Fenster
|
- **System-Tray**: Minimiert in den Tray statt zu beenden, Doppelklick oeffnet Fenster
|
||||||
|
- **Minimiert starten**: Optional direkt im Tray starten (Checkbox in Einstellungen)
|
||||||
- **Auto-Login**: Zugangsdaten und Sync-Pfade bleiben nach Neustart/Update erhalten
|
- **Auto-Login**: Zugangsdaten und Sync-Pfade bleiben nach Neustart/Update erhalten
|
||||||
- **Terminalserver**: Pro User eine eigene Instanz, keine Konflikte zwischen Benutzern
|
- **Terminalserver**: Pro User eine eigene Instanz, keine Konflikte zwischen Benutzern
|
||||||
|
- **.cloud Datei-Handler**: Doppelklick im Explorer oeffnet ueber den Client
|
||||||
|
|
||||||
### Terminalserver-Verhalten
|
### Terminalserver-Verhalten
|
||||||
|
|
||||||
@@ -329,6 +333,19 @@ Der Desktop-Client (`clients/desktop/`) synchronisiert Dateien zwischen der Clou
|
|||||||
| Upload | Neue lokale Dateien werden hochgeladen | Bidirektionaler Sync |
|
| Upload | Neue lokale Dateien werden hochgeladen | Bidirektionaler Sync |
|
||||||
| Empfehlung | Grosse Datenmengen, Laptops | Kleine Ordner, immer offline noetig |
|
| Empfehlung | Grosse Datenmengen, Laptops | Kleine Ordner, immer offline noetig |
|
||||||
|
|
||||||
|
### Sync-Logik (Checksum-Tracking)
|
||||||
|
|
||||||
|
Der Client merkt sich den Checksum jeder Datei beim letzten Sync. Beim naechsten Sync wird verglichen wer sich geaendert hat:
|
||||||
|
|
||||||
|
| Lokal geaendert | Server geaendert | Aktion |
|
||||||
|
|-----------------|------------------|--------|
|
||||||
|
| Nein | Ja | **Server -> Lokal** (Download) |
|
||||||
|
| Ja | Nein | **Lokal -> Server** (Upload) |
|
||||||
|
| Ja | Ja | **Konflikt**: Lokale Datei wird zu `Datei (Konflikt).txt`, Server-Version wird heruntergeladen |
|
||||||
|
| Nein | Nein | Nichts (identisch) |
|
||||||
|
|
||||||
|
Beim ersten Sync (kein gespeicherter Checksum) gewinnt immer der Server.
|
||||||
|
|
||||||
### Bauen
|
### Bauen
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -36,11 +36,14 @@ pub struct SyncEngine {
|
|||||||
pub api: MiniCloudApi,
|
pub api: MiniCloudApi,
|
||||||
pub sync_paths: Vec<SyncPath>,
|
pub sync_paths: Vec<SyncPath>,
|
||||||
last_sync: Option<String>,
|
last_sync: Option<String>,
|
||||||
|
/// Checksums from last sync - used to detect who changed a file
|
||||||
|
/// Key: file path (relative), Value: server checksum at last sync
|
||||||
|
known_checksums: HashMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SyncEngine {
|
impl SyncEngine {
|
||||||
pub fn new(api: MiniCloudApi) -> Self {
|
pub fn new(api: MiniCloudApi) -> Self {
|
||||||
Self { api, sync_paths: Vec::new(), last_sync: None }
|
Self { api, sync_paths: Vec::new(), last_sync: None, known_checksums: HashMap::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync all configured paths
|
/// Sync all configured paths
|
||||||
@@ -100,34 +103,53 @@ impl SyncEngine {
|
|||||||
if local_path.exists() {
|
if local_path.exists() {
|
||||||
let local_hash = compute_file_hash(&local_path);
|
let local_hash = compute_file_hash(&local_path);
|
||||||
let server_hash = entry.checksum.as_deref().unwrap_or("");
|
let server_hash = entry.checksum.as_deref().unwrap_or("");
|
||||||
|
let file_key = format!("{}/{}", server_path, entry.name);
|
||||||
|
|
||||||
if local_hash != server_hash {
|
if local_hash != server_hash {
|
||||||
if entry.locked.unwrap_or(false) {
|
if entry.locked.unwrap_or(false) {
|
||||||
log.push(format!("Zurueckgehalten (gesperrt): {}", entry.name));
|
log.push(format!("Zurueckgehalten (gesperrt): {}", entry.name));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Who is newer?
|
|
||||||
let local_modified = std::fs::metadata(&local_path)
|
|
||||||
.and_then(|m| m.modified()).ok();
|
|
||||||
let server_modified = entry.updated_at.as_deref()
|
|
||||||
.and_then(parse_server_time);
|
|
||||||
|
|
||||||
let server_is_newer = match (local_modified, server_modified) {
|
// Check if WE changed the file locally
|
||||||
(Some(lt), Some(st)) => st > lt,
|
let last_known = self.known_checksums.get(&file_key);
|
||||||
_ => false,
|
let local_changed = match last_known {
|
||||||
|
Some(known) => local_hash != *known, // local differs from last sync
|
||||||
|
None => false, // first sync, don't assume local changed
|
||||||
|
};
|
||||||
|
let server_changed = match last_known {
|
||||||
|
Some(known) => server_hash != known, // server differs from last sync
|
||||||
|
None => true, // first sync, trust server
|
||||||
};
|
};
|
||||||
|
|
||||||
if server_is_newer {
|
if server_changed && !local_changed {
|
||||||
|
// Only server changed -> download
|
||||||
match self.api.download_file(entry.id, &local_path).await {
|
match self.api.download_file(entry.id, &local_path).await {
|
||||||
Ok(_) => log.push(format!("Server->Lokal: {}", entry.name)),
|
Ok(_) => log.push(format!("Server->Lokal: {}", entry.name)),
|
||||||
Err(e) => log.push(format!("Download-Fehler {}: {}", entry.name, e)),
|
Err(e) => log.push(format!("Download-Fehler {}: {}", entry.name, e)),
|
||||||
}
|
}
|
||||||
} else {
|
} else if local_changed && !server_changed {
|
||||||
|
// Only local changed -> upload
|
||||||
match self.api.upload_file(&local_path, None).await {
|
match self.api.upload_file(&local_path, None).await {
|
||||||
Ok(_) => log.push(format!("Lokal->Server: {}", entry.name)),
|
Ok(_) => log.push(format!("Lokal->Server: {}", entry.name)),
|
||||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", entry.name, e)),
|
Err(e) => log.push(format!("Upload-Fehler {}: {}", entry.name, e)),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Both changed -> conflict! Download server, keep local as conflict copy
|
||||||
|
let conflict_name = format!("{} (Konflikt).{}",
|
||||||
|
local_path.file_stem().unwrap().to_string_lossy(),
|
||||||
|
local_path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default());
|
||||||
|
let conflict_path = local_path.parent().unwrap().join(&conflict_name);
|
||||||
|
std::fs::rename(&local_path, &conflict_path).ok();
|
||||||
|
match self.api.download_file(entry.id, &local_path).await {
|
||||||
|
Ok(_) => log.push(format!("KONFLIKT: {} (lokale Kopie: {})", entry.name, conflict_name)),
|
||||||
|
Err(e) => log.push(format!("Download-Fehler {}: {}", entry.name, e)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Track current server checksum
|
||||||
|
self.known_checksums.insert(file_key, server_hash.to_string());
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +235,6 @@ impl SyncEngine {
|
|||||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Existing file: check if changed (checksum compare)
|
|
||||||
if let Some(se) = server_entries.iter().find(|e| e.name == name) {
|
if let Some(se) = server_entries.iter().find(|e| e.name == name) {
|
||||||
if se.locked.unwrap_or(false) {
|
if se.locked.unwrap_or(false) {
|
||||||
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
|
||||||
@@ -224,33 +245,40 @@ impl SyncEngine {
|
|||||||
let server_hash = se.checksum.as_deref().unwrap_or("");
|
let server_hash = se.checksum.as_deref().unwrap_or("");
|
||||||
|
|
||||||
if local_hash != server_hash {
|
if local_hash != server_hash {
|
||||||
// Hashes differ - who is newer?
|
let file_key = name.clone();
|
||||||
let local_modified = std::fs::metadata(&path)
|
let last_known = self.known_checksums.get(&file_key);
|
||||||
.and_then(|m| m.modified())
|
let local_changed = match last_known {
|
||||||
.ok();
|
Some(known) => local_hash != *known,
|
||||||
let server_modified = se.updated_at.as_deref()
|
None => false,
|
||||||
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
|
};
|
||||||
.map(|dt| std::time::SystemTime::from(dt));
|
let server_changed = match last_known {
|
||||||
|
Some(known) => server_hash != known,
|
||||||
let server_is_newer = match (local_modified, server_modified) {
|
None => true,
|
||||||
(Some(local_t), Some(server_t)) => server_t > local_t,
|
|
||||||
_ => false, // if we can't compare, don't overwrite server
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if server_is_newer {
|
if server_changed && !local_changed {
|
||||||
// Server is newer -> download
|
|
||||||
match self.api.download_file(se.id, &path).await {
|
match self.api.download_file(se.id, &path).await {
|
||||||
Ok(_) => log.push(format!("Server->Lokal: {}", name)),
|
Ok(_) => log.push(format!("Server->Lokal: {}", name)),
|
||||||
Err(e) => log.push(format!("Download-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Download-Fehler {}: {}", name, e)),
|
||||||
}
|
}
|
||||||
} else {
|
} else if local_changed && !server_changed {
|
||||||
// Local is newer -> upload
|
|
||||||
match self.api.upload_file(&path, parent_id).await {
|
match self.api.upload_file(&path, parent_id).await {
|
||||||
Ok(_) => log.push(format!("Lokal->Server: {}", name)),
|
Ok(_) => log.push(format!("Lokal->Server: {}", name)),
|
||||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Both changed -> server wins, local becomes conflict copy
|
||||||
|
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||||
|
let stem = path.file_stem().unwrap().to_string_lossy();
|
||||||
|
let conflict_path = path.parent().unwrap().join(format!("{} (Konflikt).{}", stem, ext));
|
||||||
|
std::fs::rename(&path, &conflict_path).ok();
|
||||||
|
match self.api.download_file(se.id, &path).await {
|
||||||
|
Ok(_) => log.push(format!("KONFLIKT: {} -> {}", name, conflict_path.file_name().unwrap().to_string_lossy())),
|
||||||
|
Err(e) => log.push(format!("Download-Fehler {}: {}", name, e)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.known_checksums.insert(name, server_hash.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -339,29 +367,38 @@ impl SyncEngine {
|
|||||||
let local_hash = compute_file_hash(&path);
|
let local_hash = compute_file_hash(&path);
|
||||||
let server_hash = se.checksum.as_deref().unwrap_or("");
|
let server_hash = se.checksum.as_deref().unwrap_or("");
|
||||||
if local_hash != server_hash {
|
if local_hash != server_hash {
|
||||||
// Who is newer?
|
let last_known = self.known_checksums.get(&name);
|
||||||
let local_modified = std::fs::metadata(&path)
|
let local_changed = match last_known {
|
||||||
.and_then(|m| m.modified()).ok();
|
Some(known) => local_hash != *known,
|
||||||
let server_modified = se.updated_at.as_deref()
|
None => false,
|
||||||
.and_then(parse_server_time);
|
};
|
||||||
|
let server_changed = match last_known {
|
||||||
let server_is_newer = match (local_modified, server_modified) {
|
Some(known) => server_hash != known,
|
||||||
(Some(lt), Some(st)) => st > lt,
|
None => true,
|
||||||
_ => false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if server_is_newer {
|
if server_changed && !local_changed {
|
||||||
match self.api.download_file(se.id, &path).await {
|
match self.api.download_file(se.id, &path).await {
|
||||||
Ok(_) => log.push(format!("Server->Lokal: {}", name)),
|
Ok(_) => log.push(format!("Server->Lokal: {}", name)),
|
||||||
Err(e) => log.push(format!("Download-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Download-Fehler {}: {}", name, e)),
|
||||||
}
|
}
|
||||||
} else {
|
} else if local_changed && !server_changed {
|
||||||
match self.api.upload_file(&path, parent_id).await {
|
match self.api.upload_file(&path, parent_id).await {
|
||||||
Ok(_) => log.push(format!("Lokal->Server: {}", name)),
|
Ok(_) => log.push(format!("Lokal->Server: {}", name)),
|
||||||
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
let ext = path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
|
||||||
|
let stem = path.file_stem().unwrap().to_string_lossy();
|
||||||
|
let conflict_path = path.parent().unwrap().join(format!("{} (Konflikt).{}", stem, ext));
|
||||||
|
std::fs::rename(&path, &conflict_path).ok();
|
||||||
|
match self.api.download_file(se.id, &path).await {
|
||||||
|
Ok(_) => log.push(format!("KONFLIKT: {} -> {}", name, conflict_path.file_name().unwrap().to_string_lossy())),
|
||||||
|
Err(e) => log.push(format!("Download-Fehler {}: {}", name, e)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.known_checksums.insert(name, server_hash.to_string());
|
||||||
} else {
|
} else {
|
||||||
// New file, not on server
|
// New file, not on server
|
||||||
match self.api.upload_file(&path, parent_id).await {
|
match self.api.upload_file(&path, parent_id).await {
|
||||||
|
|||||||
Reference in New Issue
Block a user