feat: Desktop Sync Client (Tauri) - Grundgeruest

Tauri 2 Desktop-Client mit:

Rust-Backend:
- MiniCloudApi: Login, Token-Refresh, Upload, Download, Sync-Tree,
  Sync-Changes, File-Locking (Lock/Unlock/Heartbeat)
- SyncEngine: Full-Sync (Server-Tree vs. lokales Dateisystem),
  Delta-Sync (nur Aenderungen seit letztem Sync), bidirektionaler
  Abgleich mit SHA-256 Checksummen, Ordner-Erstellung,
  Lock-Status-Pruefung vor Upload, Konflikt-Erkennung
- FileWatcher: Filesystem-Watcher (notify crate) fuer Echtzeit-
  Erkennung lokaler Aenderungen, filtert temp/hidden files

Vue-Frontend:
- Login-Screen: Server-URL, Benutzername, Passwort
- Main-Screen: Sync-Ordner setzen, Sync starten, Dateiliste mit
  Lock-Status, Sync-Protokoll
- Dark-Mode Support

Tauri-Kommandos: login, set_sync_dir, start_sync, delta_sync,
  get_status, get_file_tree

Zum Bauen (Linux):
  sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev
  cd clients/desktop && npm install && npm run tauri build

Windows/Mac: Tauri Voraussetzungen installieren, dann gleicher Befehl

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 23:26:57 +02:00
parent 748537b9f5
commit 06ad65dbb3
39 changed files with 8735 additions and 0 deletions
@@ -0,0 +1,196 @@
use crate::sync::api::{FileEntry, MiniCloudApi};
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// Compare local files with server tree and determine actions
pub struct SyncEngine {
pub sync_dir: PathBuf,
pub api: MiniCloudApi,
last_sync: Option<String>,
}
#[derive(Debug, Clone)]
pub enum SyncAction {
Download { id: i64, name: String, server_path: String },
Upload { local_path: PathBuf, parent_id: Option<i64> },
CreateFolder { name: String, parent_id: Option<i64> },
Delete { local_path: PathBuf },
Conflict { local_path: PathBuf, server_id: i64, locked_by: String },
}
impl SyncEngine {
pub fn new(sync_dir: PathBuf, api: MiniCloudApi) -> Self {
Self { sync_dir, api, last_sync: None }
}
/// Full sync: compare server tree with local filesystem
pub async fn full_sync(&mut self) -> Result<Vec<String>, String> {
let server_tree = self.api.get_sync_tree().await?;
let mut log = Vec::new();
// Download missing/changed files from server
self.sync_download_recursive(&server_tree, &self.sync_dir.clone(), &mut log).await;
// Upload local files not on server
self.sync_upload_recursive(&server_tree, &self.sync_dir.clone(), None, &mut log).await;
// Update last sync time
self.last_sync = Some(chrono::Utc::now().to_rfc3339());
Ok(log)
}
/// Delta sync: only changes since last sync
pub async fn delta_sync(&mut self) -> Result<Vec<String>, String> {
let since = self.last_sync.clone().unwrap_or_else(|| "2000-01-01T00:00:00Z".to_string());
let changes = self.api.get_changes(&since).await?;
let mut log = Vec::new();
for entry in &changes.changes {
let rel_path = self.sync_dir.join(&entry.name);
if entry.is_folder {
if !rel_path.exists() {
std::fs::create_dir_all(&rel_path).ok();
log.push(format!("Ordner erstellt: {}", entry.name));
}
} else {
// Check if locked
if entry.locked.unwrap_or(false) {
log.push(format!("Uebersprungen (gesperrt von {}): {}",
entry.locked_by.as_deref().unwrap_or("?"), entry.name));
continue;
}
// Download if checksum differs
let needs_download = if rel_path.exists() {
let local_hash = compute_file_hash(&rel_path);
local_hash != entry.checksum.as_deref().unwrap_or("")
} else {
true
};
if needs_download {
match self.api.download_file(entry.id, &rel_path).await {
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)),
}
}
}
}
self.last_sync = Some(changes.server_time);
Ok(log)
}
async fn sync_download_recursive(&self, entries: &[FileEntry], local_dir: &Path, log: &mut Vec<String>) {
for entry in entries {
let local_path = local_dir.join(&entry.name);
if entry.is_folder {
std::fs::create_dir_all(&local_path).ok();
if let Some(children) = &entry.children {
Box::pin(self.sync_download_recursive(children, &local_path, log)).await;
}
} else {
// Skip locked files
if entry.locked.unwrap_or(false) {
continue;
}
let needs_download = if local_path.exists() {
let local_hash = compute_file_hash(&local_path);
local_hash != entry.checksum.as_deref().unwrap_or("")
} else {
true
};
if needs_download {
match self.api.download_file(entry.id, &local_path).await {
Ok(_) => log.push(format!("Heruntergeladen: {}", entry.name)),
Err(e) => log.push(format!("Fehler {}: {}", entry.name, e)),
}
}
}
}
}
async fn sync_upload_recursive(&self, server_entries: &[FileEntry], local_dir: &Path,
parent_id: Option<i64>, log: &mut Vec<String>) {
let server_names: HashMap<String, &FileEntry> = server_entries.iter()
.map(|e| (e.name.clone(), e))
.collect();
let entries = match std::fs::read_dir(local_dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let path = entry.path();
// Skip hidden files and sync metadata
if name.starts_with('.') || name == ".minicloud_sync" {
continue;
}
if path.is_dir() {
if let Some(server_entry) = server_names.get(&name) {
// Folder exists on server, recurse
if let Some(children) = &server_entry.children {
Box::pin(self.sync_upload_recursive(
children, &path, Some(server_entry.id), log
)).await;
}
} else {
// Folder doesn't exist on server, create + upload contents
match self.api.create_folder(&name, parent_id).await {
Ok(folder) => {
log.push(format!("Ordner erstellt: {}", name));
Box::pin(self.sync_upload_recursive(
&[], &path, Some(folder.id), log
)).await;
}
Err(e) => log.push(format!("Ordner-Fehler {}: {}", name, e)),
}
}
} else {
// File
let needs_upload = if let Some(server_entry) = server_names.get(&name) {
// Check if local version is newer
let local_hash = compute_file_hash(&path);
local_hash != server_entry.checksum.as_deref().unwrap_or("")
} else {
true // New file
};
if needs_upload {
// Check if file is locked on server
if let Some(server_entry) = server_names.get(&name) {
if server_entry.locked.unwrap_or(false) {
log.push(format!("Zurueckgehalten (gesperrt): {}", name));
continue;
}
}
match self.api.upload_file(&path, parent_id).await {
Ok(_) => log.push(format!("Hochgeladen: {}", name)),
Err(e) => log.push(format!("Upload-Fehler {}: {}", name, e)),
}
}
}
}
}
}
pub fn compute_file_hash(path: &Path) -> String {
let data = match std::fs::read(path) {
Ok(d) => d,
Err(_) => return String::new(),
};
let mut hasher = Sha256::new();
hasher.update(&data);
format!("{:x}", hasher.finalize())
}