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
+250
View File
@@ -0,0 +1,250 @@
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Clone)]
pub struct MiniCloudApi {
client: Client,
pub server_url: String,
pub access_token: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LoginResponse {
pub access_token: String,
pub user: UserInfo,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub id: i64,
pub username: String,
pub role: String,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct FileEntry {
pub id: i64,
pub name: String,
pub is_folder: bool,
pub size: Option<i64>,
pub checksum: Option<String>,
pub updated_at: Option<String>,
pub children: Option<Vec<FileEntry>>,
pub locked: Option<bool>,
pub locked_by: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SyncTreeResponse {
pub tree: Vec<FileEntry>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SyncChangesResponse {
pub changes: Vec<FileEntry>,
pub server_time: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct LockResponse {
pub locked: Option<bool>,
pub locked_by: Option<String>,
pub error: Option<String>,
}
impl MiniCloudApi {
pub fn new(server_url: &str) -> Self {
Self {
client: Client::builder()
.danger_accept_invalid_certs(false)
.build()
.unwrap(),
server_url: server_url.trim_end_matches('/').to_string(),
access_token: String::new(),
}
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.access_token)
}
pub async fn login(&mut self, username: &str, password: &str) -> Result<LoginResponse, String> {
let url = format!("{}/api/auth/login", self.server_url);
let body = serde_json::json!({
"username": username,
"password": password,
});
let resp = self.client.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("Verbindungsfehler: {}", e))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("Login fehlgeschlagen: {}", text));
}
let data: LoginResponse = resp.json().await
.map_err(|e| format!("Antwort-Fehler: {}", e))?;
self.access_token = data.access_token.clone();
Ok(data)
}
pub async fn refresh_token(&mut self) -> Result<String, String> {
let url = format!("{}/api/auth/refresh", self.server_url);
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Refresh fehlgeschlagen: {}", e))?;
if !resp.status().is_success() {
return Err("Token-Refresh fehlgeschlagen".to_string());
}
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
if let Some(token) = data.get("access_token").and_then(|t| t.as_str()) {
self.access_token = token.to_string();
Ok(token.to_string())
} else {
Err("Kein Token in Antwort".to_string())
}
}
pub async fn get_sync_tree(&self) -> Result<Vec<FileEntry>, String> {
let url = format!("{}/api/sync/tree", self.server_url);
let resp = self.client.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Sync-Tree Fehler: {}", e))?;
let data: SyncTreeResponse = resp.json().await
.map_err(|e| format!("Parse-Fehler: {}", e))?;
Ok(data.tree)
}
pub async fn get_changes(&self, since: &str) -> Result<SyncChangesResponse, String> {
let url = format!("{}/api/sync/changes?since={}", self.server_url, since);
let resp = self.client.get(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| format!("Changes Fehler: {}", e))?;
resp.json().await.map_err(|e| format!("Parse-Fehler: {}", e))
}
pub async fn download_file(&self, file_id: i64, dest: &Path) -> Result<(), String> {
let url = format!("{}/api/files/{}/download?token={}",
self.server_url, file_id, self.access_token);
let resp = self.client.get(&url)
.send()
.await
.map_err(|e| format!("Download Fehler: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Download fehlgeschlagen: {}", resp.status()));
}
let bytes = resp.bytes().await.map_err(|e| e.to_string())?;
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(dest, &bytes).map_err(|e| format!("Schreiben fehlgeschlagen: {}", e))
}
pub async fn upload_file(&self, file_path: &Path, parent_id: Option<i64>) -> Result<FileEntry, String> {
let url = format!("{}/api/files/upload", self.server_url);
let file_name = file_path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file")
.to_string();
let file_bytes = std::fs::read(file_path)
.map_err(|e| format!("Datei lesen fehlgeschlagen: {}", e))?;
let mut form = reqwest::multipart::Form::new()
.part("file", reqwest::multipart::Part::bytes(file_bytes).file_name(file_name));
if let Some(pid) = parent_id {
form = form.text("parent_id", pid.to_string());
}
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.multipart(form)
.send()
.await
.map_err(|e| format!("Upload Fehler: {}", e))?;
if !resp.status().is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(format!("Upload fehlgeschlagen: {}", text));
}
resp.json().await.map_err(|e| format!("Parse-Fehler: {}", e))
}
pub async fn create_folder(&self, name: &str, parent_id: Option<i64>) -> Result<FileEntry, String> {
let url = format!("{}/api/files/folder", self.server_url);
let body = serde_json::json!({
"name": name,
"parent_id": parent_id,
});
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
resp.json().await.map_err(|e| e.to_string())
}
pub async fn lock_file(&self, file_id: i64, client_info: &str) -> Result<(), String> {
let url = format!("{}/api/files/{}/lock", self.server_url, file_id);
let body = serde_json::json!({ "client_info": client_info });
let resp = self.client.post(&url)
.header("Authorization", self.auth_header())
.json(&body)
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status().as_u16() == 423 {
let data: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
let by = data.get("locked_by").and_then(|v| v.as_str()).unwrap_or("?");
return Err(format!("Datei gesperrt von {}", by));
}
if !resp.status().is_success() {
return Err("Lock fehlgeschlagen".to_string());
}
Ok(())
}
pub async fn unlock_file(&self, file_id: i64) -> Result<(), String> {
let url = format!("{}/api/files/{}/unlock", self.server_url, file_id);
self.client.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
pub async fn heartbeat(&self, file_id: i64) -> Result<(), String> {
let url = format!("{}/api/files/{}/heartbeat", self.server_url, file_id);
self.client.post(&url)
.header("Authorization", self.auth_header())
.send()
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}