Compare commits

..

17 Commits

Author SHA1 Message Date
Stefan Hacker dd40c55f7d fix(cloud-files): Pin loest Hydration aus, Icon-Refresh via SHChangeNotify
CfSetPinState aendert nur das Pin-Flag - ohne expliziten Call
passiert am Disk-Inhalt nichts und das Explorer-Icon bleibt
unveraendert. Darum klickte "Immer offline verfuegbar" scheinbar
ins Leere.

- Bei Pin: CfHydratePlaceholder triggert FETCH_DATA und laedt die
  Datei komplett herunter
- Bei Unpin: CfDehydratePlaceholder (war schon da)
- Nach jeder Zustandsaenderung SHChangeNotify(SHCNE_UPDATEITEM)
  damit das Overlay-Icon sofort neu gezeichnet wird, ohne dass
  der User F5 druecken muss
- Log bekommt zusaetzlich hydrate_err fuer Debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 23:09:28 +02:00
Stefan Hacker 78615d8897 fix(cloud-files): Existierende normale Dateien vor Placeholder-Erstellung loeschen
Wenn der Client vorher aktiv war und dann deaktiviert wurde (oder
hart beendet), wandelt CfUnregisterSyncRoot alle Platzhalter in
normale Dateien um. Beim erneuten Aktivieren versuchte
populate_placeholders einen neuen Platzhalter anzulegen, was aber
mit ERROR_FILE_EXISTS scheiterte - der Fehler wurde zudem nur per
eprintln geloggt und verschluckt.

Ergebnis: die Datei blieb eine ganz normale Datei (kein Platzhalter,
kein Wolken-Icon). Spaeter fragt CfDehydratePlaceholder dann mit
HRESULT 0x80070178 "Die Datei ist keine Clouddatei", und "Speicher
freigeben" funktioniert nicht.

Jetzt prueft populate_placeholders vor jedem Create, ob die Datei
schon existiert und KEIN Platzhalter ist. Wenn ja: loeschen,
dann neu als Platzhalter anlegen. Erfolge und Fehler gehen beide
ins .minicloud-cloudfiles.log, damit man das Ergebnis prueft.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 22:56:58 +02:00
Stefan Hacker 3c340f9653 fix(cloud-files): Pin/Unpin tatsaechlich wirksam machen + CLI-Logging
set_pin_state hatte drei Probleme:
- FILE_READ_ATTRIBUTES: CfSetPinState braucht WRITE_ATTRIBUTES
- Kein OPEN_REPARSE_POINT: das Oeffnen selbst hat evtl. die
  Hydration getriggert, bevor wir unpinnen konnten
- Kein CfDehydratePlaceholder: Pin-Wechsel auf UNPINNED aendert
  nur das Flag, der Disk-Space wird nicht freigegeben

Jetzt:
- WRITE_ATTRIBUTES + OPEN_REPARSE_POINT beim Handle-Oeffnen
- Bei Unpin zusaetzlich CfDehydratePlaceholder, damit "Speicher
  freigeben" auch wirklich Platz freiraeumt
- Ergebnis + Fehler werden nach <parent>\.minicloud-cloudfiles.log
  geschrieben, damit wir sehen was passiert

handle_cli_shortcuts loggt nun nach %LOCALAPPDATA%\MiniCloud Sync\
cli.log, weil Explorer die stdout/stderr eines gestarteten Prozesses
verwirft. Ohne das Log kann man die vom Kontextmenue gestarteten
Aktionen nicht debuggen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 17:29:25 +02:00
Stefan Hacker 85dae4377f fix(cloud-files): AppliesTo-Syntax fuer Kontextmenue reparieren
Alter AppliesTo-Wert hatte:
- verdoppelte Backslashes (Windows AQS will einfache)
- einen verirrten Schluss-Backslash in der Quote, was die Query
  aufgebrochen hat

Neu:
- Saubere AQS-Syntax: System.ItemPathDisplay:~< "C:\\..." mit
  einfachen Backslashes (winreg schreibt REG_SZ 1:1)
- Registrierung unter AllFilesystemObjects statt *, damit auch
  Ordner den Menueeintrag erhalten
- Default-Wert (MUIVerb zusaetzlich) gesetzt, weil manche Windows-
  Versionen den Default fuer den Anzeigename nutzen
- uninstall entfernt beide Registry-Stellen (alte und neue)

Hinweis fuer Windows 11: klassische Shell-Verben stehen standard-
maessig nur unter "Weitere Optionen anzeigen" (Shift+F10). Fuer
das Haupt-Menue braeuchte man IExplorerCommand via COM-Extension.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 16:54:46 +02:00
Stefan Hacker 88c9617ae7 feat(client): Sync-Pfade und lokalen Dateibrowser bei aktivem Cloud-Files ausblenden
Wenn der Windows-Client mit Cloud-Files (OneDrive-Style) laeuft,
macht der klassische Sync-Pfade-Abschnitt samt lokalem .cloud-
Dateibrowser keinen Sinn mehr - Cloud-Files erzeugt Platzhalter
direkt im Explorer und bietet das gleiche On-Demand-Verhalten
mit nativer Shell-Integration.

Server-Dateien bleiben sichtbar (nuetzlich als Remote-Browser
unabhaengig vom Mount).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:47:43 +02:00
Stefan Hacker 78cfbf1ad3 feat(cloud-files): Geteilte Ordner + Rechtsklick-Menue
Backend:
- /api/sync/tree liefert jetzt {tree, shared} - shared enthaelt alle
  Dateien die MIT dem Benutzer geteilt wurden (FilePermission), nur
  Top-Level-Shares, mit Owner-Name im Anzeigenamen
