Compare commits
8 Commits
e55ce106d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd40c55f7d | |||
| 78615d8897 | |||
| 3c340f9653 | |||
| 85dae4377f | |||
| 88c9617ae7 | |||
| 78cfbf1ad3 | |||
| 4026defe79 | |||
| 2937082ba2 |
+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())
|
||||
}
|
||||
@@ -102,9 +102,18 @@ pub fn register_sync_root(
|
||||
// struct einbauen koennen. windows-rs verlangt hier nichts weiter.
|
||||
let _ = display_wide;
|
||||
|
||||
// Erst versuchen ohne UPDATE-Flag (frische Registrierung). Bei "schon
|
||||
// registriert"-Fehler nochmal mit UPDATE-Flag. So laeuft es beim ersten
|
||||
// Aktivieren UND bei Folgestarts.
|
||||
// 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()),
|
||||
@@ -112,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 {
|
||||
@@ -308,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,
|
||||
@@ -357,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>(
|
||||
@@ -383,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(())
|
||||
@@ -460,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,
|
||||
)
|
||||
}
|
||||
@@ -484,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1030,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>,
|
||||
@@ -1049,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
|
||||
@@ -1071,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()
|
||||
|
||||
@@ -504,8 +504,9 @@ onUnmounted(() => { unlistenStatus?.(); unlistenLog?.(); unlistenError?.(); unli
|
||||
</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">
|
||||
@@ -571,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