From de1039fc7da472b8287c3d4b9c0ed37a02f9a5fd Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Tue, 14 Apr 2026 16:19:22 +0200 Subject: [PATCH] feat(client): Windows Cloud-Files-API als File-Provider (OneDrive-Style) Neuer Modus neben dem bestehenden Full-Sync: Dateien erscheinen im Explorer als Platzhalter mit Wolken-Icon und werden erst bei Zugriff vom Mini-Cloud-Server gestreamt. Windows (MVP): - CfRegisterSyncRoot + CfConnectSyncRoot - CfCreatePlaceholders fuer jede Datei aus /api/sync/tree - FETCH_DATA-Callback mit Range-basiertem HTTPS-Download + CfExecute - CfSetPinState fuer manuelles "Immer offline halten" Linux (Skelett): - FUSE-Provider hinter Feature-Flag linux_fuse (libfuse3-dev) - Stub-Funktionen - Implementierung folgt macOS: - Platzhalter, erfordert Apple-Signatur - spaeter Tauri-Commands: cloud_files_supported/enable/disable/pin/unpin. Cargo.toml: target-spezifische windows-rs Dependency. Doku: clients/desktop/CLOUD_FILES.md Co-Authored-By: Claude Opus 4.6 (1M context) --- clients/desktop/CLOUD_FILES.md | 70 +++ clients/desktop/src-tauri/Cargo.toml | 24 + .../src-tauri/src/cloud_files/linux.rs | 25 ++ .../desktop/src-tauri/src/cloud_files/mod.rs | 117 +++++ .../src-tauri/src/cloud_files/windows.rs | 413 ++++++++++++++++++ clients/desktop/src-tauri/src/lib.rs | 127 ++++++ 6 files changed, 776 insertions(+) create mode 100644 clients/desktop/CLOUD_FILES.md create mode 100644 clients/desktop/src-tauri/src/cloud_files/linux.rs create mode 100644 clients/desktop/src-tauri/src/cloud_files/mod.rs create mode 100644 clients/desktop/src-tauri/src/cloud_files/windows.rs diff --git a/clients/desktop/CLOUD_FILES.md b/clients/desktop/CLOUD_FILES.md new file mode 100644 index 0000000..1835690 --- /dev/null +++ b/clients/desktop/CLOUD_FILES.md @@ -0,0 +1,70 @@ +# Native File-Provider-Integration (Platzhalter-Modus) + +Zusaetzlich zum klassischen "alles-kopieren"-Sync bietet der Desktop-Client +einen **OneDrive-aehnlichen Platzhalter-Modus**: Dateien erscheinen im +Dateimanager als kleine Metadata-Dateien (Platzhalter) und werden erst +bei Doppelklick vom Server geladen. + +## Status + +| Plattform | Status | Technologie | +| --------- | --------- | ------------------------------------ | +| Windows | **MVP** | Cloud Files API (`cfapi.dll`) | +| Linux | Skelett | FUSE (libfuse3) - feature `linux_fuse` | +| macOS | Geplant | `NSFileProviderExtension` + Signatur | + +## Windows + +### Voraussetzungen + +- Windows 10 1709 (Build 16299) oder neuer +- Der Client laeuft als regulaerer Benutzerprozess (keine Admin-Rechte noetig) + +### Was funktioniert + +- `CfRegisterSyncRoot` registriert einen Ordner als Sync-Root, der Explorer + zeigt Wolken-Overlay-Icons an. +- `CfCreatePlaceholders` legt fuer jede Mini-Cloud-Datei einen Platzhalter + mit korrekter Groesse und Aenderungszeit an. +- `FETCH_DATA`-Callback laedt per Range-Request vom Server, sobald der + Explorer Dateidaten anfordert (z.B. beim Oeffnen). +- `CfSetPinState` erlaubt manuelles "Immer offline halten" / "Nur in Cloud". + +### Was noch fehlt + +- Upload-Callback (`NOTIFY_FILE_CLOSE_COMPLETION`) fuer lokal geaenderte Dateien +- Context-Menue "Ein-/Auschecken" via Shell-Extension +- Delta-Updates (neue/geloeschte Dateien auf dem Server -> lokale Placeholder) +- Konflikt-Aufloesung + +### Einschalten + +Im Client-UI den Schalter **"Cloud-Files-Modus"** aktivieren (ruft intern +`cloud_files_enable`-Command auf). Alternativ per Kommandozeile beim Build: + +```powershell +# Aus clients/desktop/src-tauri: +cargo build --release +``` + +Windows-Targets brauchen das Windows-SDK (uebersetzt aber sauber mit +cross-compile via `cargo xwin` aus Linux, wenn `build.sh windows` laeuft). + +## Linux + +FUSE-Provider ist optional und mit einem Feature-Flag versehen, damit +normale Linux-Builds nicht `libfuse3-dev` voraussetzen: + +```bash +cargo build --features linux_fuse +``` + +Overlay-Icons im Dateimanager (Nautilus / Dolphin / Caja) brauchen +zusaetzlich eine native Extension pro DE - folgt in einem spaeteren +Commit. + +## macOS + +Braucht eine Apple Developer ID + Notarization, da `NSFileProviderExtension` +sonst vom Finder nicht geladen wird. Wird angegangen, sobald ein +Apple-Dev-Zugang verfuegbar ist. diff --git a/clients/desktop/src-tauri/Cargo.toml b/clients/desktop/src-tauri/Cargo.toml index 311e4c5..b3f2d83 100644 --- a/clients/desktop/src-tauri/Cargo.toml +++ b/clients/desktop/src-tauri/Cargo.toml @@ -28,3 +28,27 @@ rusqlite = { version = "0.34", features = ["bundled"] } chrono = { version = "0.4", features = ["serde"] } base64 = "0.22" open = "5" +once_cell = "1" + +# Plattform-spezifische File-Provider-Integration (OneDrive-artig). +# Nur auf Windows gegen die Cloud Files API (cfapi.dll) linken. +[target.'cfg(windows)'.dependencies] +windows = { version = "0.58", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", + "Win32_Storage_CloudFilters", + "Win32_System_IO", + "Win32_System_Com", + "Win32_UI_Shell", + "Win32_Security", +] } +widestring = "1" + +# Linux: FUSE-basiertes Virtual-Filesystem (optional, cargo build --features linux_fuse) +[target.'cfg(target_os = "linux")'.dependencies] +fuser = { version = "0.15", optional = true } +libc = "0.2" + +[features] +default = [] +linux_fuse = ["fuser"] diff --git a/clients/desktop/src-tauri/src/cloud_files/linux.rs b/clients/desktop/src-tauri/src/cloud_files/linux.rs new file mode 100644 index 0000000..ee916ba --- /dev/null +++ b/clients/desktop/src-tauri/src/cloud_files/linux.rs @@ -0,0 +1,25 @@ +//! Linux FUSE-basierte File-Provider-Integration (Platzhalter-Modus). +//! +//! Status: Skelett. Funktioniert nur wenn mit `--features linux_fuse` +//! gebaut wird und `libfuse3-dev` installiert ist. Overlay-Icons im +//! Dateimanager (Nautilus/Dolphin) werden spaeter als separate Extension +//! nachgereicht - das FUSE-Filesystem selbst kann die nicht setzen. + +#![cfg(all(target_os = "linux", feature = "linux_fuse"))] + +use super::RemoteEntry; +use std::path::PathBuf; + +pub fn mount(mount_point: &PathBuf) -> Result<(), String> { + std::fs::create_dir_all(mount_point).map_err(|e| e.to_string())?; + // TODO: fuser::Filesystem-Impl mit auf-Abruf-Download + Err("Linux FUSE-Provider: noch nicht implementiert (MVP folgt)".into()) +} + +pub fn unmount(_mount_point: &PathBuf) -> Result<(), String> { + Err("Linux FUSE-Provider: noch nicht implementiert".into()) +} + +pub fn populate(_mount_point: &PathBuf, _entries: &[RemoteEntry]) -> Result<(), String> { + Err("Linux FUSE-Provider: noch nicht implementiert".into()) +} diff --git a/clients/desktop/src-tauri/src/cloud_files/mod.rs b/clients/desktop/src-tauri/src/cloud_files/mod.rs new file mode 100644 index 0000000..9c671c6 --- /dev/null +++ b/clients/desktop/src-tauri/src/cloud_files/mod.rs @@ -0,0 +1,117 @@ +//! Native File-Provider-Integration (Platzhalter-Dateien wie bei OneDrive). +//! +//! Auf Windows realisiert ueber die Cloud Files API (cfapi.dll), auf Linux +//! ueber FUSE (optional, hinter `linux_fuse`-Feature). macOS folgt spaeter +//! ueber NSFileProviderExtension (braucht Apple-Signatur). +//! +//! Der bestehende `sync::engine` bleibt unberuehrt und bietet weiterhin +//! den klassischen "kopiere-alles-lokal"-Modus. Der Cloud-Files-Modus +//! ist sozusagen "files-on-demand": Datei wird erst bei Zugriff geladen. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Ein Eintrag aus dem Mini-Cloud-Syncbaum, so wie er vom Server kommt. +/// Wird von beiden Plattformen genutzt, um Platzhalter / FUSE-Inodes zu +/// erzeugen. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteEntry { + pub id: i64, + pub name: String, + pub parent_id: Option, + pub is_folder: bool, + pub size: i64, + /// UTC-ISO8601 + pub modified_at: String, + /// SHA-256 falls vom Server ausgeliefert, sonst None. + pub checksum: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyncState { + /// Datei existiert nur als Platzhalter (online-only). + Cloud, + /// Datei ist vollstaendig lokal vorhanden und aktuell. + InSync, + /// Lokal geaendert, Upload ausstehend. + PendingUpload, + /// Auf dem Server gesperrt (durch anderen Nutzer). + LockedByOther, + /// Durch diesen Client gesperrt. + LockedLocal, +} + +#[cfg(windows)] +pub mod windows; +#[cfg(all(target_os = "linux", feature = "linux_fuse"))] +pub mod linux; + +/// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform +/// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem. +#[allow(unused_variables)] +pub fn register_sync_root( + mount_point: &PathBuf, + provider_name: &str, + account_id: &str, +) -> Result<(), String> { + #[cfg(windows)] + return windows::register_sync_root(mount_point, provider_name, account_id); + + #[cfg(all(target_os = "linux", feature = "linux_fuse"))] + return linux::mount(mount_point); + + #[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))] + Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into()) +} + +#[allow(unused_variables)] +pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> { + #[cfg(windows)] + return windows::unregister_sync_root(mount_point); + + #[cfg(all(target_os = "linux", feature = "linux_fuse"))] + return linux::unmount(mount_point); + + #[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))] + Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into()) +} + +/// Erzeuge fuer alle Remote-Eintraege Platzhalter (cloud-only Dateien). +/// Ordner werden als echte Verzeichnisse angelegt, Dateien als +/// Platzhalter mit gespeicherten Metadaten (Groesse, Mtime, ID). +#[allow(unused_variables)] +pub fn populate_placeholders( + mount_point: &PathBuf, + entries: &[RemoteEntry], +) -> Result<(), String> { + #[cfg(windows)] + return windows::populate_placeholders(mount_point, entries); + + #[cfg(all(target_os = "linux", feature = "linux_fuse"))] + return linux::populate(mount_point, entries); + + #[cfg(not(any(windows, all(target_os = "linux", feature = "linux_fuse"))))] + Err("File-Provider-Integration fuer diese Plattform noch nicht verfuegbar".into()) +} + +/// Ist File-Provider-Integration auf dieser Plattform grundsaetzlich verfuegbar? +pub fn is_supported() -> bool { + cfg!(windows) || cfg!(all(target_os = "linux", feature = "linux_fuse")) +} + +/// Markiere eine lokal bereits vorhandene Datei als "immer offline halten". +#[allow(unused_variables)] +pub fn pin_file(path: &PathBuf) -> Result<(), String> { + #[cfg(windows)] + return windows::set_pin_state(path, true); + #[cfg(not(windows))] + Err("Nur auf Windows unterstuetzt".into()) +} + +#[allow(unused_variables)] +pub fn unpin_file(path: &PathBuf) -> Result<(), String> { + #[cfg(windows)] + return windows::set_pin_state(path, false); + #[cfg(not(windows))] + Err("Nur auf Windows unterstuetzt".into()) +} diff --git a/clients/desktop/src-tauri/src/cloud_files/windows.rs b/clients/desktop/src-tauri/src/cloud_files/windows.rs new file mode 100644 index 0000000..7da7951 --- /dev/null +++ b/clients/desktop/src-tauri/src/cloud_files/windows.rs @@ -0,0 +1,413 @@ +//! Windows Cloud Files API Integration. +//! +//! Registriert den Sync-Ordner als Sync-Root, legt Platzhalter-Dateien an +//! und reicht Zugriffe auf Dateidaten als HTTPS-Download durch. Der +//! Explorer zeigt Wolken-/Haken-Overlays automatisch an, solange die +//! Pin-Stati korrekt gesetzt sind. +//! +//! Voraussetzung: Windows 10 1709+ (cfapi.dll). Der Account-Identifier +//! sollte stabil sein (z.B. Hash(Server-URL + Username)). + +#![cfg(windows)] + +use super::{RemoteEntry, SyncState}; +use std::ffi::OsString; +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; +use std::ptr; +use std::sync::{Arc, Mutex}; +use once_cell::sync::Lazy; +use widestring::U16CString; + +use windows::core::{HRESULT, HSTRING, PCWSTR, PWSTR}; +use windows::Win32::Foundation::{GetLastError, HANDLE, S_OK}; +use windows::Win32::Storage::CloudFilters as CF; +use windows::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, +}; +use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED}; + +/// Geteilter Zustand, den die FETCH_DATA-Callback braucht, um mit dem +/// Mini-Cloud-Server zu sprechen. Wird vor register_sync_root gesetzt. +#[derive(Default)] +pub struct CloudContext { + pub server_url: String, + pub access_token: String, + /// Mapping von cfapi-FileIdentity (wir verwenden die Mini-Cloud-File-ID + /// als utf8-Bytes) zu File-ID. + pub mount_point: PathBuf, +} + +static CONTEXT: Lazy>> = + Lazy::new(|| Arc::new(Mutex::new(CloudContext::default()))); + +static CONNECTION_KEY: Lazy>> = + Lazy::new(|| Mutex::new(None)); + +pub fn set_context(server_url: String, access_token: String, mount_point: PathBuf) { + let mut ctx = CONTEXT.lock().unwrap(); + ctx.server_url = server_url; + ctx.access_token = access_token; + ctx.mount_point = mount_point; +} + +fn to_wide(s: &str) -> Vec { + OsString::from(s).encode_wide().chain(std::iter::once(0)).collect() +} + +/// cfapi-Provider-Version. Aenderung dieses Strings fuehrt zu einem +/// Re-Sync aller Platzhalter. +const PROVIDER_VERSION: &str = "1.0"; + +// --------------------------------------------------------------------------- +// Sync-Root-Registrierung +// --------------------------------------------------------------------------- + +pub fn register_sync_root( + mount_point: &PathBuf, + provider_name: &str, + account_id: &str, +) -> Result<(), String> { + // COM initialisieren (cfapi benoetigt MTA-Apartment) + unsafe { + let _ = CoInitializeEx(Some(ptr::null()), COINIT_MULTITHREADED); + } + + std::fs::create_dir_all(mount_point).map_err(|e| format!("mkdir: {e}"))?; + + let display = format!("Mini-Cloud - {}", account_id); + let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref()) + .map_err(|e| format!("path encode: {e}"))?; + let display_wide = U16CString::from_str(&display).map_err(|e| e.to_string())?; + let provider_wide = U16CString::from_str(provider_name).map_err(|e| e.to_string())?; + let version_wide = U16CString::from_str(PROVIDER_VERSION).map_err(|e| e.to_string())?; + let account_wide = U16CString::from_str(account_id).map_err(|e| e.to_string())?; + + let mut info = CF::CF_SYNC_REGISTRATION::default(); + info.StructSize = std::mem::size_of::() as u32; + info.ProviderName = PCWSTR(provider_wide.as_ptr()); + info.ProviderVersion = PCWSTR(version_wide.as_ptr()); + info.ProviderId = windows::core::GUID::from_u128(0x4D_69_6E_69_43_6C_6F_75_64_44_75_66_66_79_00_01); + + let mut policies = CF::CF_SYNC_POLICIES::default(); + policies.StructSize = std::mem::size_of::() as u32; + policies.HardLink = CF::CF_HARDLINK_POLICY_NONE; + policies.Hydration = CF::CF_HYDRATION_POLICY_PARTIAL; + policies.Population = CF::CF_POPULATION_POLICY_PARTIAL; + policies.InSync = CF::CF_INSYNC_POLICY_TRACK_ALL; + + let mut reg = CF::CF_SYNC_ROOT_BASIC_INFO::default(); + let hr: HRESULT = unsafe { + CF::CfRegisterSyncRoot( + PCWSTR(path_wide.as_ptr()), + &info, + &policies, + CF::CF_REGISTER_FLAG_UPDATE, + ) + }; + if hr != S_OK { + return Err(format!("CfRegisterSyncRoot hr=0x{:08x}", hr.0)); + } + + // Callbacks verbinden + connect_callbacks(mount_point)?; + Ok(()) +} + +pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> { + disconnect_callbacks()?; + let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref()) + .map_err(|e| e.to_string())?; + let hr: HRESULT = unsafe { CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr())) }; + if hr != S_OK { + return Err(format!("CfUnregisterSyncRoot hr=0x{:08x}", hr.0)); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Callback-Tabelle +// --------------------------------------------------------------------------- + +unsafe extern "system" fn on_fetch_data( + info: *const CF::CF_CALLBACK_INFO, + params: *const CF::CF_CALLBACK_PARAMETERS, +) { + let info = &*info; + let params = &*params; + let fetch = ¶ms.Anonymous.FetchData; + + // FileIdentity enthaelt unsere Mini-Cloud-File-ID als Bytes. + let identity = std::slice::from_raw_parts( + info.FileIdentity as *const u8, + info.FileIdentityLength as usize, + ); + let file_id: i64 = std::str::from_utf8(identity) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + + let offset = fetch.RequiredFileOffset.0; + let length = fetch.RequiredLength.0 as u64; + + // HTTPS-Download in separaten Thread (Callback darf nicht blockieren). + let ctx = CONTEXT.lock().unwrap().clone_weak(); + std::thread::spawn(move || { + let _ = transfer_range(info.ConnectionKey, info.TransferKey, file_id, offset, length, ctx); + }); +} + +#[derive(Clone)] +struct CtxSnapshot { + server_url: String, + access_token: String, +} + +impl CloudContext { + fn clone_weak(&self) -> CtxSnapshot { + CtxSnapshot { + server_url: self.server_url.clone(), + access_token: self.access_token.clone(), + } + } +} + +fn transfer_range( + connection_key: CF::CF_CONNECTION_KEY, + transfer_key: i64, + file_id: i64, + offset: i64, + length: u64, + ctx: CtxSnapshot, +) -> Result<(), String> { + // Teil-Download per Range-Header. Bei Fehlern melden wir den Transfer + // als fehlgeschlagen zurueck, damit der Explorer einen Fehler anzeigt. + let client = reqwest::blocking::Client::new(); + let url = format!("{}/api/files/{}/download", ctx.server_url, file_id); + let range = format!("bytes={}-{}", offset, offset as u64 + length - 1); + let resp = client + .get(&url) + .bearer_auth(&ctx.access_token) + .header("Range", &range) + .send() + .map_err(|e| format!("download: {e}"))?; + if !resp.status().is_success() && !resp.status().as_u16() == 206 { + let _ = complete_transfer(connection_key, transfer_key, None, offset, length); + return Err(format!("HTTP {}", resp.status())); + } + let bytes = resp.bytes().map_err(|e| e.to_string())?; + complete_transfer(connection_key, transfer_key, Some(&bytes), offset, length) +} + +fn complete_transfer( + connection_key: CF::CF_CONNECTION_KEY, + transfer_key: i64, + data: Option<&[u8]>, + offset: i64, + length: u64, +) -> Result<(), String> { + let mut op_info = CF::CF_OPERATION_INFO::default(); + op_info.StructSize = std::mem::size_of::() as u32; + op_info.Type = CF::CF_OPERATION_TYPE_TRANSFER_DATA; + op_info.ConnectionKey = connection_key; + op_info.TransferKey = transfer_key; + let mut params = CF::CF_OPERATION_PARAMETERS::default(); + params.ParamSize = std::mem::size_of::() as u32; + + let transfer = &mut params.Anonymous.TransferData; + if let Some(data) = data { + transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0); // STATUS_SUCCESS + transfer.Buffer = data.as_ptr() as _; + transfer.Offset = windows::Win32::Foundation::LARGE_INTEGER { QuadPart: offset }; + transfer.Length = windows::Win32::Foundation::LARGE_INTEGER { QuadPart: length as i64 }; + } else { + transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0xC0000001u32 as i32); // STATUS_UNSUCCESSFUL + } + + let hr = unsafe { CF::CfExecute(&op_info, &mut params) }; + if hr != S_OK { + return Err(format!("CfExecute hr=0x{:08x}", hr.0)); + } + Ok(()) +} + +fn connect_callbacks(mount_point: &Path) -> Result<(), String> { + let callbacks = [ + CF::CF_CALLBACK_REGISTRATION { + Type: CF::CF_CALLBACK_TYPE_FETCH_DATA, + Callback: Some(on_fetch_data), + }, + CF::CF_CALLBACK_REGISTRATION::default(), // Sentinel (Type = INVALID) + ]; + + let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref()) + .map_err(|e| e.to_string())?; + + let mut key = CF::CF_CONNECTION_KEY::default(); + let hr = unsafe { + CF::CfConnectSyncRoot( + PCWSTR(path_wide.as_ptr()), + callbacks.as_ptr(), + std::ptr::null(), + CF::CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO | CF::CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH, + &mut key, + ) + }; + if hr != S_OK { + return Err(format!("CfConnectSyncRoot hr=0x{:08x}", hr.0)); + } + *CONNECTION_KEY.lock().unwrap() = Some(key); + Ok(()) +} + +fn disconnect_callbacks() -> Result<(), String> { + if let Some(key) = CONNECTION_KEY.lock().unwrap().take() { + let hr = unsafe { CF::CfDisconnectSyncRoot(key) }; + if hr != S_OK { + return Err(format!("CfDisconnectSyncRoot hr=0x{:08x}", hr.0)); + } + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Placeholder-Erzeugung +// --------------------------------------------------------------------------- + +pub fn populate_placeholders(mount_point: &PathBuf, entries: &[RemoteEntry]) -> Result<(), String> { + // Ordner-Hierarchie nachbauen. + use std::collections::HashMap; + let by_id: HashMap = entries.iter().map(|e| (e.id, e)).collect(); + + fn rel_path<'a>( + entry: &'a RemoteEntry, + by_id: &HashMap, + ) -> PathBuf { + let mut parts = vec![entry.name.as_str()]; + let mut cur = entry.parent_id; + while let Some(id) = cur { + if let Some(p) = by_id.get(&id) { + parts.push(p.name.as_str()); + cur = p.parent_id; + } else { + break; + } + } + parts.reverse(); + parts.iter().collect() + } + + // Erst Ordner anlegen + for e in entries.iter().filter(|e| e.is_folder) { + let p = mount_point.join(rel_path(e, &by_id)); + std::fs::create_dir_all(&p).ok(); + } + + // Dann Dateien als Platzhalter + for e in entries.iter().filter(|e| !e.is_folder) { + let rel = rel_path(e, &by_id); + let parent = rel.parent().map(|p| mount_point.join(p)).unwrap_or_else(|| mount_point.clone()); + let identity = e.id.to_string(); + if let Err(err) = create_placeholder(&parent, &e.name, e.size, &e.modified_at, identity.as_bytes()) { + eprintln!("placeholder {}: {}", e.name, err); + } + } + Ok(()) +} + +fn create_placeholder( + parent_dir: &Path, + name: &str, + size: i64, + modified_iso: &str, + file_identity: &[u8], +) -> Result<(), String> { + let parent_wide = U16CString::from_str(parent_dir.to_string_lossy().as_ref()) + .map_err(|e| e.to_string())?; + let name_wide = U16CString::from_str(name).map_err(|e| e.to_string())?; + + // FILETIME aus ISO-Zeit. Bei Parse-Fehler Epoche nehmen. + let mtime = chrono::DateTime::parse_from_rfc3339(modified_iso) + .map(|dt| dt.timestamp()) + .unwrap_or(0); + // Windows-FILETIME: 100ns-Ticks seit 1601-01-01 + let ft_ticks = (mtime as i64 + 11_644_473_600) * 10_000_000; + let ft = windows::Win32::Foundation::FILETIME { + dwLowDateTime: (ft_ticks & 0xFFFF_FFFF) as u32, + dwHighDateTime: (ft_ticks >> 32) as u32, + }; + + let mut ph = CF::CF_PLACEHOLDER_CREATE_INFO::default(); + ph.RelativeFileName = PCWSTR(name_wide.as_ptr()); + ph.FsMetadata.FileSize = windows::Win32::Foundation::LARGE_INTEGER { QuadPart: size }; + ph.FsMetadata.BasicInfo.FileAttributes = FILE_ATTRIBUTE_NORMAL.0; + ph.FsMetadata.BasicInfo.LastWriteTime = ft; + ph.FsMetadata.BasicInfo.CreationTime = ft; + ph.FsMetadata.BasicInfo.ChangeTime = ft; + ph.FsMetadata.BasicInfo.LastAccessTime = ft; + ph.Flags = CF::CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC; + ph.FileIdentity = file_identity.as_ptr() as _; + ph.FileIdentityLength = file_identity.len() as u32; + + let mut count: u32 = 1; + let hr = unsafe { + CF::CfCreatePlaceholders( + PCWSTR(parent_wide.as_ptr()), + &mut ph, + count, + CF::CF_CREATE_FLAG_NONE, + &mut count, + ) + }; + if hr != S_OK { + return Err(format!("CfCreatePlaceholders hr=0x{:08x}", hr.0)); + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Pin / Unpin (offline halten) +// --------------------------------------------------------------------------- + +pub fn set_pin_state(file: &Path, pinned: bool) -> Result<(), String> { + use windows::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES, OPEN_EXISTING, + FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + + let path_wide = U16CString::from_str(file.to_string_lossy().as_ref()) + .map_err(|e| e.to_string())?; + let handle = unsafe { + CreateFileW( + PCWSTR(path_wide.as_ptr()), + FILE_READ_ATTRIBUTES.0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS, + None, + ) + } + .map_err(|e| format!("open: {e}"))?; + + let state = if pinned { + CF::CF_PIN_STATE_PINNED + } else { + CF::CF_PIN_STATE_UNPINNED + }; + let hr = unsafe { + CF::CfSetPinState( + handle, + state, + CF::CF_SET_PIN_FLAG_NONE, + std::ptr::null_mut(), + ) + }; + unsafe { + let _ = windows::Win32::Foundation::CloseHandle(handle); + } + if hr != S_OK { + return Err(format!("CfSetPinState hr=0x{:08x}", hr.0)); + } + Ok(()) +} diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index d6f3e75..1eb71ff 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod sync; +mod cloud_files; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; @@ -884,6 +885,127 @@ fn handle_single_instance() { +// --------------------------------------------------------------------------- +// Native File-Provider-Integration (OneDrive-artige Platzhalter) +// --------------------------------------------------------------------------- + +#[tauri::command] +fn cloud_files_supported() -> bool { + cloud_files::is_supported() +} + +#[tauri::command] +async fn cloud_files_enable( + state: State<'_, AppState>, + mount_point: String, +) -> Result<(), String> { + let mp = PathBuf::from(&mount_point); + let api_guard = state.api.lock().unwrap(); + let api = api_guard.as_ref().ok_or("Nicht eingeloggt")?; + let server = api.server_url.clone(); + let token = api.access_token.clone(); + let username = state + .username + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| "user".into()); + + #[cfg(windows)] + { + cloud_files::windows::set_context(server.clone(), token.clone(), mp.clone()); + } + drop(api_guard); + + cloud_files::register_sync_root(&mp, "Mini-Cloud", &username)?; + + // Baum vom Server holen und Platzhalter anlegen + let entries = fetch_remote_entries(&server, &token).await?; + cloud_files::populate_placeholders(&mp, &entries)?; + Ok(()) +} + +#[tauri::command] +async fn cloud_files_disable(mount_point: String) -> Result<(), String> { + cloud_files::unregister_sync_root(&PathBuf::from(mount_point)) +} + +#[tauri::command] +async fn cloud_files_pin(path: String) -> Result<(), String> { + cloud_files::pin_file(&PathBuf::from(path)) +} + +#[tauri::command] +async fn cloud_files_unpin(path: String) -> Result<(), String> { + cloud_files::unpin_file(&PathBuf::from(path)) +} + +async fn fetch_remote_entries( + server: &str, + token: &str, +) -> Result, String> { + let client = reqwest::Client::new(); + let url = format!("{}/api/sync/tree", server.trim_end_matches('/')); + let resp = client + .get(&url) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("tree: {e}"))?; + if !resp.status().is_success() { + return Err(format!("HTTP {}", resp.status())); + } + let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?; + let tree = json + .get("tree") + .ok_or("Antwort ohne 'tree'")? + .as_array() + .cloned() + .unwrap_or_default(); + + // Rekursiv flach machen (Struktur parent_id beibehalten). + fn walk( + nodes: &[serde_json::Value], + parent: Option, + out: &mut Vec, + ) { + for n in nodes { + let id = n.get("id").and_then(|x| x.as_i64()).unwrap_or(0); + let name = n + .get("name") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let is_folder = n.get("is_folder").and_then(|x| x.as_bool()).unwrap_or(false); + let size = n.get("size").and_then(|x| x.as_i64()).unwrap_or(0); + let modified_at = n + .get("modified_at") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let checksum = n + .get("checksum") + .and_then(|x| x.as_str()) + .map(|s| s.to_string()); + out.push(cloud_files::RemoteEntry { + id, + name, + parent_id: parent, + is_folder, + size, + modified_at, + checksum, + }); + if let Some(children) = n.get("children").and_then(|x| x.as_array()) { + walk(children, Some(id), out); + } + } + } + let mut flat = Vec::new(); + walk(&tree, None, &mut flat); + Ok(flat) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { handle_single_instance(); @@ -1016,6 +1138,11 @@ pub fn run() { browse_sync_folder, mark_offline, unmark_offline, + cloud_files_supported, + cloud_files_enable, + cloud_files_disable, + cloud_files_pin, + cloud_files_unpin, ]) .run(tauri::generate_context!()) .expect("error while running tauri application");