feat(client/windows): cfapi-Sync lebendig machen (Loop + Watcher + UI)

Jetzt tatsaechlich funktionsfaehig, nicht mehr nur Dummy:

- Register-Fallback: erst CF_REGISTER_FLAG_NONE, bei "bereits registriert"
  automatisch mit UPDATE erneut versuchen. Klappt damit bei Erstaktivierung
  und bei Client-Neustart.
- Hintergrund-Loop (cloud_files::sync_loop) pollt alle 30s
  /api/sync/changes, legt neue Placeholder an und ersetzt geaenderte.
- Eigener Callback-Watcher (cloud_files::watcher::CallbackWatcher) hoert
  auf den Mount-Ordner und sendet lokale Aenderungen (Create/Modify) an
  den Loop, der sie via POST /api/files/upload hochlaedt.
- Helper create_placeholder_at() vom Windows-Modul exportiert, damit der
  Loop neue Server-Dateien als Placeholder anlegen kann.
- AppState erhaelt cloud_files_loop + cloud_files_watcher Felder; beim
  Disable wird der Loop sauber gestoppt und der Watcher gedroppt.

Frontend (App.vue):
- Neue Sektion "Cloud-Files (OneDrive-Style)" nur sichtbar wenn die
  Plattform es unterstuetzt (cloud_files_supported).
- Ordner-Picker + Aktivieren/Deaktivieren-Button.
- Fehlermeldungen + Sync-Log-Eintraege.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker 2026-04-15 08:46:52 +02:00
parent 8f70b047d8
commit d9a4ee6a0b
6 changed files with 381 additions and 5 deletions

View File

@ -45,6 +45,8 @@ pub enum SyncState {
pub mod windows;
#[cfg(all(target_os = "linux", feature = "linux_fuse"))]
pub mod linux;
pub mod sync_loop;
pub mod watcher;
/// Registriere den Sync-Root beim Betriebssystem. Ruft je nach Plattform
/// cfapi/CfRegisterSyncRoot bzw. mountet ein FUSE-Dateisystem.

View File

@ -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")
}

View File

@ -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 })
}
}

View File

@ -100,14 +100,30 @@ pub fn register_sync_root(
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
let _ = display_wide;
// Erst versuchen ohne UPDATE-Flag (frische Registrierung). Bei "schon
// registriert"-Fehler nochmal mit UPDATE-Flag. So laeuft es beim ersten
// Aktivieren UND bei Folgestarts.
unsafe {
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: {e}"))?;
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
} else {
return Err(format!("CfRegisterSyncRoot: {e}"));
}
}
}
connect_callbacks(mount_point)?;
@ -322,6 +338,16 @@ pub fn populate_placeholders(
Ok(())
}
pub fn create_placeholder_at(
parent_dir: &Path,
name: &str,
size: i64,
modified_iso: &str,
file_identity: &[u8],
) -> Result<(), String> {
create_placeholder(parent_dir, name, size, modified_iso, file_identity)
}
fn create_placeholder(
parent_dir: &Path,
name: &str,

View File

@ -27,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 ---
@ -923,11 +925,44 @@ async fn cloud_files_enable(
// Baum vom Server holen und Platzhalter anlegen
let entries = fetch_remote_entries(&server, &token).await?;
cloud_files::populate_placeholders(&mp, &entries)?;
// Hintergrund-Loop starten: poll Changes + upload lokaler Aenderungen
let cfg = cloud_files::sync_loop::SyncLoopConfig {
server_url: server.clone(),
access_token: token.clone(),
mount_point: mp.clone(),
poll_interval_secs: 30,
};
let handle = cloud_files::sync_loop::start(cfg);
// Filesystem-Watcher mit Callback; leitet geaenderte Dateien
// direkt an den Sync-Loop weiter.
let tx = handle.tx.clone();
let watcher = cloud_files::watcher::CallbackWatcher::new(&mp, move |path, kind| {
use notify::EventKind;
let relevant = matches!(kind, EventKind::Create(_) | EventKind::Modify(_));
if relevant {
let _ = tx.send(cloud_files::sync_loop::LoopMessage::LocalChange(path));
}
})
.map_err(|e| format!("watcher: {e}"))?;
*state.cloud_files_loop.lock().unwrap() = Some(handle);
*state.cloud_files_watcher.lock().unwrap() = Some(watcher);
Ok(())
}
#[tauri::command]
async fn cloud_files_disable(mount_point: String) -> Result<(), String> {
async fn cloud_files_disable(
state: State<'_, AppState>,
mount_point: String,
) -> Result<(), String> {
// Loop und Watcher stoppen
if let Some(handle) = state.cloud_files_loop.lock().unwrap().take() {
handle.stop_flag.store(true, std::sync::atomic::Ordering::Relaxed);
let _ = handle.tx.send(cloud_files::sync_loop::LoopMessage::Shutdown);
}
state.cloud_files_watcher.lock().unwrap().take();
cloud_files::unregister_sync_root(&PathBuf::from(mount_point))
}
@ -1025,6 +1060,8 @@ pub fn run() {
sync_paths: Mutex::new(Vec::new()),
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
background_started: AtomicBool::new(false),
cloud_files_loop: Mutex::new(None),
cloud_files_watcher: Mutex::new(None),
})
.on_window_event(|window, event| {
// Close button = minimize to tray instead of quit

View File

@ -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 &rarr; "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}