Files
minmal-file-cloud-email-pim…/clients/desktop/src-tauri/src/lib.rs
T
Stefan Hacker 607d18a7e2 feat: Lokaler Datei-Browser mit Offline-Markierung + Kontextmenue
Datei-Browser im Client:
- Zeigt lokalen Sync-Ordner mit allen Dateien an
- Ordner navigierbar mit Breadcrumb
- Status pro Datei: ☁ Cloud (Platzhalter) / 📄 Offline (echte Datei)
- Badges: blaues "Cloud" oder gruenes "Offline"
- Cloud-Dateien zeigen Originalgroesse aus .cloud-Metadaten
- Aktualisiert sich automatisch nach jedem Sync

Rechtsklick-Kontextmenue:
- .cloud Datei: "Oeffnen (herunterladen)" + "Offline verfuegbar machen"
- Echte Datei: "Nicht mehr offline (Platzhalter)"
- Doppelklick auf Ordner = navigieren
- Doppelklick auf .cloud = herunterladen + oeffnen

Rust-Backend:
- browse_sync_folder: Listet lokale Dateien mit Status auf
  (is_cloud, is_offline, cloud_size aus JSON-Metadaten)
- Sortierung: Ordner zuerst, dann alphabetisch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 01:02:21 +02:00

566 lines
19 KiB
Rust

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::engine::{SyncEngine, SyncMode, SyncPath};
use sync::watcher::{FileWatcher, ChangeKind};
struct AppState {
api: Mutex<Option<MiniCloudApi>>,
sync_engine: Mutex<Option<SyncEngine>>,
username: Mutex<Option<String>>,
watchers: Mutex<Vec<FileWatcher>>,
sync_running: Arc<Mutex<bool>>,
locked_files: Mutex<Vec<i64>>,
sync_paths: Mutex<Vec<SyncPath>>,
}
// --- Auth ---
#[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,
}))
}
// --- Sync Paths ---
#[tauri::command]
fn add_sync_path(
state: State<'_, AppState>,
server_path: String,
server_folder_id: Option<i64>,
local_dir: String,
mode: String, // "virtual" or "full"
) -> Result<serde_json::Value, String> {
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());
Ok(serde_json::to_value(sp).map_err(|e| e.to_string())?)
}
#[tauri::command]
fn remove_sync_path(state: State<'_, AppState>, id: String) -> Result<String, String> {
state.sync_paths.lock().unwrap().retain(|p| p.id != id);
Ok("Sync-Pfad entfernt".to_string())
}
#[tauri::command]
fn get_sync_paths(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
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<String, String> {
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<Vec<String>, 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<Vec<String>, 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<String, String> {
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<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())?)
}
#[tauri::command]
async fn get_status(state: State<'_, AppState>) -> Result<serde_json::Value, String> {
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<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())
}
// --- 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<i64>, // original size from .cloud metadata
}
#[tauri::command]
fn browse_sync_folder(state: State<'_, AppState>, sub_path: Option<String>) -> Result<Vec<LocalFileEntry>, 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::<serde_json::Value>(&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<String, String> {
// 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<String, String> {
// 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<Mutex<bool>>,
api: MiniCloudApi,
paths: Vec<SyncPath>,
) {
// 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::<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: 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::<AppState>();
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 ---
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
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 bundled icon for tray
let icon = app.default_window_icon().cloned()
.unwrap_or_else(|| tauri::image::Image::from_bytes(include_bytes!("../icons/32x32.png")).unwrap());
// Handle .cloud file opened via file association (double-click)
let args: Vec<String> = 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);
});
}
}
TrayIconBuilder::new()
.icon(icon)
.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![
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");
}