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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||||
@@ -28,3 +28,27 @@ rusqlite = { version = "0.34", features = ["bundled"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
open = "5"
|
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"]
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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<i64>,
|
||||||
|
pub is_folder: bool,
|
||||||
|
pub size: i64,
|
||||||
|
/// UTC-ISO8601
|
||||||
|
pub modified_at: String,
|
||||||
|
/// SHA-256 falls vom Server ausgeliefert, sonst None.
|
||||||
|
pub checksum: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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())
|
||||||
|
}
|
||||||
@@ -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<Arc<Mutex<CloudContext>>> =
|
||||||
|
Lazy::new(|| Arc::new(Mutex::new(CloudContext::default())));
|
||||||
|
|
||||||
|
static CONNECTION_KEY: Lazy<Mutex<Option<CF::CF_CONNECTION_KEY>>> =
|
||||||
|
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<u16> {
|
||||||
|
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::<CF::CF_SYNC_REGISTRATION>() 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::<CF::CF_SYNC_POLICIES>() 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::<CF::CF_OPERATION_INFO>() 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::<CF::CF_OPERATION_PARAMETERS>() 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<i64, &RemoteEntry> = entries.iter().map(|e| (e.id, e)).collect();
|
||||||
|
|
||||||
|
fn rel_path<'a>(
|
||||||
|
entry: &'a RemoteEntry,
|
||||||
|
by_id: &HashMap<i64, &'a RemoteEntry>,
|
||||||
|
) -> 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(())
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod sync;
|
mod sync;
|
||||||
|
mod cloud_files;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
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<Vec<cloud_files::RemoteEntry>, 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<i64>,
|
||||||
|
out: &mut Vec<cloud_files::RemoteEntry>,
|
||||||
|
) {
|
||||||
|
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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
handle_single_instance();
|
handle_single_instance();
|
||||||
@@ -1016,6 +1138,11 @@ pub fn run() {
|
|||||||
browse_sync_folder,
|
browse_sync_folder,
|
||||||
mark_offline,
|
mark_offline,
|
||||||
unmark_offline,
|
unmark_offline,
|
||||||
|
cloud_files_supported,
|
||||||
|
cloud_files_enable,
|
||||||
|
cloud_files_disable,
|
||||||
|
cloud_files_pin,
|
||||||
|
cloud_files_unpin,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
Reference in New Issue
Block a user