Compare commits
4 Commits
2610e3b183
...
d9a4ee6a0b
| Author | SHA1 | Date | |
|---|---|---|---|
| d9a4ee6a0b | |||
| 8f70b047d8 | |||
| f9bf53803f | |||
| de1039fc7d |
@@ -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.
|
||||
@@ -19,7 +19,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-notification = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls"], default-features = false }
|
||||
reqwest = { version = "0.12", features = ["json", "multipart", "rustls-tls", "blocking"], default-features = false }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
notify = "7"
|
||||
sha2 = "0.10"
|
||||
@@ -28,3 +28,28 @@ 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_System_CorrelationVector", # gate fuer CF_CALLBACK_INFO / CfExecute / CfConnectSyncRoot
|
||||
"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,119 @@
|
||||
//! 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;
|
||||
pub mod sync_loop;
|
||||
pub mod watcher;
|
||||
|
||||
/// 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,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<AtomicBool>,
|
||||
pub tx: mpsc::UnboundedSender<LoopMessage>,
|
||||
}
|
||||
|
||||
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::<LoopMessage>();
|
||||
let stop = stop_flag.clone();
|
||||
let cfg_task = cfg.clone();
|
||||
tokio::spawn(async move {
|
||||
let client = reqwest::Client::new();
|
||||
let mut since: Option<String> = 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<RemoteEntry>,
|
||||
#[serde(default)]
|
||||
updated: Vec<RemoteEntry>,
|
||||
#[serde(default)]
|
||||
deleted: Vec<i64>,
|
||||
timestamp: Option<String>,
|
||||
}
|
||||
|
||||
async fn poll_server_changes(
|
||||
client: &reqwest::Client,
|
||||
cfg: &SyncLoopConfig,
|
||||
since: &mut Option<String>,
|
||||
) -> 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")
|
||||
}
|
||||
@@ -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<F>(watch_dir: &Path, mut on_change: F) -> Result<Self, String>
|
||||
where
|
||||
F: FnMut(PathBuf, EventKind) + Send + 'static,
|
||||
{
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |res: Result<Event, notify::Error>| {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,433 @@
|
||||
//! 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;
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::ptr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use widestring::U16CString;
|
||||
|
||||
use windows::core::PCWSTR;
|
||||
use windows::Win32::Storage::CloudFilters as CF;
|
||||
use windows::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL;
|
||||
use windows::Win32::System::Com::{CoInitializeEx, COINIT_MULTITHREADED};
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct CloudContext {
|
||||
pub server_url: String,
|
||||
pub access_token: String,
|
||||
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 ctx_snapshot() -> CloudContext {
|
||||
CONTEXT.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
const PROVIDER_VERSION: &str = "1.0";
|
||||
|
||||
// Windows-FILETIME: 100ns-Ticks seit 1601-01-01. Unix-Epoch liegt
|
||||
// 11_644_473_600 Sekunden danach.
|
||||
fn unix_to_ft_ticks(unix_secs: i64) -> i64 {
|
||||
(unix_secs + 11_644_473_600) * 10_000_000
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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 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());
|
||||
// Stabile GUID fuer "Mini-Cloud" (random einmalig generiert).
|
||||
info.ProviderId = windows::core::GUID::from_u128(0x4D696E69_436C_6F75_6444_7566667944ab);
|
||||
|
||||
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::default();
|
||||
policies.Hydration = CF::CF_HYDRATION_POLICY::default();
|
||||
policies.Population = CF::CF_POPULATION_POLICY::default();
|
||||
policies.InSync = CF::CF_INSYNC_POLICY::default();
|
||||
|
||||
// Das Struct-Feld ist `CF_HYDRATION_POLICY` (u16-Wrapper um das
|
||||
// _PRIMARY-Enum). Direkter Feldzugriff:
|
||||
policies.Hydration.Primary = CF::CF_HYDRATION_POLICY_PARTIAL;
|
||||
policies.Population.Primary = CF::CF_POPULATION_POLICY_PARTIAL;
|
||||
|
||||
// Holder fuer displayname, damit wir ihn spaeter ggf. in ein eigenes
|
||||
// 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 {
|
||||
if let Err(e) = CF::CfRegisterSyncRoot(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
&info,
|
||||
&policies,
|
||||
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)?;
|
||||
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())?;
|
||||
unsafe {
|
||||
CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()))
|
||||
.map_err(|e| format!("CfUnregisterSyncRoot: {e}"))?;
|
||||
}
|
||||
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 UTF-8-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: i64 = fetch.RequiredFileOffset;
|
||||
let length: u64 = fetch.RequiredLength as u64;
|
||||
let connection_key = info.ConnectionKey;
|
||||
let transfer_key = info.TransferKey;
|
||||
|
||||
// HTTPS-Download im separaten Thread (Callback darf nicht blockieren).
|
||||
let ctx = ctx_snapshot();
|
||||
std::thread::spawn(move || {
|
||||
let _ = transfer_range(connection_key, transfer_key, file_id, offset, length, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
fn transfer_range(
|
||||
connection_key: CF::CF_CONNECTION_KEY,
|
||||
transfer_key: i64,
|
||||
file_id: i64,
|
||||
offset: i64,
|
||||
length: u64,
|
||||
ctx: CloudContext,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!(
|
||||
"{}/api/files/{}/download",
|
||||
ctx.server_url.trim_end_matches('/'),
|
||||
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}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() && status.as_u16() != 206 {
|
||||
let _ = complete_transfer(connection_key, transfer_key, None, offset, length);
|
||||
return Err(format!("HTTP {}", status));
|
||||
}
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.map_err(|e: reqwest::Error| 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;
|
||||
|
||||
unsafe {
|
||||
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 = offset;
|
||||
transfer.Length = length as i64;
|
||||
} else {
|
||||
transfer.CompletionStatus =
|
||||
windows::Win32::Foundation::NTSTATUS(0xC0000001u32 as i32); // STATUS_UNSUCCESSFUL
|
||||
}
|
||||
|
||||
CF::CfExecute(&op_info, &mut params).map_err(|e| format!("CfExecute: {e}"))?;
|
||||
}
|
||||
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),
|
||||
},
|
||||
// Sentinel: Type = INVALID beendet die Tabelle.
|
||||
CF::CF_CALLBACK_REGISTRATION {
|
||||
Type: CF::CF_CALLBACK_TYPE_NONE,
|
||||
Callback: None,
|
||||
},
|
||||
];
|
||||
|
||||
let path_wide = U16CString::from_str(mount_point.to_string_lossy().as_ref())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let key = unsafe {
|
||||
CF::CfConnectSyncRoot(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
callbacks.as_ptr(),
|
||||
None,
|
||||
CF::CF_CONNECT_FLAG_REQUIRE_PROCESS_INFO
|
||||
| CF::CF_CONNECT_FLAG_REQUIRE_FULL_FILE_PATH,
|
||||
)
|
||||
.map_err(|e| format!("CfConnectSyncRoot: {e}"))?
|
||||
};
|
||||
*CONNECTION_KEY.lock().unwrap() = Some(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn disconnect_callbacks() -> Result<(), String> {
|
||||
if let Some(key) = CONNECTION_KEY.lock().unwrap().take() {
|
||||
unsafe {
|
||||
CF::CfDisconnectSyncRoot(key)
|
||||
.map_err(|e| format!("CfDisconnectSyncRoot: {e}"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Placeholder-Erzeugung
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn populate_placeholders(
|
||||
mount_point: &PathBuf,
|
||||
entries: &[RemoteEntry],
|
||||
) -> Result<(), String> {
|
||||
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(())
|
||||
}
|
||||
|
||||
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,
|
||||
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())?;
|
||||
|
||||
let mtime_unix = chrono::DateTime::parse_from_rfc3339(modified_iso)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0);
|
||||
let ft_ticks = unix_to_ft_ticks(mtime_unix);
|
||||
|
||||
let mut ph = CF::CF_PLACEHOLDER_CREATE_INFO::default();
|
||||
ph.RelativeFileName = PCWSTR(name_wide.as_ptr());
|
||||
ph.FsMetadata.FileSize = size;
|
||||
ph.FsMetadata.BasicInfo.FileAttributes = FILE_ATTRIBUTE_NORMAL.0;
|
||||
ph.FsMetadata.BasicInfo.LastWriteTime = ft_ticks;
|
||||
ph.FsMetadata.BasicInfo.CreationTime = ft_ticks;
|
||||
ph.FsMetadata.BasicInfo.ChangeTime = ft_ticks;
|
||||
ph.FsMetadata.BasicInfo.LastAccessTime = ft_ticks;
|
||||
ph.Flags = CF::CF_PLACEHOLDER_CREATE_FLAG_MARK_IN_SYNC;
|
||||
ph.FileIdentity = file_identity.as_ptr() as _;
|
||||
ph.FileIdentityLength = file_identity.len() as u32;
|
||||
|
||||
// CfCreatePlaceholders nimmt in windows-rs 0.58 einen Slice und einen
|
||||
// Option<*mut u32> fuer "wie viele wurden angelegt".
|
||||
let mut phs = [ph];
|
||||
let mut count: u32 = 0;
|
||||
unsafe {
|
||||
CF::CfCreatePlaceholders(
|
||||
PCWSTR(parent_wide.as_ptr()),
|
||||
&mut phs,
|
||||
CF::CF_CREATE_FLAG_NONE,
|
||||
Some(&mut count as *mut u32),
|
||||
)
|
||||
.map_err(|e| format!("CfCreatePlaceholders: {e}"))?;
|
||||
}
|
||||
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,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
|
||||
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 res = unsafe {
|
||||
CF::CfSetPinState(handle, state, CF::CF_SET_PIN_FLAG_NONE, None)
|
||||
};
|
||||
unsafe {
|
||||
let _ = windows::Win32::Foundation::CloseHandle(handle);
|
||||
}
|
||||
res.map_err(|e| format!("CfSetPinState: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
mod sync;
|
||||
mod cloud_files;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -26,6 +27,8 @@ struct AppState {
|
||||
sync_paths: Mutex<Vec<SyncPath>>,
|
||||
journal: Arc<Journal>,
|
||||
background_started: AtomicBool,
|
||||
cloud_files_loop: Mutex<Option<cloud_files::sync_loop::SyncLoopHandle>>,
|
||||
cloud_files_watcher: Mutex<Option<cloud_files::watcher::CallbackWatcher>>,
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
@@ -884,6 +887,161 @@ 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);
|
||||
// MutexGuards nur kurz halten, damit der Future Send bleibt.
|
||||
let (server, token, username) = {
|
||||
let api_guard = state.api.lock().unwrap();
|
||||
let api = api_guard.as_ref().ok_or("Nicht eingeloggt")?;
|
||||
let username = state
|
||||
.username
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.unwrap_or_else(|| "user".into());
|
||||
(api.server_url.clone(), api.access_token.clone(), username)
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
cloud_files::windows::set_context(server.clone(), token.clone(), mp.clone());
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
// 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(
|
||||
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))
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub fn run() {
|
||||
handle_single_instance();
|
||||
@@ -902,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
|
||||
@@ -1016,6 +1176,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");
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
|
||||
<div v-if="cloudFilesSupported" class="section">
|
||||
<div class="section-header">
|
||||
<h3>Cloud-Files (OneDrive-Style)</h3>
|
||||
<span v-if="cloudFilesActive" class="status-badge syncing">☁ aktiv</span>
|
||||
</div>
|
||||
<p class="hint">Dateien erscheinen als Platzhalter im Explorer mit Wolken-Icon und werden erst bei Zugriff geladen. Rechtsklick im Explorer → "Immer offline halten" oder "Speicher freigeben".</p>
|
||||
<div class="cf-row">
|
||||
<input v-model="cloudFilesMountPoint" placeholder="Ordner waehlen..." />
|
||||
<button class="btn-secondary" @click="browseCfMount">Durchsuchen</button>
|
||||
<button v-if="!cloudFilesActive" class="btn-primary" :disabled="!cloudFilesMountPoint || cloudFilesBusy" @click="enableCloudFiles">
|
||||
{{ cloudFilesBusy ? "Aktiviere..." : "Aktivieren" }}
|
||||
</button>
|
||||
<button v-else class="btn-secondary" :disabled="cloudFilesBusy" @click="disableCloudFiles">Deaktivieren</button>
|
||||
</div>
|
||||
<div v-if="cloudFilesError" class="error" style="margin-top:0.5rem">{{ cloudFilesError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Sync Paths -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user