- updated_at zusaetzlich als modified_at im Response fuer Client-
  Kompatibilitaet

Client:
- fetch_remote_entries merged Shared-Subtree unter virtuellem Ordner
  "Geteilt mit mir" (synthetische ID -1) in den Mount-Point
- modified_at faellt auf updated_at zurueck, falls nicht vorhanden

Kontextmenue:
- Neue HKCU-Registry-Eintraege fuer "Immer offline verfuegbar" und
  "Speicher freigeben", AppliesTo filtert auf Mount-Pfad, sodass die
  Verben nur bei Dateien unterhalb des Sync-Ordners erscheinen
- Aufruf der eigenen .exe mit --pin / --unpin <file>
- handle_cli_shortcuts fuehrt die Aktion aus und beendet sofort,
  ohne die UI/Tray/Single-Instance-Logik anzustossen

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 11:15:04 +02:00
Stefan Hacker 4026defe79 feat(cloud-files): Explorer-Sidebar-Integration fuer Windows
Registriert den Sync-Ordner als Shell-Namespace-Extension unter
HKEY_CURRENT_USER (kein Admin noetig), sodass er mit eigenem Icon
in der linken Leiste des Datei-Explorers erscheint - wie bei
OneDrive oder Dropbox.

- Neues Modul cloud_files::shell_integration mit install/uninstall
- Registry-Eintraege unter HKCU\Software\Classes\CLSID\{GUID} und
  HKCU\...\Explorer\Desktop\NameSpace\{GUID}
- Nutzt die laufende .exe als Icon-Quelle (fallback: imageres.dll)
- SHChangeNotify(SHCNE_ASSOCCHANGED) damit Explorer sofort aktualisiert
- install/uninstall werden aus register_sync_root/unregister aufgerufen
- winreg-Crate fuer sauberen Registry-Zugriff

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-22 15:47:05 +02:00
Stefan Hacker 2937082ba2 fix(cloud-files): Sauberes Re-Register + FETCH_PLACEHOLDERS-Stub + mehr Log
- CfUnregisterSyncRoot VOR CfRegisterSyncRoot, damit alte Policies
  (z.B. PARTIAL) nicht durch UPDATE-Flag kleben bleiben
- FETCH_PLACEHOLDERS-Stub registriert, der mit leerer Antwort und
  DISABLE_ON_DEMAND_POPULATION-Flag antwortet. Safety-Net falls
  Windows trotz FULL-Policy doch danach fragt
- log_msg an kritischen Stellen (register, connect, populate), damit
  wir beim naechsten Timeout sehen wo es haengt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:29:11 +02:00
Stefan Hacker e55ce106d4 fix(cloud-files): Population-Policy FULL statt PARTIAL
Mit PARTIAL erwartet Windows einen FETCH_PLACEHOLDERS-Callback
fuer die Ordnerenumeration. Den haben wir nicht registriert, also
lief der Explorer beim Oeffnen des Mount-Ordners in Timeout.

FULL bedeutet: wir fuellen alle Platzhalter selbst vor (machen wir
schon in populate_placeholders) und Windows fragt nicht nach.
Hydration bleibt PARTIAL - Datei-Inhalt wird weiter bei Zugriff
per FETCH_DATA geladen.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:42:44 +02:00
Stefan Hacker 601e0741b1 fix(cloud-files): Platzhalter nicht als lokale Aenderung hochladen + Logging
Ursache des "voll gesynct"-Problems: der notify-Watcher feuerte auf die
cfapi-Platzhalter, die wir selbst beim Aktivieren angelegt haben. Der
sync_loop hat die dann als lokale Aenderung hochgeladen, was implizit
die Hydration ausgeloest hat. Ergebnis: keine On-Demand-Platzhalter,
sondern voller Sync.

- is_cfapi_placeholder() prueft FILE_ATTRIBUTE_OFFLINE /
  RECALL_ON_DATA_ACCESS / RECALL_ON_OPEN - solche Dateien werden beim
  Upload uebersprungen
- Log-Datei liegt jetzt NEBEN dem Mount (nicht drin), damit sie nicht
  selbst als Cloud-Datei behandelt wird
- FETCH_DATA loggt jetzt auch Success, damit man sieht dass der
  Callback ueberhaupt feuert

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:42:00 +02:00
Stefan Hacker be121190b3 feat(cloud-files): Mount-Pfad persistieren + Force-Cleanup fuer tote Sync-Roots
- cloud_files_mount in AppConfig -> bleibt ueber Neustarts erhalten
- Beim Auto-Login wird Cloud-Files automatisch wieder aktiviert
- Neue Commands cloud_files_get_mount und cloud_files_force_cleanup
- UI zeigt "Aufraeumen"-Button wenn Mount gesetzt aber nicht aktiv,
  damit User einen Ordner der nach hartem Beenden des Clients als
  toter Sync-Root haengt wieder freigeben/loeschen kann

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:32:02 +02:00
Stefan Hacker 6274567219 fix(cloud-files): Timeout-Ursachen im FETCH_DATA-Callback beheben
- HTTP-Client bekommt 60s-Timeout (statt unendlich)
- Bei Send-/Netzwerkfehler wird CfExecute immer mit Failure-Status
  abgeschlossen, damit Explorer nicht ins OS-Timeout laeuft
- Wenn Server kein Range unterstuetzt (200 statt 206), wird aus dem
  Full-Body der angeforderte Bereich herausgeschnitten und die
  tatsaechliche Laenge an CfExecute uebergeben
