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:
parent
8f70b047d8
commit
d9a4ee6a0b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue