From 78cfbf1ad39ac128e03dc1c4ccf66fc11d94239e Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Thu, 23 Apr 2026 11:15:04 +0200 Subject: [PATCH] feat(cloud-files): Geteilte Ordner + Rechtsklick-Menue Backend: - /api/sync/tree liefert jetzt {tree, shared} - shared enthaelt alle Dateien die MIT dem Benutzer geteilt wurden (FilePermission), nur Top-Level-Shares, mit Owner-Name im Anzeigenamen - updated_at zusaetzlich als modified_at im Response fuer Client- Kompatibilitaet Client: - fetch_remote_entries merged Shared-Subtree unter virtuellem Ordner "Geteilt mit mir" (synthetische ID -1) in den Mount-Point - modified_at faellt auf updated_at zurueck, falls nicht vorhanden Kontextmenue: - Neue HKCU-Registry-Eintraege fuer "Immer offline verfuegbar" und "Speicher freigeben", AppliesTo filtert auf Mount-Pfad, sodass die Verben nur bei Dateien unterhalb des Sync-Ordners erscheinen - Aufruf der eigenen .exe mit --pin / --unpin - handle_cli_shortcuts fuehrt die Aktion aus und beendet sofort, ohne die UI/Tray/Single-Instance-Logik anzustossen Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/app/api/files.py | 79 +++++++++++++++---- .../src/cloud_files/shell_integration.rs | 55 ++++++++++++- clients/desktop/src-tauri/src/lib.rs | 52 ++++++++++++ 3 files changed, 169 insertions(+), 17 deletions(-) diff --git a/backend/app/api/files.py b/backend/app/api/files.py index 9866a0d..67f0866 100644 --- a/backend/app/api/files.py +++ b/backend/app/api/files.py @@ -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']) diff --git a/clients/desktop/src-tauri/src/cloud_files/shell_integration.rs b/clients/desktop/src-tauri/src/cloud_files/shell_integration.rs index 0e24bab..abbec92 100644 --- a/clients/desktop/src-tauri/src/cloud_files/shell_integration.rs +++ b/clients/desktop/src-tauri/src/cloud_files/shell_integration.rs @@ -102,12 +102,63 @@ pub fn install( ns.set_value("", &display_name.to_string()) .map_err(|e| format!("set ns name: {e}"))?; - // 7) Explorer informieren (SHChangeNotify) + // 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(); + let mount_str = mount_point.to_string_lossy(); + // AppliesTo filtert die Verben auf Pfade, die unter dem Mount liegen. + // Syntax: "System.ItemPathDisplay:~< \"C:\\Users\\..\\Mini-Cloud\\\"" + let applies_to = format!( + "System.ItemPathDisplay:~< \"{}\\\"", + mount_str.replace('\\', "\\\\") + ); + + for (verb, label, flag) in [ + ("MiniCloudPin", "Immer offline verfuegbar", "--pin"), + ("MiniCloudUnpin", "Speicher freigeben", "--unpin"), + ] { + let key_path = format!("Software\\Classes\\*\\shell\\{}", verb); + let (k, _) = hkcu + .create_subkey(&key_path) + .map_err(|e| format!("verb {verb}: {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"] { + let _ = hkcu.delete_subkey_all(format!("Software\\Classes\\*\\shell\\{}", verb)); + } +} + /// Entferne die Shell-Integration wieder. pub fn uninstall() -> Result<(), String> { let hkcu = RegKey::predef(HKEY_CURRENT_USER); @@ -121,6 +172,8 @@ pub fn uninstall() -> Result<(), String> { let clsid_path = format!("Software\\Classes\\CLSID\\{}", CLSID_GUID); let _ = hkcu.delete_subkey_all(&clsid_path); + uninstall_context_menu(); + notify_shell(); Ok(()) } diff --git a/clients/desktop/src-tauri/src/lib.rs b/clients/desktop/src-tauri/src/lib.rs index 0a0b458..16760d9 100644 --- a/clients/desktop/src-tauri/src/lib.rs +++ b/clients/desktop/src-tauri/src/lib.rs @@ -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, @@ -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,55 @@ 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 ` oder `--unpin ` fuehrt die +/// Aktion direkt aus und beendet. Kein UI, kein Tray. +#[cfg(windows)] +fn handle_cli_shortcuts() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + return; + } + let cmd = args[1].as_str(); + let path = std::path::PathBuf::from(&args[2]); + let result = match cmd { + "--pin" => cloud_files::pin_file(&path), + "--unpin" => cloud_files::unpin_file(&path), + _ => return, + }; + if let Err(e) = result { + eprintln!("[cloud_files CLI] {cmd} failed: {e}"); + std::process::exit(1); + } + std::process::exit(0); +} + +#[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()