- Fehler werden in <mount>\.minicloud-cloudfiles.log geschrieben,
  damit man das Problem bei Timeout ueberhaupt sehen kann

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:24:51 +02:00
Stefan Hacker 204dbb6ab5 fix(client): Cloud-Files-Sektion immer sichtbar, Hinweis bei nicht unterstuetzter Plattform
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:06:54 +02:00
Stefan Hacker d9a4ee6a0b 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>
2026-04-15 08:46:52 +02:00
Stefan Hacker 8f70b047d8 fix(client/windows): CfConnectSyncRoot liefert Key als Return-Value
In windows-rs 0.58 hat CfConnectSyncRoot nur 4 Argumente und liefert
den CF_CONNECTION_KEY direkt zurueck, keinen out-Parameter mehr.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:37:16 +02:00
Stefan Hacker f9bf53803f fix(client/windows): cfapi-Code auf windows-rs 0.58 umgestellt
- Feature Win32_System_CorrelationVector aktiviert (gate fuer
  CF_CALLBACK_INFO / CfExecute / CfConnectSyncRoot / CfCreatePlaceholders
  / CfSetPinState / CF_OPERATION_INFO / CF_CALLBACK_REGISTRATION)
- reqwest "blocking" aktiviert (wird im cfapi-Callback-Thread genutzt)
- Cf*-Funktionen liefern jetzt Result<(), Error> statt HRESULT; alle
  Aufrufe ueber ? / .map_err umgestellt
- CF_SYNC_POLICIES.Hydration/Population sind Wrapper-Structs;
  .Primary-Feld setzen statt direkter Enum-Zuweisung
- LARGE_INTEGER entfernt (Felder sind in 0.58 einfach i64)
- FILETIME-Ticks direkt als i64 schreiben (BasicInfo.*Time)
- FetchData.RequiredFileOffset/Length direkt als i64 verwenden
- CfCreatePlaceholders nimmt Slice + Option<*mut u32>
- CfSetPinState nimmt Option<*mut OVERLAPPED>
- Tauri-Command: MutexGuard vor .await freigeben (Send-Constraint)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:29:18 +02:00
Stefan Hacker de1039fc7d 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>
2026-04-14 16:19:22 +02:00
12 changed files with 1819 additions and 20 deletions
+63 -16
View File
@@ -1254,32 +1254,79 @@ def list_locks():
@api_bp.route('/sync/tree', methods=['GET'])
@token_required
def sync_tree():
"""Returns complete file tree with checksums for sync clients."""
"""Returns complete file tree with checksums for sync clients.
Includes both files owned by the user (under 'tree') and files
shared WITH the user (as a virtual 'Geteilt mit mir' folder under
'shared'). The client merges both.
"""
user = request.current_user
def _entry(f):
entry = {
'id': f.id,
'name': f.name,
'is_folder': f.is_folder,
'size': f.size,
'checksum': f.checksum,
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
'modified_at': f.updated_at.isoformat() if f.updated_at else None,
}
lock = FileLock.get_lock(f.id)
if lock:
entry['locked'] = True
entry['locked_by'] = lock.user.username
return entry
def _build_tree(parent_id):
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\
.order_by(File.is_folder.desc(), File.name).all()
result = []
for f in files:
entry = {
'id': f.id,
'name': f.name,
'is_folder': f.is_folder,
'size': f.size,
'checksum': f.checksum,
'updated_at': f.updated_at.isoformat() if f.updated_at else None,
}
lock = FileLock.get_lock(f.id)
if lock:
entry['locked'] = True
entry['locked_by'] = lock.user.username
e = _entry(f)
if f.is_folder:
entry['children'] = _build_tree(f.id)
result.append(entry)
e['children'] = _build_tree(f.id)
result.append(e)
return result
return jsonify({'tree': _build_tree(None)}), 200
def _build_shared_children(parent_id):
files = File.query.filter_by(parent_id=parent_id, is_trashed=False)\
.order_by(File.is_folder.desc(), File.name).all()
out = []
for f in files:
e = _entry(f)
if f.is_folder:
e['children'] = _build_shared_children(f.id)
out.append(e)
return out
shared_perms = FilePermission.query.filter_by(user_id=user.id).all()
shared_roots = []
seen = set()
for perm in shared_perms:
f = perm.file
if not f or f.is_trashed or f.id in seen:
continue
seen.add(f.id)
# Nur "Top-Level"-Shares: wenn der Eltern-Ordner NICHT auch geteilt
# ist, ist dieses Item die Wurzel des Shares beim Empfaenger.
parent_shared = any(
p.file_id == f.parent_id for p in shared_perms
) if f.parent_id else False
if parent_shared:
continue
e = _entry(f)
owner = f.owner.display_name if hasattr(f, 'owner') and f.owner else None
if owner:
e['name'] = f'{f.name} (von {owner})'
if f.is_folder:
e['children'] = _build_shared_children(f.id)
shared_roots.append(e)
return jsonify({
'tree': _build_tree(None),
'shared': shared_roots,
}), 200
@api_bp.route('/sync/events', methods=['GET'])
+70
View File
@@ -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 -1
View File
@@ -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,30 @@ 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",
"Win32_System_Registry",
] }
widestring = "1"
winreg = "0.52"
# 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,121 @@
//! 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(windows)]
pub mod shell_integration;
#[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,206 @@
//! Explorer-Sidebar-Integration fuer Windows (ohne Admin-Rechte).
//!
//! Registriert den Sync-Ordner als Shell-Namespace-Extension unter
//! HKEY_CURRENT_USER, sodass er mit eigenem Icon in der Navigation
//! des Datei-Explorers erscheint (wie OneDrive/Dropbox).
//!
//! Anders als die eigentliche Cloud Files API ist das reine Registry-
//! Kosmetik - der Ordner funktioniert auch ohne Sidebar-Eintrag,
//! nur sieht man ihn dann nicht in der linken Leiste.
#![cfg(windows)]
use std::path::Path;
use winreg::enums::*;
use winreg::RegKey;
// Stabile GUID fuer Mini-Cloud - gleiche wie in windows.rs als ProviderId.
const CLSID_GUID: &str = "{4D696E69-436C-6F75-6444-7566667944AB}";
// Standard-CLSID fuer "Generic Shell Folder Implementation".
const SHELL_FOLDER_CLSID: &str = "{0E5AAE11-A475-4c5b-AB00-C66DE400274E}";
/// Registriere den Mount-Ordner in der Explorer-Navigation.
/// `icon_source`: Pfad zu ICO oder EXE mit Icon-Index (z.B. "C:\\app.exe,0")
pub fn install(
display_name: &str,
mount_point: &Path,
icon_source: &str,
) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
// 1) CLSID-Eintrag unter Software\Classes\CLSID\{GUID}
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
let (clsid, _) = hkcu
.create_subkey(&clsid_path)
.map_err(|e| format!("create CLSID: {e}"))?;
clsid
.set_value("", &display_name.to_string())
.map_err(|e| format!("set displayname: {e}"))?;
clsid
.set_value("System.IsPinnedToNameSpaceTree", &1u32)
.map_err(|e| format!("set pinned: {e}"))?;
clsid
.set_value("SortOrderIndex", &0x42u32)
.map_err(|e| format!("set sortorder: {e}"))?;
// 2) DefaultIcon
let (icon_key, _) = clsid
.create_subkey("DefaultIcon")
.map_err(|e| format!("create DefaultIcon: {e}"))?;
icon_key
.set_value("", &icon_source.to_string())
.map_err(|e| format!("set icon: {e}"))?;
// 3) InProcServer32 -> shell32.dll (Standard Shell-Folder-Host)
let (inproc, _) = clsid
.create_subkey("InProcServer32")
.map_err(|e| format!("create InProcServer32: {e}"))?;
inproc
.set_value("", &"%SystemRoot%\\system32\\shell32.dll".to_string())
.map_err(|e| format!("set shell32: {e}"))?;
inproc
.set_value("ThreadingModel", &"Both".to_string())
.map_err(|e| format!("set threading: {e}"))?;
// 4) Instance -> zeigt auf generischen Shell-Folder
let (instance, _) = clsid
.create_subkey("Instance")
.map_err(|e| format!("create Instance: {e}"))?;
instance
.set_value("CLSID", &SHELL_FOLDER_CLSID.to_string())
.map_err(|e| format!("set inst clsid: {e}"))?;
let (pb, _) = instance
.create_subkey("InitPropertyBag")
.map_err(|e| format!("create InitPropertyBag: {e}"))?;
pb.set_value("Attributes", &0x11u32)
.map_err(|e| format!("set attrs pb: {e}"))?;
pb.set_value(
"TargetFolderPath",
&mount_point.to_string_lossy().into_owned(),
)
.map_err(|e| format!("set target: {e}"))?;
// 5) ShellFolder-Flags
let (sf, _) = clsid
.create_subkey("ShellFolder")
.map_err(|e| format!("create ShellFolder: {e}"))?;
sf.set_value("FolderValueFlags", &0x28u32)
.map_err(|e| format!("set folderflags: {e}"))?;
sf.set_value("Attributes", &0xF080004Du32)
.map_err(|e| format!("set attrs sf: {e}"))?;
// 6) In die Navigation einhaengen
let ns_path = format!(
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Desktop\\NameSpace\\{}",
CLSID_GUID
);
let (ns, _) = hkcu
.create_subkey(&ns_path)
.map_err(|e| format!("create NameSpace: {e}"))?;
ns.set_value("", &display_name.to_string())
.map_err(|e| format!("set ns name: {e}"))?;
// 7) Kontext-Menue-Verben (Rechtsklick) fuer Dateien unter dem Mount
install_context_menu(mount_point)?;
// 8) Explorer informieren (SHChangeNotify)
notify_shell();
Ok(())
}
/// Registriert "Immer offline halten" / "Speicher freigeben" als
/// Rechtsklick-Menuepunkte, die nur fuer Dateien unterhalb des Mounts
/// angezeigt werden (AppliesTo-Filter).
fn install_context_menu(mount_point: &Path) -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let exe = std::env::current_exe()
.map_err(|e| format!("current_exe: {e}"))?
.to_string_lossy()
.into_owned();
// Trailing Backslash wegstrippen, dann eine saubere AQS-Query bauen.
// Registry-Werte sind normale Strings; Backslashes bleiben einfach.
let mount_clean = mount_point
.to_string_lossy()
.trim_end_matches('\\')
.to_string();
// AppliesTo: nur Dateien, deren Pfad mit dem Mount-Ordner beginnt.
let applies_to = format!("System.ItemPathDisplay:~< \"{}\"", mount_clean);
for (verb, label, flag) in [
("MiniCloudPin", "Immer offline verfuegbar", "--pin"),
("MiniCloudUnpin", "Speicher freigeben", "--unpin"),
] {
// Unter AllFilesystemObjects statt * - das greift auch fuer
// Ordner und vermeidet Konflikte mit Dateityp-spezifischen Verben.
let key_path = format!("Software\\Classes\\AllFilesystemObjects\\shell\\{}", verb);
let (k, _) = hkcu
.create_subkey(&key_path)
.map_err(|e| format!("verb {verb}: {e}"))?;
k.set_value("", &label.to_string())
.map_err(|e| format!("default: {e}"))?;
k.set_value("MUIVerb", &label.to_string())
.map_err(|e| format!("MUIVerb: {e}"))?;
k.set_value("AppliesTo", &applies_to)
.map_err(|e| format!("AppliesTo: {e}"))?;
k.set_value("Icon", &exe)
.map_err(|e| format!("Icon: {e}"))?;
let (cmd, _) = k
.create_subkey("command")
.map_err(|e| format!("cmd: {e}"))?;
cmd.set_value("", &format!("\"{}\" {} \"%1\"", exe, flag))
.map_err(|e| format!("cmdline: {e}"))?;
}
Ok(())
}
fn uninstall_context_menu() {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
for verb in ["MiniCloudPin", "MiniCloudUnpin"] {
// alte (falsche) Stelle ebenfalls aufraeumen
let _ = hkcu.delete_subkey_all(format!("Software\\Classes\\*\\shell\\{}", verb));
let _ = hkcu.delete_subkey_all(format!(
"Software\\Classes\\AllFilesystemObjects\\shell\\{}",
verb
));
}
}
/// Entferne die Shell-Integration wieder.
pub fn uninstall() -> Result<(), String> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let ns_path = format!(
"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Desktop\\NameSpace\\{}",
CLSID_GUID
);
let _ = hkcu.delete_subkey_all(&ns_path);
let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID);
let _ = hkcu.delete_subkey_all(&clsid_path);
uninstall_context_menu();
notify_shell();
Ok(())
}
/// Teilt Explorer mit, dass sich die Shell-Namespace-Liste geaendert hat.
/// Ohne das sieht man den neuen Eintrag erst nach Explorer-Neustart.
fn notify_shell() {
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_ASSOCCHANGED, SHCNF_IDLIST};
unsafe {
SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, None, None);
}
}
/// Standard-Icon-Quelle: die laufende .exe mit Index 0.
pub fn default_icon_source() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(|s| format!("{},0", s)))
.unwrap_or_else(|| "%SystemRoot%\\system32\\imageres.dll,2".to_string())
}
@@ -0,0 +1,221 @@
//! 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(());
}
// cfapi-Platzhalter oder gerade hydrierende Dateien NICHT hochladen -
// sonst wird jede Wolken-Datei sofort komplett gesynct und wir haben
// keinen On-Demand-Modus mehr.
#[cfg(windows)]
{
if super::windows::is_cfapi_placeholder(path) {
super::windows::log_msg(
&cfg.mount_point,
&format!("skip upload (placeholder): {}", path.display()),
);
return Ok(());
}
}
// Eigene Log-Datei nicht mit hochladen.
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with(".minicloud-"))
.unwrap_or(false)
{
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,639 @@
//! 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();
// Hydration PARTIAL = Datei-Inhalt kommt bei Zugriff per FETCH_DATA.
// Population FULL = Ordnerinhalt ist komplett vorgefuellt durch uns
// (populate_placeholders). So muss Windows NICHT FETCH_PLACEHOLDERS
// callen, den wir nicht implementieren - sonst timeout beim Oeffnen.
policies.Hydration.Primary = CF::CF_HYDRATION_POLICY_PARTIAL;
policies.Population.Primary = CF::CF_POPULATION_POLICY_FULL;
// Holder fuer displayname, damit wir ihn spaeter ggf. in ein eigenes
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
let _ = display_wide;
// Erst eine eventuell vorhandene Registrierung wegraeumen. Sonst
// uebernimmt UPDATE nur einen Teil der Policies und alte PARTIAL-
// Population-Einstellungen bleiben aktiv -> Explorer-Timeout.
unsafe {
let _ = CF::CfUnregisterSyncRoot(PCWSTR(path_wide.as_ptr()));
}
log_msg(mount_point, &format!(
"register_sync_root path={} provider={} account={}",
mount_point.display(), provider_name, account_id
));
unsafe {
if let Err(e) = CF::CfRegisterSyncRoot(
PCWSTR(path_wide.as_ptr()),
&info,
&policies,
CF::CF_REGISTER_FLAG_NONE,
) {
log_err(mount_point, &format!("CfRegisterSyncRoot FAILED: {e:?}"));
// Als Fallback mit UPDATE-Flag
CF::CfRegisterSyncRoot(
PCWSTR(path_wide.as_ptr()),
&info,
&policies,
CF::CF_REGISTER_FLAG_UPDATE,
)
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
}
}
log_msg(mount_point, "CfRegisterSyncRoot OK");
connect_callbacks(mount_point)?;
log_msg(mount_point, "callbacks connected");
// Explorer-Sidebar-Eintrag mit Wolken-Icon
let icon = super::shell_integration::default_icon_source();
match super::shell_integration::install(provider_name, mount_point, &icon) {
Ok(()) => log_msg(mount_point, "shell integration installed"),
Err(e) => log_err(mount_point, &format!("shell integration FAILED: {e}")),
}
Ok(())
}
pub fn unregister_sync_root(mount_point: &PathBuf) -> Result<(), String> {
// Shell-Eintrag zuerst entfernen (schlaegt nie fehl).
let _ = super::shell_integration::uninstall();
let _ = 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 = &params.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 || {
log_msg(&ctx.mount_point, &format!(
"FETCH_DATA file_id={file_id} offset={offset} len={length}"
));
match transfer_range(connection_key, transfer_key, file_id, offset, length, &ctx) {
Ok(()) => log_msg(&ctx.mount_point, &format!(
"fetch file_id={file_id} OK"
)),
Err(e) => {
log_err(&ctx.mount_point, &format!(
"fetch file_id={file_id} offset={offset} len={length} FAILED: {e}"
));
// Garantiert Fehler-Completion, damit Windows nicht in Timeout laeuft.
let _ = complete_transfer(connection_key, transfer_key, None, offset, length);
}
}
});
}
pub fn log_msg(mount: &Path, msg: &str) {
use std::io::Write;
// Log-Datei NEBEN den Mount, damit sie nicht selbst als Platzhalter
// behandelt wird.
let log = mount
.parent()
.map(|p| p.join(".minicloud-cloudfiles.log"))
.unwrap_or_else(|| PathBuf::from(".minicloud-cloudfiles.log"));
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&log) {
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
}
}
fn log_err(mount: &Path, msg: &str) {
log_msg(mount, msg);
}
/// True wenn die Datei ein cfapi-Platzhalter ist (noch nicht hydriert)
/// oder gerade vom Cloud-Filter verwaltet wird. Fuer solche Dateien
/// duerfen wir KEINEN Upload ausloesen, sonst verwandelt der Sync-Loop
/// jeden Platzhalter sofort in eine vollstaendig lokale Datei.
pub fn is_cfapi_placeholder(path: &Path) -> bool {
use windows::Win32::Storage::FileSystem::GetFileAttributesW;
let Ok(w) = U16CString::from_str(path.to_string_lossy().as_ref()) else {
return false;
};
let attrs = unsafe { GetFileAttributesW(PCWSTR(w.as_ptr())) };
if attrs == u32::MAX {
return false;
}
// FILE_ATTRIBUTE_OFFLINE (0x1000) oder
// FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS (0x400000) oder
// FILE_ATTRIBUTE_RECALL_ON_OPEN (0x40000)
(attrs & 0x0040_1000) != 0 || (attrs & 0x0004_0000) != 0
}
fn transfer_range(
connection_key: CF::CF_CONNECTION_KEY,
transfer_key: i64,
file_id: i64,
offset: i64,
length: u64,
ctx: &CloudContext,
) -> Result<(), String> {
if ctx.server_url.is_empty() || ctx.access_token.is_empty() {
return Err("CloudContext nicht gesetzt (Server/Token leer)".into());
}
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("client: {e}"))?;
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!("send: {e}"))?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 206 {
return Err(format!("HTTP {}", status));
}
let bytes = resp.bytes().map_err(|e: reqwest::Error| e.to_string())?;
// Wenn Server kein Range unterstuetzt und volle Datei liefert,
// aus dem Body den angeforderten Bereich ausschneiden.
let slice: &[u8] = if status.as_u16() == 206 {
&bytes[..]
} else {
let start = offset as usize;
let end = (start + length as usize).min(bytes.len());
if start >= bytes.len() {
&[]
} else {
&bytes[start..end]
}
};
complete_transfer(connection_key, transfer_key, Some(slice), offset, slice.len() as u64)
}
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(())
}
unsafe extern "system" fn on_fetch_placeholders(
info: *const CF::CF_CALLBACK_INFO,
_params: *const CF::CF_CALLBACK_PARAMETERS,
) {
// Safety-Net: wir populieren schon ueber populate_placeholders,
// aber falls Windows trotzdem ruft, geben wir leere Antwort.
let info = &*info;
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_PLACEHOLDERS;
op_info.ConnectionKey = info.ConnectionKey;
op_info.TransferKey = info.TransferKey;
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.TransferPlaceholders;
transfer.CompletionStatus = windows::Win32::Foundation::NTSTATUS(0);
transfer.PlaceholderTotalCount = 0;
transfer.PlaceholderArray = std::ptr::null_mut();
transfer.PlaceholderCount = 0;
transfer.EntriesProcessed = 0;
transfer.Flags = CF::CF_OPERATION_TRANSFER_PLACEHOLDERS_FLAG_DISABLE_ON_DEMAND_POPULATION;
let _ = CF::CfExecute(&op_info, &mut params);
}
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 {
Type: CF::CF_CALLBACK_TYPE_FETCH_PLACEHOLDERS,
Callback: Some(on_fetch_placeholders),
},
// 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;
log_msg(mount_point, &format!(
"populate_placeholders: {} Eintraege", entries.len()
));
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. Existierende "normale" Dateien
// (z.B. nach vorherigem CfUnregisterSyncRoot) vorher loeschen,
// weil CfCreatePlaceholders sonst mit ERROR_FILE_EXISTS scheitert
// und die Datei nie zum Platzhalter wird -> spaeter koennte man
// sie nicht mehr dehydrieren (0x80070178 "keine Clouddatei").
for e in entries.iter().filter(|e| !e.is_folder) {
let rel = rel_path(e, &by_id);
let full = mount_point.join(&rel);
let parent = rel
.parent()
.map(|p| mount_point.join(p))
.unwrap_or_else(|| mount_point.clone());
let identity = e.id.to_string();
if full.exists() && !is_cfapi_placeholder(&full) {
log_msg(mount_point, &format!(
"deleting non-placeholder {} to recreate",
full.display()
));
if let Err(err) = std::fs::remove_file(&full) {
log_err(mount_point, &format!(
"remove {} failed: {err}", full.display()
));
}
}
match create_placeholder(&parent, &e.name, e.size, &e.modified_at, identity.as_bytes()) {
Ok(()) => log_msg(mount_point, &format!("placeholder created: {}", full.display())),
Err(err) => log_err(mount_point, &format!(
"placeholder {} FAILED: {err}", full.display()
)),
}
}
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_FLAG_OPEN_REPARSE_POINT,
FILE_WRITE_ATTRIBUTES, FILE_READ_ATTRIBUTES,
FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_SHARE_DELETE, OPEN_EXISTING,
};
let path_wide = U16CString::from_str(file.to_string_lossy().as_ref())
.map_err(|e| e.to_string())?;
// CfSetPinState / CfDehydratePlaceholder brauchen WRITE_ATTRIBUTES.
// OPEN_REPARSE_POINT verhindert, dass das Oeffnen selbst eine
// Hydration ausloest (sonst waere Unpin bedeutungslos).
let handle = unsafe {
CreateFileW(
PCWSTR(path_wide.as_ptr()),
(FILE_READ_ATTRIBUTES | FILE_WRITE_ATTRIBUTES).0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
None,
OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
None,
)
}
.map_err(|e| format!("open: {e}"))?;
let state = if pinned {
CF::CF_PIN_STATE_PINNED
} else {
CF::CF_PIN_STATE_UNPINNED
};
let set_res = unsafe {
CF::CfSetPinState(handle, state, CF::CF_SET_PIN_FLAG_NONE, None)
};
// Hydrate bei Pin / Dehydrate bei Unpin. CfSetPinState aendert nur
// das Flag - ohne explizite Hydrate-/Dehydrate-Calls passiert am
// Disk-Inhalt und am Icon nichts Sichtbares.
let (hydrate_err, dehydrate_err) = if set_res.is_ok() {
if pinned {
let r = unsafe {
CF::CfHydratePlaceholder(
handle,
0,
-1,
CF::CF_HYDRATE_FLAG_NONE,
None,
)
};
(r.err().map(|e| format!("{:?}", e)), None)
} else {
let r = unsafe {
CF::CfDehydratePlaceholder(
handle,
0,
-1,
CF::CF_DEHYDRATE_FLAG_NONE,
None,
)
};
(None, r.err().map(|e| format!("{:?}", e)))
}
} else {
(None, None)
};
unsafe {
let _ = windows::Win32::Foundation::CloseHandle(handle);
}
// Explorer Icon-Overlay aktualisieren
notify_file_update(file);
// Log-Verzeichnis ist der Mount-Ordner oder dessen Parent
let log_dir = file
.ancestors()
.find(|p| p.parent().is_some())
.map(|p| p.to_path_buf())
.unwrap_or_else(|| file.to_path_buf());
log_msg(
&log_dir,
&format!(
"set_pin_state file={} pinned={} result={:?} hydrate_err={:?} dehydrate_err={:?}",
file.display(),
pinned,
set_res,
hydrate_err,
dehydrate_err
),
);
set_res.map_err(|e| format!("CfSetPinState: {e}"))?;
Ok(())
}
/// Sagt dem Shell "diese Datei hat sich geaendert" damit das Overlay-
/// Icon (Wolke/Haken) aktualisiert wird, ohne dass der User F5 druecken
/// muss.
fn notify_file_update(file: &Path) {
use windows::Win32::UI::Shell::{SHChangeNotify, SHCNE_UPDATEITEM, SHCNF_PATHW};
let Ok(w) = U16CString::from_str(file.to_string_lossy().as_ref()) else {
return;
};
unsafe {
SHChangeNotify(
SHCNE_UPDATEITEM,
SHCNF_PATHW,
Some(w.as_ptr() as _),
None,
);
}
}
+276
View File
@@ -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,8 +887,272 @@ 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);
// Mount-Pfad persistieren, damit er beim Neustart wiederkommt.
let mut cfg = AppConfig::load();
cfg.cloud_files_mount = mount_point.clone();
let _ = cfg.save();
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();
let result = cloud_files::unregister_sync_root(&PathBuf::from(&mount_point));
// Auch bei Fehler Mount aus Config loeschen, damit der Client nicht
// endlos versucht, einen toten Pfad wiederherzustellen.
let mut cfg = AppConfig::load();
cfg.cloud_files_mount.clear();
let _ = cfg.save();
result
}
#[tauri::command]
fn cloud_files_get_mount() -> String {
AppConfig::load().cloud_files_mount
}
/// Notfall-Aufraeumen: Ordner als Sync-Root deregistrieren, auch wenn
/// kein Callback-Handle existiert. Nuetzlich wenn der Client hart beendet
/// wurde und ein "toter" Ordner in Windows haengt.
#[tauri::command]
async fn cloud_files_force_cleanup(mount_point: String) -> Result<(), String> {
let mp = PathBuf::from(&mount_point);
let _ = cloud_files::unregister_sync_root(&mp);
let mut cfg = AppConfig::load();
cfg.cloud_files_mount.clear();
let _ = cfg.save();
Ok(())
}
#[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();
let shared = json
.get("shared")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
// Rekursiv flach machen (Struktur parent_id beibehalten).
// modified_at akzeptiert beides: das neue "modified_at" oder das
// alte "updated_at" als Fallback.
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())
.or_else(|| n.get("updated_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);
// Virtueller Ordner "Geteilt mit mir" nur dann, wenn es geteilte
// Dateien gibt. ID -1 ist reserviert dafuer (keine Kollision
// mit echten DB-IDs).
if !shared.is_empty() {
flat.push(cloud_files::RemoteEntry {
id: -1,
name: "Geteilt mit mir".to_string(),
parent_id: None,
is_folder: true,
size: 0,
modified_at: String::new(),
checksum: None,
});
walk(&shared, Some(-1), &mut flat);
}
Ok(flat)
}
/// Short-circuit fuer Shell-Kontextmenue-Aufrufe:
/// `minicloud-sync --pin <file>` oder `--unpin <file>` fuehrt die
/// Aktion direkt aus und beendet. Kein UI, kein Tray.
/// Logs landen in %LOCALAPPDATA%\MiniCloud Sync\cli.log - sonst
/// wuerden wir vom Explorer gestartete Prozesse nie debuggen koennen.
#[cfg(windows)]
fn handle_cli_shortcuts() {
use std::io::Write;
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
return;
}
let cmd = args[1].as_str();
if cmd != "--pin" && cmd != "--unpin" {
return;
}
let path = std::path::PathBuf::from(&args[2]);
let log_path = dirs::data_local_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join("MiniCloud Sync")
.join("cli.log");
if let Some(p) = log_path.parent() {
let _ = std::fs::create_dir_all(p);
}
let log = |msg: &str| {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = writeln!(f, "[{}] {}", chrono::Utc::now().to_rfc3339(), msg);
}
};
log(&format!("CLI invoked: {} {}", cmd, path.display()));
let result = match cmd {
"--pin" => cloud_files::pin_file(&path),
"--unpin" => cloud_files::unpin_file(&path),
_ => unreachable!(),
};
match &result {
Ok(()) => log(&format!("{cmd} OK: {}", path.display())),
Err(e) => log(&format!("{cmd} FAILED: {e}")),
}
std::process::exit(if result.is_ok() { 0 } else { 1 });
}
#[cfg(not(windows))]
fn handle_cli_shortcuts() {}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
handle_cli_shortcuts();
handle_single_instance();
tauri::Builder::default()
@@ -902,6 +1169,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 +1285,13 @@ pub fn run() {
browse_sync_folder,
mark_offline,
unmark_offline,
cloud_files_supported,
cloud_files_enable,
cloud_files_disable,
cloud_files_get_mount,
cloud_files_force_cleanup,
cloud_files_pin,
cloud_files_unpin,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
@@ -13,6 +13,10 @@ pub struct AppConfig {
pub auto_start: bool,
#[serde(default)]
pub start_minimized: bool,
/// Persistierter Mount-Punkt der Cloud-Files-Integration.
/// Leer = nicht aktiv. Wird beim App-Start wieder aktiviert.
#[serde(default)]
pub cloud_files_mount: String,
}
impl AppConfig {
+123 -3
View File
@@ -31,6 +31,75 @@ 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; }
try {
const saved = await invoke("cloud_files_get_mount");
if (saved) cloudFilesMountPoint.value = saved;
} catch { /* no saved mount */ }
}
async function forceCleanupCloudFiles() {
if (!cloudFilesMountPoint.value) return;
if (!confirm(`Sync-Root unter ${cloudFilesMountPoint.value} zwangsweise aufraeumen?\n\nDanach kann der Ordner ggf. geloescht werden.`)) return;
cloudFilesError.value = "";
cloudFilesBusy.value = true;
try {
await invoke("cloud_files_force_cleanup", { mountPoint: cloudFilesMountPoint.value });
cloudFilesActive.value = false;
cloudFilesMountPoint.value = "";
syncLog.value = [`[${ts()}] Cloud-Files Zwangsbereinigung durchgefuehrt`, ...syncLog.value].slice(0, 200);
} catch (err) {
cloudFilesError.value = String(err);
} finally {
cloudFilesBusy.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 +358,7 @@ function formatSize(b) {
}
onMounted(async () => {
await checkCloudFilesSupport();
// Try auto-login with saved credentials
try {
const saved = await invoke("load_saved_config");
@@ -308,6 +378,15 @@ onMounted(async () => {
if (syncPaths.value.length > 0) {
await startSync();
}
// Cloud-Files automatisch reaktivieren, wenn Mount gespeichert.
if (cloudFilesSupported.value && cloudFilesMountPoint.value) {
try {
await invoke("cloud_files_enable", { mountPoint: cloudFilesMountPoint.value });
cloudFilesActive.value = true;
} catch (e) {
cloudFilesError.value = `Auto-Reaktivierung fehlgeschlagen: ${e}`;
}
}
} catch (err) {
syncStatus.value = "Auto-Login fehlgeschlagen";
// Show login screen with pre-filled fields
@@ -387,8 +466,47 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
</div>
<div class="content">
<!-- Sync Paths -->
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
<div class="section">
<div class="section-header">
<h3>Cloud-Files (OneDrive-Style)</h3>
<span v-if="cloudFilesActive" class="status-badge syncing">☁ aktiv</span>
<span v-else-if="!cloudFilesSupported" class="status-badge error">nicht verfuegbar</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>
<p v-if="!cloudFilesSupported" class="hint" style="color:#c62828">
Auf dieser Plattform noch nicht verfuegbar. Aktuell: Windows 10/11.
Linux-FUSE ist in Vorbereitung, macOS folgt mit Apple-Signatur.
</p>
<template v-else>
<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>
<button v-if="cloudFilesMountPoint && !cloudFilesActive"
class="btn-secondary" :disabled="cloudFilesBusy"
@click="forceCleanupCloudFiles"
title="Toten Sync-Root nach hartem Beenden des Clients aufraeumen">
Aufraeumen
</button>
</div>
<div v-if="cloudFilesError" class="error" style="margin-top:0.5rem">{{ cloudFilesError }}</div>
</template>
</div>
<!-- Sync Paths (Legacy) - auf Windows ausgeblendet sobald Cloud-Files
aktiv ist; Cloud-Files ersetzt diese Ansicht vollstaendig. -->
<div v-if="!cloudFilesActive" class="section">
<div class="section-header">
<h3>Sync-Pfade</h3>
<div class="header-btns">
@@ -454,8 +572,8 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
</div>
</div>
<!-- Local File Browser -->
<div v-if="autoSyncActive" class="section" @click="hideContextMenu">
<!-- Local File Browser (Legacy, nur fuer Full-Sync-Modus) -->
<div v-if="autoSyncActive && !cloudFilesActive" class="section" @click="hideContextMenu">
<div class="section-header">
<h3>Lokale Dateien</h3>
<button @click="loadLocalFiles(null)" class="btn-small">↻</button>
@@ -604,6 +722,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}