Compare commits
13 Commits
d9a4ee6a0b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd40c55f7d | |||
| 78615d8897 | |||
| 3c340f9653 | |||
| 85dae4377f | |||
| 88c9617ae7 | |||
| 78cfbf1ad3 | |||
| 4026defe79 | |||
| 2937082ba2 | |||
| e55ce106d4 | |||
| 601e0741b1 | |||
| be121190b3 | |||
| 6274567219 | |||
| 204dbb6ab5 |
+63
-16
@@ -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'])
|
||||
|
||||
@@ -42,8 +42,10 @@ windows = { version = "0.58", features = [
|
||||
"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]
|
||||
|
||||
@@ -43,6 +43,8 @@ pub enum SyncState {
|
||||
|
||||
#[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;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -151,6 +151,28 @@ async fn upload_local_change(
|
||||
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)
|
||||
|
||||
@@ -91,18 +91,29 @@ pub fn register_sync_root(
|
||||
policies.Population = CF::CF_POPULATION_POLICY::default();
|
||||
policies.InSync = CF::CF_INSYNC_POLICY::default();
|
||||
|
||||
// Das Struct-Feld ist `CF_HYDRATION_POLICY` (u16-Wrapper um das
|
||||
// _PRIMARY-Enum). Direkter Feldzugriff:
|
||||
// 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_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 versuchen ohne UPDATE-Flag (frische Registrierung). Bei "schon
|
||||
// registriert"-Fehler nochmal mit UPDATE-Flag. So laeuft es beim ersten
|
||||
// Aktivieren UND bei Folgestarts.
|
||||
// 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()),
|
||||
@@ -110,28 +121,37 @@ pub fn register_sync_root(
|
||||
&policies,
|
||||
CF::CF_REGISTER_FLAG_NONE,
|
||||
) {
|
||||
let already = e.code().0 == 0x80070091u32 as i32 // DIR_NOT_EMPTY
|
||||
|| e.code().0 == 0x800710D1u32 as i32; // Already registered
|
||||
if already {
|
||||
CF::CfRegisterSyncRoot(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
&info,
|
||||
&policies,
|
||||
CF::CF_REGISTER_FLAG_UPDATE,
|
||||
)
|
||||
.map_err(|e| format!("CfRegisterSyncRoot(UPDATE): {e}"))?;
|
||||
} else {
|
||||
return Err(format!("CfRegisterSyncRoot: {e}"));
|
||||
}
|
||||
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> {
|
||||
disconnect_callbacks()?;
|
||||
// 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 {
|
||||
@@ -171,19 +191,75 @@ unsafe extern "system" fn on_fetch_data(
|
||||
// HTTPS-Download im separaten Thread (Callback darf nicht blockieren).
|
||||
let ctx = ctx_snapshot();
|
||||
std::thread::spawn(move || {
|
||||
let _ = transfer_range(connection_key, transfer_key, file_id, offset, length, ctx);
|
||||
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,
|
||||
ctx: &CloudContext,
|
||||
) -> Result<(), String> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
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('/'),
|
||||
@@ -195,22 +271,26 @@ fn transfer_range(
|
||||
.bearer_auth(&ctx.access_token)
|
||||
.header("Range", &range)
|
||||
.send()
|
||||
.map_err(|e| format!("download: {e}"))?;
|
||||
.map_err(|e| format!("send: {e}"))?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() && status.as_u16() != 206 {
|
||||
let _ = complete_transfer(connection_key, transfer_key, None, offset, length);
|
||||
return Err(format!("HTTP {}", status));
|
||||
}
|
||||
let bytes = resp
|
||||
.bytes()
|
||||
.map_err(|e: reqwest::Error| e.to_string())?;
|
||||
complete_transfer(
|
||||
connection_key,
|
||||
transfer_key,
|
||||
Some(&bytes),
|
||||
offset,
|
||||
length,
|
||||
)
|
||||
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(
|
||||
@@ -246,12 +326,40 @@ fn complete_transfer(
|
||||
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,
|
||||
@@ -295,6 +403,9 @@ pub fn populate_placeholders(
|
||||
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>(
|
||||
@@ -321,18 +432,37 @@ pub fn populate_placeholders(
|
||||
std::fs::create_dir_all(&p).ok();
|
||||
}
|
||||
|
||||
// Dann Dateien als Platzhalter
|
||||
// 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 let Err(err) =
|
||||
create_placeholder(&parent, &e.name, e.size, &e.modified_at, identity.as_bytes())
|
||||
{
|
||||
eprintln!("placeholder {}: {}", e.name, err);
|
||||
|
||||
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(())
|
||||
@@ -398,20 +528,24 @@ fn create_placeholder(
|
||||
|
||||
pub fn set_pin_state(file: &Path, pinned: bool) -> Result<(), String> {
|
||||
use windows::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_READ_ATTRIBUTES,
|
||||
FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
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.0,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
(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_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT,
|
||||
None,
|
||||
)
|
||||
}
|
||||
@@ -422,12 +556,84 @@ pub fn set_pin_state(file: &Path, pinned: bool) -> Result<(), String> {
|
||||
} else {
|
||||
CF::CF_PIN_STATE_UNPINNED
|
||||
};
|
||||
let res = unsafe {
|
||||
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);
|
||||
}
|
||||
res.map_err(|e| format!("CfSetPinState: {e}"))?;
|
||||
|
||||
// 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,6 +949,12 @@ async fn cloud_files_enable(
|
||||
|
||||
*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(())
|
||||
}
|
||||
|
||||
@@ -963,7 +969,33 @@ async fn cloud_files_disable(
|
||||
let _ = handle.tx.send(cloud_files::sync_loop::LoopMessage::Shutdown);
|
||||
}
|
||||
state.cloud_files_watcher.lock().unwrap().take();
|
||||
cloud_files::unregister_sync_root(&PathBuf::from(mount_point))
|
||||
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]
|
||||
@@ -998,8 +1030,15 @@ async fn fetch_remote_entries(
|
||||
.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>,
|
||||
@@ -1017,6 +1056,7 @@ async fn fetch_remote_entries(
|
||||
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
|
||||
@@ -1039,11 +1079,80 @@ async fn fetch_remote_entries(
|
||||
}
|
||||
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()
|
||||
@@ -1179,6 +1288,8 @@ pub fn run() {
|
||||
cloud_files_supported,
|
||||
cloud_files_enable,
|
||||
cloud_files_disable,
|
||||
cloud_files_get_mount,
|
||||
cloud_files_force_cleanup,
|
||||
cloud_files_pin,
|
||||
cloud_files_unpin,
|
||||
])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+67
-16
@@ -42,6 +42,27 @@ 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() {
|
||||
@@ -337,7 +358,7 @@ function formatSize(b) {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
checkCloudFilesSupport();
|
||||
await checkCloudFilesSupport();
|
||||
// Try auto-login with saved credentials
|
||||
try {
|
||||
const saved = await invoke("load_saved_config");
|
||||
@@ -357,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
|
||||
@@ -437,25 +467,46 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||
|
||||
<div class="content">
|
||||
<!-- Cloud-Files (Windows Cloud Files API, OneDrive-artig) -->
|
||||
<div v-if="cloudFilesSupported" 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>
|
||||
<div class="cf-row">
|
||||
<input v-model="cloudFilesMountPoint" placeholder="Ordner waehlen..." />
|
||||
<button class="btn-secondary" @click="browseCfMount">Durchsuchen</button>
|
||||
<button v-if="!cloudFilesActive" class="btn-primary" :disabled="!cloudFilesMountPoint || cloudFilesBusy" @click="enableCloudFiles">
|
||||
{{ cloudFilesBusy ? "Aktiviere..." : "Aktivieren" }}
|
||||
</button>
|
||||
<button v-else class="btn-secondary" :disabled="cloudFilesBusy" @click="disableCloudFiles">Deaktivieren</button>
|
||||
</div>
|
||||
<div v-if="cloudFilesError" class="error" style="margin-top:0.5rem">{{ cloudFilesError }}</div>
|
||||
<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 -->
|
||||
<div class="section">
|
||||
<!-- 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">
|
||||
@@ -521,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>
|
||||
|
||||
Reference in New Issue
Block a user