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
+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "minicloud-sync"
version = "0.1.0"
description = "Mini-Cloud Desktop Sync Client"
authors = ["Mini-Cloud"]
edition = "2021"
[lib]
name = "minicloud_sync_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-opener = "2"
tauri-plugin-dialog = "2"
tauri-plugin-notification = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
tokio = { version = "1", features = ["full"] }
notify = "7"
sha2 = "0.10"
dirs = "6"
rusqlite = { version = "0.34", features = ["bundled"] }
chrono = { version = "0.4", features = ["serde"] }
keyring = "3"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+108
View File
@@ -0,0 +1,108 @@
mod sync;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::State;
use sync::api::MiniCloudApi;
use sync::engine::SyncEngine;
struct AppState {
api: Mutex<Option<MiniCloudApi>>,
sync_engine: Mutex<Option<SyncEngine>>,
sync_dir: Mutex<Option<PathBuf>>,
username: Mutex<Option<String>>,
}
#[tauri::command]
async fn login(
state: State<'_, AppState>,
server_url: String,
username: String,
password: String,
) -> Result<serde_json::Value, String> {
let mut api = MiniCloudApi::new(&server_url);
let result = api.login(&username, &password).await?;
*state.api.lock().unwrap() = Some(api);
*state.username.lock().unwrap() = Some(username);
Ok(serde_json::json!({
"username": result.user.username,
"role": result.user.role,
}))
}
#[tauri::command]
fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result<String, String> {
let sync_path = PathBuf::from(&path);
if !sync_path.exists() {
std::fs::create_dir_all(&sync_path).map_err(|e| e.to_string())?;
}
*state.sync_dir.lock().unwrap() = Some(sync_path);
Ok(format!("Sync-Ordner gesetzt: {}", path))
}
#[tauri::command]
async fn start_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let api = state.api.lock().unwrap().clone()
.ok_or("Nicht eingeloggt")?;
let sync_dir = state.sync_dir.lock().unwrap().clone()
.ok_or("Kein Sync-Ordner gesetzt")?;
let mut engine = SyncEngine::new(sync_dir, api);
let log = engine.full_sync().await?;
*state.sync_engine.lock().unwrap() = Some(engine);
Ok(log)
}
#[tauri::command]
async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
let mut engine_guard = state.sync_engine.lock().unwrap();
let engine = engine_guard.as_mut().ok_or("Sync nicht gestartet")?;
engine.delta_sync().await
}
#[tauri::command]
async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
let logged_in = state.api.lock().unwrap().is_some();
let sync_dir = state.sync_dir.lock().unwrap().clone();
let username = state.username.lock().unwrap().clone();
Ok(serde_json::json!({
"logged_in": logged_in,
"username": username,
"sync_dir": sync_dir.map(|p| p.to_string_lossy().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")?;
let tree = api.get_sync_tree().await?;
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.manage(AppState {
api: Mutex::new(None),
sync_engine: Mutex::new(None),
sync_dir: Mutex::new(None),
username: Mutex::new(None),
})
.invoke_handler(tauri::generate_handler![
login,
set_sync_dir,
start_sync,
delta_sync,
get_status,
get_file_tree,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
tauri_app_lib::run()
}
+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(())
}
}
@@ -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())
}
@@ -0,0 +1,3 @@
pub mod api;
pub mod engine;
pub mod watcher;
@@ -0,0 +1,58 @@
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::PathBuf;
use std::sync::mpsc;
pub struct FileWatcher {
_watcher: RecommendedWatcher,
pub receiver: mpsc::Receiver<FileChange>,
}
#[derive(Debug, Clone)]
pub struct FileChange {
pub path: PathBuf,
pub kind: ChangeKind,
}
#[derive(Debug, Clone)]
pub enum ChangeKind {
Created,
Modified,
Deleted,
}
impl FileWatcher {
pub fn new(watch_dir: &PathBuf) -> Result<Self, String> {
let (tx, rx) = mpsc::channel();
let mut watcher = RecommendedWatcher::new(
move |result: Result<Event, notify::Error>| {
if let Ok(event) = result {
let kind = match event.kind {
EventKind::Create(_) => Some(ChangeKind::Created),
EventKind::Modify(_) => Some(ChangeKind::Modified),
EventKind::Remove(_) => Some(ChangeKind::Deleted),
_ => None,
};
if let Some(kind) = kind {
for path in event.paths {
// Skip hidden files and temp files
let name = path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if name.starts_with('.') || name.starts_with('~') || name.ends_with(".tmp") {
continue;
}
let _ = tx.send(FileChange { path, kind: kind.clone() });
}
}
}
},
Config::default(),
).map_err(|e| format!("Watcher-Fehler: {}", e))?;
watcher.watch(watch_dir.as_ref(), RecursiveMode::Recursive)
.map_err(|e| format!("Watch-Fehler: {}", e))?;
Ok(Self { _watcher: watcher, receiver: rx })
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "MiniCloud Sync",
"version": "0.1.0",
"identifier": "com.minicloud.sync",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Mini-Cloud Sync",
"width": 700,
"height": 550,
"resizable": true,
"center": true
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}