diff --git a/clients/desktop/src-tauri/src/cloud_files/mod.rs b/clients/desktop/src-tauri/src/cloud_files/mod.rs index 9c671c6..52e8a5e 100644 --- a/clients/desktop/src-tauri/src/cloud_files/mod.rs +++ b/clients/desktop/src-tauri/src/cloud_files/mod.rs @@ -45,6 +45,8 @@ pub enum SyncState { pub mod windows; #[cfg(all(target_os = "linux", feature = "linux_fuse"))] pub mod linux; +pub mod sync_loop; +pub mod watcher; /// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform /// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem. diff --git a/clients/desktop/src-tauri/src/cloud_files/sync_loop.rs b/clients/desktop/src-tauri/src/cloud_files/sync_loop.rs new file mode 100644 index 0000000..731171e --- /dev/null +++ b/clients/desktop/src-tauri/src/cloud_files/sync_loop.rs @@ -0,0 +1,199 @@ +//! Hintergrund-Synchronisation fuer den Cloud-Files-Modus. +//! +//! Zwei Aufgaben: +//! 1. Lokale Aenderungen im Mount-Point beobachten (notify-Watcher) und +//! geaenderte Dateien hochladen. Neu angelegte Dateien werden als +//! neue Datei beim Server registriert und als Platzhalter markiert. +//! 2. Serverseitige Aenderungen pollen (/api/sync/changes?since=...) und +//! fehlende Platzhalter erzeugen bzw. entfernte loeschen. +//! +//! Der Loop laeuft in einem dedizierten Tokio-Task; ein gespeicherter +//! `Stop`-Channel beendet ihn sauber beim Deaktivieren. + +use super::RemoteEntry; +use serde::Deserialize; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +#[derive(Clone)] +pub struct SyncLoopConfig { + pub server_url: String, + pub access_token: String, + pub mount_point: PathBuf, + pub poll_interval_secs: u64, +} + +pub struct SyncLoopHandle { + pub stop_flag: Arc, + pub tx: mpsc::UnboundedSender, +} + +pub enum LoopMessage { + LocalChange(PathBuf), + Shutdown, +} + +/// Starte den Sync-Loop. Gibt einen Handle zurueck, mit dem man ihn +/// stoppen oder externe Events (z.B. vom Watcher) einspeisen kann. +pub fn start(cfg: SyncLoopConfig) -> SyncLoopHandle { + let stop_flag = Arc::new(AtomicBool::new(false)); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let stop = stop_flag.clone(); + let cfg_task = cfg.clone(); + tokio::spawn(async move { + let client = reqwest::Client::new(); + let mut since: Option = None; + let mut interval = tokio::time::interval(Duration::from_secs(cfg_task.poll_interval_secs)); + loop { + if stop.load(Ordering::Relaxed) { + break; + } + tokio::select! { + _ = interval.tick() => { + if let Err(e) = poll_server_changes(&client, &cfg_task, &mut since).await { + eprintln!("[cloud_files] poll error: {e}"); + } + } + Some(msg) = rx.recv() => { + match msg { + LoopMessage::Shutdown => break, + LoopMessage::LocalChange(path) => { + if let Err(e) = upload_local_change(&client, &cfg_task, &path).await { + eprintln!("[cloud_files] upload error: {e}"); + } + } + } + } + } + } + }); + SyncLoopHandle { stop_flag, tx } +} + +#[derive(Debug, Deserialize)] +struct ChangesResponse { + #[serde(default)] + created: Vec, + #[serde(default)] + updated: Vec, + #[serde(default)] + deleted: Vec, + timestamp: Option, +} + +async fn poll_server_changes( + client: &reqwest::Client, + cfg: &SyncLoopConfig, + since: &mut Option, +) -> Result<(), String> { + let base = cfg.server_url.trim_end_matches('/'); + let mut url = format!("{}/api/sync/changes", base); + if let Some(s) = since.as_deref() { + url.push_str(&format!("?since={}", urlencode(s))); + } + let resp = client + .get(&url) + .bearer_auth(&cfg.access_token) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let body: ChangesResponse = resp.json().await.map_err(|e| e.to_string())?; + + // Created + Updated: jeweils passendes Verzeichnis sichern, dann + // Platzhalter (neu) anlegen. Bei Updates muss der alte Platzhalter + // erst geloescht werden - Windows erlaubt kein "replace in place". + for e in body.created.iter().chain(body.updated.iter()) { + let rel = build_relative_path(e); + let full = cfg.mount_point.join(&rel); + if e.is_folder { + let _ = std::fs::create_dir_all(&full); + continue; + } + let parent = full.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| cfg.mount_point.clone()); + let _ = std::fs::create_dir_all(&parent); + let _ = std::fs::remove_file(&full); // ignoriert falls nicht da + #[cfg(windows)] + { + let identity = e.id.to_string(); + if let Err(err) = super::windows::create_placeholder_at( + &parent, + &e.name, + e.size, + &e.modified_at, + identity.as_bytes(), + ) { + eprintln!("[cloud_files] placeholder {}: {}", e.name, err); + } + } + } + + // Deleted: nur per ID vom Server - wir kennen den Pfad nicht mehr. + // MVP: ignorieren. In Version 2 fuehren wir ein lokales Mapping. + let _ = body.deleted; + + if let Some(ts) = body.timestamp { + *since = Some(ts); + } + Ok(()) +} + +async fn upload_local_change( + client: &reqwest::Client, + cfg: &SyncLoopConfig, + path: &PathBuf, +) -> Result<(), String> { + if !path.is_file() { + return Ok(()); + } + // Relativer Pfad im Mount = Ziel-Pfad auf Server + let rel = path + .strip_prefix(&cfg.mount_point) + .map_err(|_| "path outside mount".to_string())? + .to_string_lossy() + .replace('\\', "/"); + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let base = cfg.server_url.trim_end_matches('/'); + let url = format!("{}/api/files/upload", base); + let file_name = path + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or("unnamed") + .to_string(); + let form = reqwest::multipart::Form::new() + .text("path", rel.clone()) + .part( + "file", + reqwest::multipart::Part::bytes(bytes).file_name(file_name), + ); + let resp = client + .post(&url) + .bearer_auth(&cfg.access_token) + .multipart(form) + .send() + .await + .map_err(|e| e.to_string())?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + Ok(()) +} + +fn build_relative_path(e: &RemoteEntry) -> PathBuf { + // Achtung: RemoteEntry hat nur parent_id, nicht den kompletten Pfad. + // Fuer diesen einfachen Fall nehmen wir nur den Namen. Bei geschachtelten + // Ordnern muesste man die Hierarchie ueber /api/sync/tree vor-laden - + // das passiert einmal beim Aktivieren; Delta-Updates kommen meistens + // flach (oder in einer gemeinsamen Wurzel). + PathBuf::from(&e.name) +} + +fn urlencode(s: &str) -> String { + // Sehr minimalistisch: wir ersetzen nur problematische Zeichen. + s.replace(' ', "%20").replace(':', "%3A").replace('+', "%2B") +} diff --git a/clients/desktop/src-tauri/src/cloud_files/watcher.rs b/clients/desktop/src-tauri/src/cloud_files/watcher.rs new file mode 100644 index 0000000..e8969bd --- /dev/null +++ b/clients/desktop/src-tauri/src/cloud_files/watcher.rs @@ -0,0 +1,43 @@ +//! Leichtgewichtiger Callback-basierter FS-Watcher fuer den Cloud-Files-Modus. +//! +//! Anders als `sync::watcher::FileWatcher` gibt dieser hier einen Closure +//! direkt an notify weiter, sodass wir kein Channel-Pumpen brauchen. + +use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, Config}; +use std::path::{Path, PathBuf}; + +pub struct CallbackWatcher { + _watcher: RecommendedWatcher, +} + +impl CallbackWatcher { + pub fn new(watch_dir: &Path, mut on_change: F) -> Result + where + F: FnMut(PathBuf, EventKind) + Send + 'static, + { + let mut watcher = RecommendedWatcher::new( + move |res: Result| { + if let Ok(ev) = res { + for path in ev.paths { + 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; + } + on_change(path, ev.kind.clone()); + } + } + }, + Config::default(), + ) + .map_err(|e| format!("Watcher-Fehler: {e}"))?; + + watcher + .watch(watch_dir, RecursiveMode::Recursive) + .map_err(|e| format!("Watch-Fehler: {e}"))?; + + Ok(Self { _watcher: watcher }) + } +} diff --git a/clients/desktop/src-tauri/src/cloud_files/windows.rs b/clients/desktop/src-tauri/src/cloud_files/windows.rs index 9b2a014..91b5e42 100644 --- a/clients/desktop/src-tauri/src/cloud_files/windows.rs +++ b/clients/desktop/src-tauri/src/cloud_files/windows.rs @@ -100,14 +100,30 @@ pub fn register_sync_root( // struct einbauen koennen. windows-rs verlangt hier nichts weiter. let _ = display_wide; + // Erst versuchen ohne UPDATE-Flag (frische Registrierung). Bei "schon + // registriert"-Fehler nochmal mit UPDATE-Flag. So laeuft es beim ersten + // Aktivieren UND bei Folgestarts. unsafe { - CF::CfRegisterSyncRoot( + if let Err(e) = CF::CfRegisterSyncRoot( PCWSTR(path_wide.as_ptr()), &info, &policies, - CF::CF_REGISTER_FLAG_UPDATE, - ) - .map_err(|e| format!("CfRegisterSyncRoot: {e}"))?; + CF::CF_REGISTER_FLAG_NONE, + ) { + let already = e.code().0 == 0x80070091u32 as i32 // DIR_NOT_EMPTY + || e.code().0 == 0x800710D1u32 as i32; // Already registered + if already { + CF::CfRegisterSyncRoot( + PCWSTR(path_wide.as_ptr()), + &info, + &policies, + CF::CF_REGISTER_FLAG_UPDATE, + ) + .map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?; + } else { + return Err(format!("CfRegisterSyncRoot: {e}")); + } + } } connect_callbacks(mount_point)?; @@ -322,6 +338,16 @@ pub fn populate_placeholders( Ok(()) } +pub fn create_placeholder_at( + parent_dir: &Path, + name: &str, + size: i64, + modified_iso: &str, + file_identity: &[u8], +) -> Result<(), String> { + create_placeholder(parent_dir, name, size, modified_iso, file_identity) +} + fn create_placeholder( parent_dir: &Path, name: &str, diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index b4c05f3..9bda797 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -27,6 +27,8 @@ struct AppState { sync_paths: Mutex>, journal: Arc, background_started: AtomicBool, + cloud_files_loop: Mutex>, + cloud_files_watcher: Mutex>, } // --- Auth --- @@ -923,11 +925,44 @@ async fn cloud_files_enable( // Baum vom Server holen und Platzhalter anlegen let entries = fetch_remote_entries(&server, &token).await?; cloud_files::populate_placeholders(&mp, &entries)?; + + // Hintergrund-Loop starten: poll Changes + upload lokaler Aenderungen + let cfg = cloud_files::sync_loop::SyncLoopConfig { + server_url: server.clone(), + access_token: token.clone(), + mount_point: mp.clone(), + poll_interval_secs: 30, + }; + let handle = cloud_files::sync_loop::start(cfg); + + // Filesystem-Watcher mit Callback; leitet geaenderte Dateien + // direkt an den Sync-Loop weiter. + let tx = handle.tx.clone(); + let watcher = cloud_files::watcher::CallbackWatcher::new(&mp, move |path, kind| { + use notify::EventKind; + let relevant = matches!(kind, EventKind::Create(_) | EventKind::Modify(_)); + if relevant { + let _ = tx.send(cloud_files::sync_loop::LoopMessage::LocalChange(path)); + } + }) + .map_err(|e| format!("watcher: {e}"))?; + + *state.cloud_files_loop.lock().unwrap() = Some(handle); + *state.cloud_files_watcher.lock().unwrap() = Some(watcher); Ok(()) } #[tauri::command] -async fn cloud_files_disable(mount_point: String) -> Result<(), String> { +async fn cloud_files_disable( + state: State<'_, AppState>, + mount_point: String, +) -> Result<(), String> { + // Loop und Watcher stoppen + if let Some(handle) = state.cloud_files_loop.lock().unwrap().take() { + handle.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed); + let _ = handle.tx.send(cloud_files::sync_loop::LoopMessage::Shutdown); + } + state.cloud_files_watcher.lock().unwrap().take(); cloud_files::unregister_sync_root(&PathBuf::from(mount_point)) } @@ -1025,6 +1060,8 @@ pub fn run() { sync_paths: Mutex::new(Vec::new()), journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")), background_started: AtomicBool::new(false), + cloud_files_loop: Mutex::new(None), + cloud_files_watcher: Mutex::new(None), }) .on_window_event(|window, event| { // Close button = minimize to tray instead of quit diff --git a/clients/desktop/src/App.vue b/clients/desktop/src/App.vue index e175bbf..b09de7e 100644 --- a/clients/desktop/src/App.vue +++ b/clients/desktop/src/App.vue @@ -31,6 +31,54 @@ const newPathLocal = ref(""); const newPathServerFolder = ref(""); const newPathServerId = ref(null); const newPathMode = ref("virtual"); + +// Cloud-Files (Windows cfapi / Linux FUSE) +const cloudFilesSupported = ref(false); +const cloudFilesActive = ref(false); +const cloudFilesBusy = ref(false); +const cloudFilesMountPoint = ref(""); +const cloudFilesError = ref(""); + +async function checkCloudFilesSupport() { + try { cloudFilesSupported.value = await invoke("cloud_files_supported"); } + catch { cloudFilesSupported.value = false; } +} + +async function browseCfMount() { + try { + const selected = await dialogOpen({ directory: true, multiple: false, + title: "Cloud-Files-Ordner waehlen" }); + if (selected) cloudFilesMountPoint.value = selected; + } catch { /* cancelled */ } +} + +async function enableCloudFiles() { + cloudFilesError.value = ""; + cloudFilesBusy.value = true; + try { + await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value }); + cloudFilesActive.value = true; + syncLog.value = [`[${ts()}] Cloud-Files aktiviert: ${cloudFilesMountPoint.value}`, ...syncLog.value].slice(0, 200); + } catch (err) { + cloudFilesError.value = String(err); + } finally { + cloudFilesBusy.value = false; + } +} + +async function disableCloudFiles() { + cloudFilesError.value = ""; + cloudFilesBusy.value = true; + try { + await invoke("cloud_files_disable", { mountPoint: cloudFilesMountPoint.value }); + cloudFilesActive.value = false; + syncLog.value = [`[${ts()}] Cloud-Files deaktiviert`, ...syncLog.value].slice(0, 200); + } catch (err) { + cloudFilesError.value = String(err); + } finally { + cloudFilesBusy.value = false; + } +} const serverFolders = ref([]); // Local file browser @@ -289,6 +337,7 @@ function formatSize(b) { } onMounted(async () => { + checkCloudFilesSupport(); // Try auto-login with saved credentials try { const saved = await invoke("load_saved_config"); @@ -387,6 +436,24 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
+ +
+
+

Cloud-Files (OneDrive-Style)

+ ☁ aktiv +
+

Dateien erscheinen als Platzhalter im Explorer mit Wolken-Icon und werden erst bei Zugriff geladen. Rechtsklick im Explorer → "Immer offline halten" oder "Speicher freigeben".

+
+ + + + +
+
{{ cloudFilesError }}
+
+
@@ -604,6 +671,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;f .sp-actions{display:flex;align-items:center;gap:.375rem;flex-shrink:0} .sp-mode{font-size:.75rem;padding:.2rem .4rem;border-radius:4px;cursor:pointer;background:#f0f0f0} .sp-mode.Full{background:#e3f2fd;color:#1565c0}.sp-mode.Virtual{background:#f3e5f5;color:#7b1fa2} +.cf-row{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap} +.cf-row input{flex:1;min-width:300px} .file-tree{max-height:250px;overflow-y:auto} .tree-item{display:flex;align-items:center;gap:.5rem;padding:.3rem 0;border-bottom:1px solid #f5f5f5;font-size:.85rem} .tree-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}