feat: Desktop-Client komplett - Auto-Sync, Watcher, Locking, Tray

Alles eingebunden was vorher nur als unused code existierte:

Auto-Sync:
- Nach erstem Sync laeuft alle 30s ein Delta-Sync im Hintergrund
- Status-Badge zeigt live: Synchronisiert / Synchronisiere... / Fehler
- Sync-Protokoll mit Timestamps

File-Watcher:
- Ueberwacht den Sync-Ordner auf lokale Aenderungen (Erstellt/Geaendert/Geloescht)
- Aenderungen werden im UI unter "Lokale Aenderungen" angezeigt
- Filtert temp/hidden files automatisch

File-Locking:
- lock_file_cmd / unlock_file_cmd Tauri-Kommandos
- Heartbeat-Thread sendet alle 60s Heartbeat fuer gesperrte Dateien
- locked_files Liste im State

System-Tray:
- Tray-Icon mit "Mini-Cloud Sync" Tooltip
- Rechtsklick-Menue: Oeffnen / Beenden
- "Oeffnen" zeigt das Hauptfenster

UI:
- Status-Badge mit Farbe (gruen=synced, orange=syncing, rot=error)
- Spinning-Icon waehrend Sync
- "Auto-Sync aktiv" Hinweis nach erstem Sync
- Sync-Ordner wird nach Start gesperrt (nicht mehr aenderbar)
- Lokale Aenderungen und Sync-Log mit Timestamps

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-11 23:54:54 +02:00
parent 8342cbfa17
commit 714ce1ae53
4 changed files with 291 additions and 63 deletions
+157 -6
View File
@@ -1,17 +1,26 @@
mod sync;
use std::path::PathBuf;
use std::sync::Mutex;
use tauri::State;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
AppHandle, Emitter, Manager, State,
};
use sync::api::MiniCloudApi;
use sync::engine::SyncEngine;
use sync::watcher::{FileWatcher, ChangeKind};
struct AppState {
api: Mutex<Option<MiniCloudApi>>,
sync_engine: Mutex<Option<SyncEngine>>,
sync_dir: Mutex<Option<PathBuf>>,
username: Mutex<Option<String>>,
watcher: Mutex<Option<FileWatcher>>,
sync_running: Arc<Mutex<bool>>,
locked_files: Mutex<Vec<i64>>, // file IDs we have locked
}
#[tauri::command]
@@ -44,28 +53,36 @@ fn set_sync_dir(state: State<'_, AppState>, path: String) -> Result<String, Stri
}
#[tauri::command]
async fn start_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
async fn start_sync(app: AppHandle, 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);
// Full sync
let mut engine = SyncEngine::new(sync_dir.clone(), api.clone());
let log = engine.full_sync().await?;
*state.sync_engine.lock().unwrap() = Some(engine);
// Start file watcher
let watcher = FileWatcher::new(&sync_dir)?;
*state.watcher.lock().unwrap() = Some(watcher);
// Start background auto-sync + watcher processing
start_background_sync(app, state.sync_running.clone(), api, sync_dir);
let _ = app.emit("sync-status", "synced");
Ok(log)
}
#[tauri::command]
async fn delta_sync(state: State<'_, AppState>) -> Result<Vec<String>, String> {
// Extract engine from state, dropping the MutexGuard before .await
let mut engine = {
let mut guard = state.sync_engine.lock().unwrap();
guard.take().ok_or("Sync nicht gestartet")?
};
let result = engine.delta_sync().await;
// Put engine back
*state.sync_engine.lock().unwrap() = Some(engine);
result
}
@@ -75,11 +92,15 @@ async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, Str
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();
let syncing = *state.sync_running.lock().unwrap();
let locked_count = state.locked_files.lock().unwrap().len();
Ok(serde_json::json!({
"logged_in": logged_in,
"username": username,
"sync_dir": sync_dir.map(|p| p.to_string_lossy().to_string()),
"syncing": syncing,
"locked_files": locked_count,
}))
}
@@ -91,15 +112,143 @@ async fn get_file_tree(state: State<'_, AppState>) -> Result<serde_json::Value,
Ok(serde_json::to_value(tree).map_err(|e| e.to_string())?)
}
#[tauri::command]
async fn lock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
api.lock_file(file_id, "Desktop Sync Client").await?;
state.locked_files.lock().unwrap().push(file_id);
Ok("Datei gesperrt".to_string())
}
#[tauri::command]
async fn unlock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result<String, String> {
let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?;
api.unlock_file(file_id).await?;
state.locked_files.lock().unwrap().retain(|&id| id != file_id);
Ok("Datei entsperrt".to_string())
}
/// Background sync: auto-sync every 30s + process file watcher events + heartbeat for locks
fn start_background_sync(
app: AppHandle,
sync_running: Arc<Mutex<bool>>,
api: MiniCloudApi,
sync_dir: PathBuf,
) {
// Auto-sync thread (delta sync every 30 seconds)
let app_sync = app.clone();
let api_sync = api.clone();
let sync_dir_clone = sync_dir.clone();
let running = sync_running.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
let mut engine = SyncEngine::new(sync_dir_clone, api_sync);
loop {
std::thread::sleep(Duration::from_secs(30));
*running.lock().unwrap() = true;
let _ = app_sync.emit("sync-status", "syncing");
match rt.block_on(engine.delta_sync()) {
Ok(log) => {
if !log.is_empty() {
let _ = app_sync.emit("sync-log", log);
}
}
Err(e) => {
let _ = app_sync.emit("sync-error", e);
}
}
*running.lock().unwrap() = false;
let _ = app_sync.emit("sync-status", "synced");
}
});
// Heartbeat thread (every 60 seconds for locked files)
let app_hb = app.clone();
let api_hb = api.clone();
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
loop {
std::thread::sleep(Duration::from_secs(60));
let state = app_hb.state::<AppState>();
let locked = state.locked_files.lock().unwrap().clone();
for file_id in locked {
let _ = rt.block_on(api_hb.heartbeat(file_id));
}
}
});
// File watcher processing thread
let app_watcher = app.clone();
std::thread::spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(2));
let state = app_watcher.state::<AppState>();
let watcher_guard = state.watcher.lock().unwrap();
if let Some(watcher) = watcher_guard.as_ref() {
while let Ok(change) = watcher.receiver.try_recv() {
let rel_path = change.path.strip_prefix(&sync_dir)
.unwrap_or(&change.path)
.to_string_lossy()
.to_string();
let msg = match change.kind {
ChangeKind::Created => format!("Neu: {}", rel_path),
ChangeKind::Modified => format!("Geaendert: {}", rel_path),
ChangeKind::Deleted => format!("Geloescht: {}", rel_path),
};
let _ = app_watcher.emit("file-change", msg);
}
}
}
});
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_notification::init())
.manage(AppState {
api: Mutex::new(None),
sync_engine: Mutex::new(None),
sync_dir: Mutex::new(None),
username: Mutex::new(None),
watcher: Mutex::new(None),
sync_running: Arc::new(Mutex::new(false)),
locked_files: Mutex::new(Vec::new()),
})
.setup(|app| {
// System Tray
let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?;
let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &quit])?;
TrayIconBuilder::new()
.tooltip("Mini-Cloud Sync")
.menu(&menu)
.on_menu_event(|app, event| {
match event.id.as_ref() {
"quit" => std::process::exit(0),
"show" => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
}
_ => {}
}
})
.build(app)?;
Ok(())
})
.invoke_handler(tauri::generate_handler![
login,
@@ -108,6 +257,8 @@ pub fn run() {
delta_sync,
get_status,
get_file_tree,
lock_file_cmd,
unlock_file_cmd,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");