mod sync; use std::path::PathBuf; 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::config::AppConfig; use sync::engine::{SyncEngine, SyncMode, SyncPath}; use sync::watcher::{FileWatcher, ChangeKind}; struct AppState { api: Mutex>, sync_engine: Mutex>, username: Mutex>, watchers: Mutex>, sync_running: Arc>, locked_files: Mutex>, sync_paths: Mutex>, } // --- Auth --- #[tauri::command] async fn login( state: State<'_, AppState>, server_url: String, username: String, password: String, ) -> Result { 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.clone()); // Save credentials to config file let mut config = AppConfig::load(); config.server_url = server_url; config.username = username; config.save_password(&password); let _ = config.save(); Ok(serde_json::json!({ "username": result.user.username, "role": result.user.role, })) } // --- Saved Config --- #[tauri::command] fn load_saved_config() -> Result { let config = AppConfig::load(); let has_credentials = !config.server_url.is_empty() && !config.username.is_empty() && config.get_password().is_some(); Ok(serde_json::json!({ "server_url": config.server_url, "username": config.username, "sync_paths": config.sync_paths, "has_credentials": has_credentials, "has_config": !config.server_url.is_empty(), })) } #[tauri::command] async fn auto_login(state: State<'_, AppState>) -> Result { let config = AppConfig::load(); if config.server_url.is_empty() || config.username.is_empty() { return Err("Keine gespeicherten Zugangsdaten".to_string()); } let password = config.get_password() .ok_or("Passwort nicht gespeichert")?; let mut api = MiniCloudApi::new(&config.server_url); let result = api.login(&config.username, &password).await?; *state.api.lock().unwrap() = Some(api); *state.username.lock().unwrap() = Some(config.username.clone()); *state.sync_paths.lock().unwrap() = config.sync_paths; Ok(serde_json::json!({ "username": result.user.username, "role": result.user.role, })) } // --- Sync Paths --- #[tauri::command] fn add_sync_path( state: State<'_, AppState>, server_path: String, server_folder_id: Option, local_dir: String, mode: String, // "virtual" or "full" ) -> Result { let local = PathBuf::from(&local_dir); std::fs::create_dir_all(&local).map_err(|e| format!("Ordner erstellen: {}", e))?; let sync_mode = if mode == "full" { SyncMode::Full } else { SyncMode::Virtual }; let id = format!("{}_{}", server_folder_id.unwrap_or(0), local_dir.replace(['/', '\\'], "_")); let sp = SyncPath { id: id.clone(), server_path, server_folder_id, local_dir, mode: sync_mode, enabled: true, }; state.sync_paths.lock().unwrap().push(sp.clone()); // Save to config let mut config = AppConfig::load(); config.sync_paths = state.sync_paths.lock().unwrap().clone(); let _ = config.save(); Ok(serde_json::to_value(sp).map_err(|e| e.to_string())?) } #[tauri::command] fn remove_sync_path(state: State<'_, AppState>, id: String) -> Result { state.sync_paths.lock().unwrap().retain(|p| p.id != id); let mut config = AppConfig::load(); config.sync_paths = state.sync_paths.lock().unwrap().clone(); let _ = config.save(); Ok("Sync-Pfad entfernt".to_string()) } #[tauri::command] fn get_sync_paths(state: State<'_, AppState>) -> Result { let paths = state.sync_paths.lock().unwrap().clone(); Ok(serde_json::to_value(paths).map_err(|e| e.to_string())?) } #[tauri::command] fn toggle_sync_mode(state: State<'_, AppState>, id: String) -> Result { let mut paths = state.sync_paths.lock().unwrap(); if let Some(p) = paths.iter_mut().find(|p| p.id == id) { p.mode = match p.mode { SyncMode::Virtual => SyncMode::Full, SyncMode::Full => SyncMode::Virtual, }; Ok(format!("Modus: {:?}", p.mode)) } else { Err("Pfad nicht gefunden".to_string()) } } // --- Sync --- #[tauri::command] async fn start_sync(app: AppHandle, state: State<'_, AppState>) -> Result, String> { let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; let paths = state.sync_paths.lock().unwrap().clone(); if paths.is_empty() { return Err("Keine Sync-Pfade konfiguriert".to_string()); } let mut engine = SyncEngine::new(api.clone()); engine.sync_paths = paths.clone(); let log = engine.sync_all().await?; *state.sync_engine.lock().unwrap() = Some(engine); // Start watchers for each sync path let mut watchers = Vec::new(); for sp in &paths { if let Ok(w) = FileWatcher::new(&PathBuf::from(&sp.local_dir)) { watchers.push(w); } } *state.watchers.lock().unwrap() = watchers; // Start background threads start_background_sync(app, state.sync_running.clone(), api, paths); Ok(log) } #[tauri::command] async fn run_sync_now(state: State<'_, AppState>) -> Result, String> { let mut engine = { let mut guard = state.sync_engine.lock().unwrap(); guard.take().ok_or("Sync nicht gestartet")? }; let result = engine.sync_all().await; *state.sync_engine.lock().unwrap() = Some(engine); result } // --- File Operations --- #[tauri::command] async fn open_cloud_file(state: State<'_, AppState>, cloud_path: String) -> Result { let engine = { let guard = state.sync_engine.lock().unwrap(); guard.as_ref().ok_or("Sync nicht gestartet")?.api.clone() }; let path = PathBuf::from(&cloud_path); let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let placeholder: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?; let file_id = placeholder.get("id").and_then(|v| v.as_i64()).ok_or("Keine ID")?; let file_name = placeholder.get("name").and_then(|v| v.as_str()).unwrap_or("file"); let real_path = path.parent().unwrap().join(file_name); // Download engine.download_file(file_id, &real_path).await?; // Remove placeholder std::fs::remove_file(&path).ok(); // Lock let _ = engine.lock_file(file_id, "Desktop Sync Client").await; state.locked_files.lock().unwrap().push(file_id); // Open with default application let _ = open::that(&real_path); Ok(real_path.to_string_lossy().to_string()) } #[tauri::command] async fn get_file_tree(state: State<'_, AppState>) -> Result { 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())?) } #[tauri::command] async fn get_status(state: State<'_, AppState>) -> Result { let logged_in = state.api.lock().unwrap().is_some(); let username = state.username.lock().unwrap().clone(); let syncing = *state.sync_running.lock().unwrap(); let paths = state.sync_paths.lock().unwrap().len(); let locked = state.locked_files.lock().unwrap().len(); Ok(serde_json::json!({ "logged_in": logged_in, "username": username, "syncing": syncing, "sync_paths": paths, "locked_files": locked, })) } #[tauri::command] async fn lock_file_cmd(state: State<'_, AppState>, file_id: i64) -> Result { 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 { 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()) } // --- Local File Browser --- #[derive(serde::Serialize)] struct LocalFileEntry { name: String, path: String, is_folder: bool, is_cloud: bool, // .cloud placeholder is_offline: bool, // real file (offline available) size: i64, cloud_size: Option, // original size from .cloud metadata } #[tauri::command] fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option) -> Result, String> { let paths = state.sync_paths.lock().unwrap(); if paths.is_empty() { return Err("Keine Sync-Pfade konfiguriert".to_string()); } // If sub_path given, use it directly; otherwise use first sync path let base_dir = if let Some(ref sp) = sub_path { PathBuf::from(sp) } else { PathBuf::from(&paths[0].local_dir) }; if !base_dir.exists() { return Ok(Vec::new()); } let mut entries = Vec::new(); let dir = std::fs::read_dir(&base_dir).map_err(|e| e.to_string())?; for entry in dir.flatten() { let name = entry.file_name().to_string_lossy().to_string(); let path = entry.path(); // Skip hidden files if name.starts_with('.') || name.starts_with('~') { continue; } let is_folder = path.is_dir(); let is_cloud = name.ends_with(".cloud"); let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0); // For .cloud files, read the original size from JSON let mut cloud_size = None; let mut display_name = name.clone(); if is_cloud { display_name = name.trim_end_matches(".cloud").to_string(); if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(json) = serde_json::from_str::(&content) { cloud_size = json.get("size").and_then(|v| v.as_i64()); } } } // A real (non-.cloud) file = offline available let is_offline = !is_cloud && !is_folder; entries.push(LocalFileEntry { name: display_name, path: path.to_string_lossy().to_string(), is_folder, is_cloud, is_offline, size, cloud_size, }); } // Sort: folders first, then by name entries.sort_by(|a, b| { b.is_folder.cmp(&a.is_folder).then(a.name.to_lowercase().cmp(&b.name.to_lowercase())) }); Ok(entries) } // --- Offline-Markierung --- #[tauri::command] async fn mark_offline(state: State<'_, AppState>, cloud_path: String) -> Result { // Read .cloud placeholder, download real file, keep it local let path = PathBuf::from(&cloud_path); let content = std::fs::read_to_string(&path).map_err(|e| e.to_string())?; let placeholder: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?; let file_id = placeholder.get("id").and_then(|v| v.as_i64()).ok_or("Keine ID")?; let file_name = placeholder.get("name").and_then(|v| v.as_str()).unwrap_or("file"); let api = state.api.lock().unwrap().clone().ok_or("Nicht eingeloggt")?; let real_path = path.parent().unwrap().join(file_name); // Download api.download_file(file_id, &real_path).await?; // Remove .cloud placeholder (real file stays permanently) std::fs::remove_file(&path).ok(); Ok(format!("{} ist jetzt offline verfuegbar", file_name)) } #[tauri::command] fn unmark_offline(cloud_path: String) -> Result { // Convert real file back to .cloud placeholder let path = PathBuf::from(&cloud_path); if !path.exists() { return Err("Datei nicht gefunden".to_string()); } let name = path.file_name().unwrap().to_string_lossy().to_string(); let size = std::fs::metadata(&path).map(|m| m.len() as i64).unwrap_or(0); let checksum = sync::engine::compute_file_hash(&path); let placeholder = serde_json::json!({ "id": 0, // will be updated on next sync "name": name, "size": size, "checksum": checksum, "updated_at": chrono::Utc::now().to_rfc3339(), "server_path": "", }); let cloud_path = path.parent().unwrap().join(format!("{}.cloud", name)); std::fs::write(&cloud_path, serde_json::to_string_pretty(&placeholder).unwrap()).ok(); std::fs::remove_file(&path).ok(); Ok(format!("{} ist nicht mehr offline", name)) } // --- Background Threads --- fn start_background_sync( app: AppHandle, sync_running: Arc>, api: MiniCloudApi, paths: Vec, ) { // Shared flag: watcher sets true when changes detected, sync thread checks it let watcher_triggered = Arc::new(Mutex::new(false)); // Main sync thread: syncs on watcher trigger OR every 60s as fallback let app_sync = app.clone(); let api_sync = api.clone(); let paths_sync = paths.clone(); let trigger_sync = watcher_triggered.clone(); std::thread::spawn(move || { let rt = tokio::runtime::Runtime::new().unwrap(); let mut engine = SyncEngine::new(api_sync); engine.sync_paths = paths_sync; let mut idle_counter = 0u32; loop { // Check every 2 seconds if watcher triggered std::thread::sleep(Duration::from_secs(2)); idle_counter += 2; let should_sync = { let mut triggered = trigger_sync.lock().unwrap(); if *triggered { *triggered = false; true } else { // Fallback: sync every 60 seconds even without changes idle_counter >= 60 } }; if !should_sync { continue; } idle_counter = 0; // Run sync *sync_running.lock().unwrap() = true; let _ = app_sync.emit("sync-status", "syncing"); match rt.block_on(engine.sync_all()) { Ok(log) => { if !log.is_empty() { let _ = app_sync.emit("sync-log", log); } } Err(e) => { let _ = app_sync.emit("sync-error", e); } } *sync_running.lock().unwrap() = false; let _ = app_sync.emit("sync-status", "synced"); } }); // Heartbeat every 60 seconds 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::(); let locked = state.locked_files.lock().unwrap().clone(); for file_id in locked { let _ = rt.block_on(api_hb.heartbeat(file_id)); } } }); // File watcher: detects changes and triggers immediate sync let app_w = app.clone(); let trigger_w = watcher_triggered.clone(); std::thread::spawn(move || { // Debounce: wait 3 seconds after last change before triggering sync let mut last_change = std::time::Instant::now() - Duration::from_secs(100); let mut pending = false; loop { std::thread::sleep(Duration::from_millis(500)); let state = app_w.state::(); let watchers = state.watchers.lock().unwrap(); let mut had_changes = false; for watcher in watchers.iter() { while let Ok(change) = watcher.receiver.try_recv() { let name = change.path.file_name() .and_then(|n| n.to_str()).unwrap_or("?"); // Skip .cloud files from triggering sync if name.ends_with(".cloud") { continue; } let msg = match change.kind { ChangeKind::Created => format!("Neu: {}", name), ChangeKind::Modified => format!("Geaendert: {}", name), ChangeKind::Deleted => format!("Geloescht: {}", name), }; let _ = app_w.emit("file-change", msg); had_changes = true; last_change = std::time::Instant::now(); pending = true; } } // Debounce: trigger sync 3 seconds after last change if pending && last_change.elapsed() >= Duration::from_secs(3) { *trigger_w.lock().unwrap() = true; pending = false; let _ = app_w.emit("file-change", "→ Sync ausgeloest".to_string()); } } }); } // --- App Setup --- /// Check if another instance is running. If yes, pass the .cloud file to it and exit. /// Single instance per user. On terminal servers each user gets their own /// instance because the lock file is in %APPDATA% (user-specific). fn handle_single_instance() { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("MiniCloud Sync"); std::fs::create_dir_all(&config_dir).ok(); let lock_file = config_dir.join("instance.lock"); let args: Vec = std::env::args().collect(); // Check if another instance of THIS USER is running let other_running = if lock_file.exists() { if let Ok(pid_str) = std::fs::read_to_string(&lock_file) { let pid = pid_str.trim().parse::().unwrap_or(0); if pid > 0 && pid != std::process::id() { // Check if that process is still alive #[cfg(target_os = "windows")] { std::process::Command::new("tasklist") .args(["/FI", &format!("PID eq {}", pid), "/NH"]) .output() .map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string())) .unwrap_or(false) } #[cfg(not(target_os = "windows"))] { std::path::Path::new(&format!("/proc/{}", pid)).exists() } } else { false } } else { false } } else { false }; // If .cloud file argument and another instance runs -> delegate and exit if args.len() > 1 && args[1].ends_with(".cloud") && other_running { let request_file = config_dir.join("open_request.txt"); std::fs::write(&request_file, &args[1]).ok(); eprintln!("[SingleInstance] Delegated {} to running instance (PID in lock)", args[1]); std::process::exit(0); } // If no .cloud argument but another instance runs -> just bring it to front and exit if other_running && args.len() <= 1 { eprintln!("[SingleInstance] Already running, exiting"); std::process::exit(0); } // We are the main instance - write our PID std::fs::write(&lock_file, std::process::id().to_string()).ok(); } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { handle_single_instance(); tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_dialog::init()) .manage(AppState { api: Mutex::new(None), sync_engine: Mutex::new(None), username: Mutex::new(None), watchers: Mutex::new(Vec::new()), sync_running: Arc::new(Mutex::new(false)), locked_files: Mutex::new(Vec::new()), sync_paths: Mutex::new(Vec::new()), }) .on_window_event(|window, event| { // Close button = minimize to tray instead of quit if let tauri::WindowEvent::CloseRequested { api, .. } = event { api.prevent_close(); let _ = window.hide(); } }) .setup(|app| { let quit = MenuItem::with_id(app, "quit", "Beenden", true, None::<&str>)?; let show = MenuItem::with_id(app, "show", "Oeffnen", true, None::<&str>)?; let sync_now = MenuItem::with_id(app, "sync", "Jetzt synchronisieren", true, None::<&str>)?; let menu = Menu::with_items(app, &[&show, &sync_now, &quit])?; // Use window icon for tray (set via tauri.conf.json) let icon = app.default_window_icon().cloned(); // Watch for open requests from other instances let app_req = app.handle().clone(); std::thread::spawn(move || { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("MiniCloud Sync"); let request_file = config_dir.join("open_request.txt"); loop { std::thread::sleep(Duration::from_secs(1)); if request_file.exists() { if let Ok(path) = std::fs::read_to_string(&request_file) { let path = path.trim().to_string(); if !path.is_empty() { let _ = app_req.emit("open-cloud-file", path); // Show the window if let Some(w) = app_req.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); } } } std::fs::remove_file(&request_file).ok(); } } }); // Handle .cloud file opened via file association (double-click) let args: Vec = std::env::args().collect(); if args.len() > 1 { let file_arg = &args[1]; if file_arg.ends_with(".cloud") { let cloud_path = file_arg.to_string(); let app_handle = app.handle().clone(); // Open the .cloud file after app is ready std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(2)); let _ = app_handle.emit("open-cloud-file", cloud_path); }); } } let mut tray = TrayIconBuilder::new(); if let Some(ic) = icon { tray = tray.icon(ic); } tray .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(w) = app.get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); } } "sync" => { let _ = app.emit("trigger-sync", ()); } _ => {} } }) .on_tray_icon_event(|tray, event| { // Double-click on tray icon = show window if let tauri::tray::TrayIconEvent::DoubleClick { .. } = event { if let Some(w) = tray.app_handle().get_webview_window("main") { let _ = w.show(); let _ = w.set_focus(); } } }) .build(app)?; Ok(()) }) .invoke_handler(tauri::generate_handler![ load_saved_config, auto_login, login, add_sync_path, remove_sync_path, get_sync_paths, toggle_sync_mode, start_sync, run_sync_now, open_cloud_file, get_file_tree, get_status, lock_file_cmd, unlock_file_cmd, browse_sync_folder, mark_offline, unmark_offline, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }