Compare commits
17 Commits
2610e3b183
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd40c55f7d | |||
| 78615d8897 | |||
| 3c340f9653 | |||
| 85dae4377f | |||
| 88c9617ae7 | |||
| 78cfbf1ad3 | |||
| 4026defe79 | |||
| 2937082ba2 | |||
| e55ce106d4 | |||
| 601e0741b1 | |||
| be121190b3 | |||
| 6274567219 | |||
| 204dbb6ab5 | |||
| d9a4ee6a0b | |||
| 8f70b047d8 | |||
| f9bf53803f | |||
| de1039fc7d |
+63
-16
@@ -1254,32 +1254,79 @@ def list_locks():
|
|||||||
@api_bp.route('/sync/tree', methods=['GET'])
|
@api_bp.route('/sync/tree', methods=['GET'])
|
||||||
@token_required
|
@token_required
|
||||||
def sync_tree():
|
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
|
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):
|
def _build_tree(parent_id):
|
||||||
files = File.query.filter_by(owner_id=user.id, parent_id=parent_id, is_trashed=False)\
|
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()
|
.order_by(File.is_folder.desc(), File.name).all()
|
||||||
result = []
|
result = []
|
||||||
for f in files:
|
for f in files:
|
||||||
entry = {
|
e = _entry(f)
|
||||||
'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
|
|
||||||
if f.is_folder:
|
if f.is_folder:
|
||||||
entry['children'] = _build_tree(f.id)
|
e['children'] = _build_tree(f.id)
|
||||||
result.append(entry)
|
result.append(e)
|
||||||
return result
|
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'])
|
@api_bp.route('/sync/events', methods=['GET'])
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Native File-Provider-Integration (Platzhalter-Modus)
|
||||||
|
|
||||||
|
Zusaetzlich zum klassischen "alles-kopieren"-Sync bietet der Desktop-Client
|
||||||
|
einen **OneDrive-aehnlichen Platzhalter-Modus**: Dateien erscheinen im
|
||||||
|
Dateimanager als kleine Metadata-Dateien (Platzhalter) und werden erst
|
||||||
|
bei Doppelklick vom Server geladen.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Plattform | Status | Technologie |
|
||||||
|
| --------- | --------- | ------------------------------------ |
|
||||||
|
| Windows | **MVP** | Cloud Files API (`cfapi.dll`) |
|
||||||
|
| Linux | Skelett | FUSE (libfuse3) - feature `linux_fuse` |
|
||||||
|
| macOS | Geplant | `NSFileProviderExtension` + Signatur |
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Windows 10 1709 (Build 16299) oder neuer
|
||||||
|
- Der Client laeuft als regulaerer Benutzerprozess (keine Admin-Rechte noetig)
|
||||||
|
|
||||||
|
### Was funktioniert
|
||||||
|
|
||||||
|
- `CfRegisterSyncRoot` registriert einen Ordner als Sync-Root, der Explorer
|
||||||
|
zeigt Wolken-Overlay-Icons an.
|
||||||
|
- `CfCreatePlaceholders` legt fuer jede Mini-Cloud-Datei einen Platzhalter
|
||||||
|
mit korrekter Groesse und Aenderungszeit an.
|
||||||
|
- `FETCH_DATA`-Callback laedt per Range-Request vom Server, sobald der
|
||||||
|
Explorer Dateidaten anfordert (z.B. beim Oeffnen).
|
||||||
|
- `CfSetPinState` erlaubt manuelles "Immer offline halten" / "Nur in Cloud".
|
||||||
|
|
||||||
|
### Was noch fehlt
|
||||||
|
|
||||||
|
- Upload-Callback (`NOTIFY_FILE_CLOSE_COMPLETION`) fuer lokal geaenderte Dateien
|
||||||
|
- Context-Menue "Ein-/Auschecken" via Shell-Extension
|
||||||
|
- Delta-Updates (neue/geloeschte Dateien auf dem Server -> lokale Placeholder)
|
||||||
|
- Konflikt-Aufloesung
|
||||||
|
|
||||||
|
### Einschalten
|
||||||
|
|
||||||
|
Im Client-UI den Schalter **"Cloud-Files-Modus"** aktivieren (ruft intern
|
||||||
|
`cloud_files_enable`-Command auf). Alternativ per Kommandozeile beim Build:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Aus clients/desktop/src-tauri:
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows-Targets brauchen das Windows-SDK (uebersetzt aber sauber mit
|
||||||
|
cross-compile via `cargo xwin` aus Linux, wenn `build.sh windows` laeuft).
|
||||||
|
|
||||||
|
## Linux
|
||||||
|
|
||||||
|
FUSE-Provider ist optional und mit einem Feature-Flag versehen, damit
|
||||||
|
normale Linux-Builds nicht `libfuse3-dev` voraussetzen:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --features linux_fuse
|
||||||
|
```
|
||||||
|
|
||||||
|
Overlay-Icons im Dateimanager (Nautilus / Dolphin / Caja) brauchen
|
||||||
|
zusaetzlich eine native Extension pro DE - folgt in einem spaeteren
|
||||||
|
Commit.
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
Braucht eine Apple Developer ID + Notarization, da `NSFileProviderExtension`
|
||||||
|
sonst vom Finder nicht geladen wird. Wird angegangen, sobald ein
|
||||||
|
Apple-Dev-Zugang verfuegbar ist.
|
||||||
@@ -19,7 +19,7 @@ tauri-plugin-dialog = "2"
|
|||||||
tauri-plugin-notification = "2"
|
tauri-plugin-notification = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
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"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
notify = "7"
|
notify = "7"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
@@ -28,3 +28,30 @@ rusqlite = { version = "0.34", features = ["bundled"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
open = "5"
|
open = "5"
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
|
# Plattform-spezifische File-Provider-Integration (OneDrive-artig).
|
||||||
|
# Nur auf Windows gegen die Cloud Files API (cfapi.dll) linken.
|
||||||
|
[target.'cfg(windows)'.dependencies]
|
||||||
|
windows = { version = "0.58", features = [
|
||||||
|
"Win32_Foundation",
|
||||||
|
"Win32_Storage_FileSystem",
|
||||||
|
"Win32_Storage_CloudFilters",
|
||||||
|
"Win32_System_IO",
|
||||||
|
"Win32_System_Com",
|
||||||
|
"Win32_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 = ¶ms.Anonymous.FetchData;
|
||||||
|
|
||||||
|
// FileIdentity enthaelt unsere Mini-Cloud-File-ID als UTF-8-Bytes.
|
||||||
|
let identity = std::slice::from_raw_parts(
|
||||||
|
info.FileIdentity as *const u8,
|
||||||
|
info.FileIdentityLength as usize,
|
||||||
|
);
|
||||||
|
let file_id: i64 = std::str::from_utf8(identity)
|
||||||
|
.ok()
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
let offset: i64 = fetch.RequiredFileOffset;
|
||||||
|
let length: u64 = fetch.RequiredLength as u64;
|
||||||
|
let connection_key = info.ConnectionKey;
|
||||||
|
let transfer_key = info.TransferKey;
|
||||||
|
|
||||||
|
// HTTPS-Download im separaten Thread (Callback darf nicht blockieren).
|
||||||
|
let ctx = ctx_snapshot();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod sync;
|
mod sync;
|
||||||
|
mod cloud_files;
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -26,6 +27,8 @@ struct AppState {
|
|||||||
sync_paths: Mutex<Vec<SyncPath>>,
|
sync_paths: Mutex<Vec<SyncPath>>,
|
||||||
journal: Arc<Journal>,
|
journal: Arc<Journal>,
|
||||||
background_started: AtomicBool,
|
background_started: AtomicBool,
|
||||||
|
cloud_files_loop: Mutex<Option<cloud_files::sync_loop::SyncLoopHandle>>,
|
||||||
|
cloud_files_watcher: Mutex<Option<cloud_files::watcher::CallbackWatcher>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth ---
|
// --- 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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
handle_cli_shortcuts();
|
||||||
handle_single_instance();
|
handle_single_instance();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -902,6 +1169,8 @@ pub fn run() {
|
|||||||
sync_paths: Mutex::new(Vec::new()),
|
sync_paths: Mutex::new(Vec::new()),
|
||||||
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
|
journal: Arc::new(Journal::open().expect("Journal konnte nicht geoeffnet werden")),
|
||||||
background_started: AtomicBool::new(false),
|
background_started: AtomicBool::new(false),
|
||||||
|
cloud_files_loop: Mutex::new(None),
|
||||||
|
cloud_files_watcher: Mutex::new(None),
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
// Close button = minimize to tray instead of quit
|
// Close button = minimize to tray instead of quit
|
||||||
@@ -1016,6 +1285,13 @@ pub fn run() {
|
|||||||
browse_sync_folder,
|
browse_sync_folder,
|
||||||
mark_offline,
|
mark_offline,
|
||||||
unmark_offline,
|
unmark_offline,
|
||||||
|
cloud_files_supported,
|
||||||
|
cloud_files_enable,
|
||||||
|
cloud_files_disable,
|
||||||
|
cloud_files_get_mount,
|
||||||
|
cloud_files_force_cleanup,
|
||||||
|
cloud_files_pin,
|
||||||
|
cloud_files_unpin,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ pub struct AppConfig {
|
|||||||
pub auto_start: bool,
|
pub auto_start: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub start_minimized: bool,
|
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 {
|
impl AppConfig {
|
||||||
|
|||||||
+123
-3
@@ -31,6 +31,75 @@ const newPathLocal = ref("");
|
|||||||
const newPathServerFolder = ref("");
|
const newPathServerFolder = ref("");
|
||||||
const newPathServerId = ref(null);
|
const newPathServerId = ref(null);
|
||||||
const newPathMode = ref("virtual");
|
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([]);
|
const serverFolders = ref([]);
|
||||||
|
|
||||||
// Local file browser
|
// Local file browser
|
||||||
@@ -289,6 +358,7 @@ function formatSize(b) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await checkCloudFilesSupport();
|
||||||
// Try auto-login with saved credentials
|
// Try auto-login with saved credentials
|
||||||
try {
|
try {
|
||||||
const saved = await invoke("load_saved_config");
|
const saved = await invoke("load_saved_config");
|
||||||
@@ -308,6 +378,15 @@ onMounted(async () => {
|
|||||||
if (syncPaths.value.length > 0) {
|
if (syncPaths.value.length > 0) {
|
||||||
await startSync();
|
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) {
|
} catch (err) {
|
||||||
syncStatus.value = "Auto-Login fehlgeschlagen";
|
syncStatus.value = "Auto-Login fehlgeschlagen";
|
||||||
// Show login screen with pre-filled fields
|
// Show login screen with pre-filled fields
|
||||||
@@ -387,8 +466,47 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- Sync Paths -->
|
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
|
||||||
<div class="section">
|
<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 →
|
||||||
|
"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">
|
<div class="section-header">
|
||||||
<h3>Sync-Pfade</h3>
|
<h3>Sync-Pfade</h3>
|
||||||
<div class="header-btns">
|
<div class="header-btns">
|
||||||
@@ -454,8 +572,8 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Local File Browser -->
|
<!-- Local File Browser (Legacy, nur fuer Full-Sync-Modus) -->
|
||||||
<div v-if="autoSyncActive" class="section" @click="hideContextMenu">
|
<div v-if="autoSyncActive && !cloudFilesActive" class="section" @click="hideContextMenu">
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h3>Lokale Dateien</h3>
|
<h3>Lokale Dateien</h3>
|
||||||
<button @click="loadLocalFiles(null)" class="btn-small">↻</button>
|
<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-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{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}
|
.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}
|
.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{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}
|
.tree-item.indent{padding-left:1.5rem}.tree-icon{flex-shrink:0}.tree-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
|||||||
Reference in New Issue
Block a user