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:
@@ -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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user